<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://blog.openacid.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.openacid.com/" rel="alternate" type="text/html" /><updated>2026-08-27T04:52:45+00:00</updated><id>https://blog.openacid.com/feed.xml</id><title type="html">OpenACID Blog</title><subtitle>Distributed systems research notes.</subtitle><author><name>OpenACID</name></author><entry><title type="html">The Pitfalls of Raft Membership Change</title><link href="https://blog.openacid.com/distributed/raft-bug/" rel="alternate" type="text/html" title="The Pitfalls of Raft Membership Change" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://blog.openacid.com/distributed/raft-bug</id><content type="html" xml:base="https://blog.openacid.com/distributed/raft-bug/"><![CDATA[<p><img src="/post-res/raft-bug/4bcb4c62d2b23134-raft-bug-en-banner-small.webp" alt="" /></p>

<h1 id="the-problem">The Problem</h1>

<p>A while back, over coffee, a friend described a problem their team had hit with <a href="https://raft.github.io/">Raft</a> in production.
It looks like a small detail. It cost them a whole cluster.</p>

<p>Their implementation uses <a href="https://gist.github.com/ongardie/a11f32b70581e20d6bcd">single-server change</a>:
to change the replica set, you add or remove one node at a time.
Moving from <code class="language-plaintext highlighter-rouge">abc</code> to <code class="language-plaintext highlighter-rouge">bcd</code> takes two steps.
First add <code class="language-plaintext highlighter-rouge">d</code>, which gives <code class="language-plaintext highlighter-rouge">abcd</code>. Then remove <code class="language-plaintext highlighter-rouge">a</code>, which gives <code class="language-plaintext highlighter-rouge">bcd</code>.</p>

<p>The trouble lives in the middle step.
While the cluster has four nodes, a network split of the shape <code class="language-plaintext highlighter-rouge">ad | bc</code> leaves it unable to elect a leader.
That shape of split is easy to get when the nodes live in different datacenters.
Say <code class="language-plaintext highlighter-rouge">a</code>, <code class="language-plaintext highlighter-rouge">b</code> and <code class="language-plaintext highlighter-rouge">c</code> each sit in their own datacenter:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
 a      b      c
----   ----   ----
DC-1   DC-2   DC-3

        | add `d` in DC-1
        v

 a      b      c     partitioned     a   |  b      c
 d                   no leader!      d   |
----   ----   ----   ------------&gt;  ---- | ----   ----
DC-1   DC-2   DC-3                  DC-1 | DC-2   DC-3

        | remove `a`,
        | healthy again
        v

        b      c
 d
----   ----   ----
DC-1   DC-2   DC-3

</code></pre></div></div>

<ul>
  <li>
    <p>In the steady state, the cluster has three nodes. If any one datacenter loses contact with the outside,
the other two still hold a majority. They elect a leader and keep serving.</p>
  </li>
  <li>
    <p>In the middle state, DC-1 holds two nodes, <code class="language-plaintext highlighter-rouge">a</code> and <code class="language-plaintext highlighter-rouge">d</code>.
A majority of four nodes needs three nodes.
DC-1 has only two, so it cannot elect a leader on its own.
DC-2 and DC-3 together also have only two, so they cannot either.
One datacenter going quiet now stops the whole cluster.</p>
  </li>
</ul>

<p>Look at what happened to DC-1. In the four-node middle state, every majority has to include a node from DC-1.
DC-1 became a <strong>single point of failure</strong>, and only because a membership change was in progress.</p>

<p>The root cause is that single-server change is rigid about one thing:
a quorum is always a majority, and never anything else.
Once we let go of that rule, the problem dissolves.
So we will look at membership change through quorum sets,
and that view leads us straight to joint consensus.</p>

<p>If quorum sets are new to you, my earlier article
<a href="https://blog.openacid.com/algo/quorum/">A Minority Implementation of Majority Read/Write</a> introduces the way of thinking we use below.</p>

<h1 id="looking-at-the-problem-through-quorum-sets">Looking at the Problem Through Quorum Sets</h1>

<p>Instead of describing a cluster by its nodes, describe it by its quorums.
A quorum is a group of nodes that is allowed to commit something.
The list of all such groups is the quorum set.</p>

<p>One rule keeps a quorum set safe: <strong>any two groups in it must share at least one node.</strong>
That shared node is what stops two different values from both being committed,
and it is the only thing Paxos and Raft really need from a quorum.</p>

<p>Here are the quorum sets in our story:</p>

<ul>
  <li>
    <p>The starting state <code class="language-plaintext highlighter-rouge">abc</code> uses every majority of <code class="language-plaintext highlighter-rouge">abc</code>: M(abc) = {ab, ac, bc}.
The full group <code class="language-plaintext highlighter-rouge">abc</code> is a quorum too, but it already contains <code class="language-plaintext highlighter-rouge">ab</code>,
and any group that contains a quorum is a quorum.
So listing the bigger groups adds nothing, and we list only the smallest ones.</p>
  </li>
  <li>
    <p>The final state <code class="language-plaintext highlighter-rouge">bcd</code> uses M(bcd) = {bc, cd, bd}.</p>
  </li>
  <li>
    <p>The middle state <code class="language-plaintext highlighter-rouge">abcd</code> of a single-server change is again a majority set:
M(abcd) = {abc, abd, acd, bcd}.</p>
  </li>
</ul>

<p>So a single-server change is a walk through three quorum sets:</p>

<p><strong>M(abc) → M(abcd) → M(bcd)</strong></p>

<p>Now the availability problem has a one-line explanation.
Every quorum in the middle set needs three nodes.
When the network splits into <code class="language-plaintext highlighter-rouge">ad | bc</code>, neither side has three nodes.
Neither side can elect a leader, and the cluster stops.</p>

<h2 id="a-first-patch-let">A First Patch: Let</h2>
<p><code class="language-plaintext highlighter-rouge">bc</code>
 Commit</p>

<p>The middle set is where it hurts, so let us change the middle set.
Suppose we also allow <code class="language-plaintext highlighter-rouge">bc</code> to commit. The middle quorum set becomes:</p>

<p>Q(abcd) = M(abcd) ∪ {bc}</p>

<p>In plain words: <strong>an entry is committed once it reaches <code class="language-plaintext highlighter-rouge">bc</code>, or any three of the four nodes.</strong></p>

<p>This is safe, and the check is quick.
<code class="language-plaintext highlighter-rouge">bc</code> shares a node with every group in M(abcd), because <code class="language-plaintext highlighter-rouge">bc</code> has two nodes,
each three-node majority leaves out only one node, and it cannot leave out both <code class="language-plaintext highlighter-rouge">b</code> and <code class="language-plaintext highlighter-rouge">c</code>.
So the sharing rule still holds across the whole set.
Paxos and Raft run on this middle state exactly as before, and consistency is untouched.</p>

<p>The change now reads: M(abc) → M(abcd) ∪ {bc} → M(bcd).</p>

<p>It is also still a legal membership change.
Raft’s safety argument for a single-server change asks for one thing:
every quorum of the old node set must share a node with every quorum of the new one.
Our added group <code class="language-plaintext highlighter-rouge">bc</code> meets <code class="language-plaintext highlighter-rouge">ab</code> at <code class="language-plaintext highlighter-rouge">b</code>, meets <code class="language-plaintext highlighter-rouge">ac</code> at <code class="language-plaintext highlighter-rouge">c</code>, and meets <code class="language-plaintext highlighter-rouge">bc</code> at both.
So <strong>if Raft is safe for M(abc) → M(abcd), it is equally safe for M(abc) → M(abcd) ∪ {bc}</strong>.
The same check passes for the second step, M(abcd) ∪ {bc} → M(bcd).</p>

<p>That treats the symptom. The cluster now survives the <code class="language-plaintext highlighter-rouge">ad | bc</code> split while the change is running.</p>

<h2 id="why-majority-runs-out-of-room">Why Majority Runs Out of Room</h2>

<p>The four-node state has this weakness for a reason worth naming:
<strong>M(abcd) is not the largest safe quorum set for four nodes.</strong></p>

<p>With an odd number of nodes, majority is the best you can do.
Take three nodes <code class="language-plaintext highlighter-rouge">abc</code>: you cannot add any smaller group to {ab, ac, bc},
because a single node such as <code class="language-plaintext highlighter-rouge">a</code> fails to meet <code class="language-plaintext highlighter-rouge">bc</code>.
Majority is already maximal, so nothing is lost.</p>

<p>With an even number of nodes, majority leaves quorums on the table.
A four-node system has four three-node majorities.
On top of those, it can safely hold three more two-node groups:</p>

<p>Q’(abcd) = M(abcd) ∪ <strong>{ab, bc, ac}</strong></p>

<p>Every pair in Q’(abcd) shares a node.
<code class="language-plaintext highlighter-rouge">ab</code> and <code class="language-plaintext highlighter-rouge">bc</code> share <code class="language-plaintext highlighter-rouge">b</code>. <code class="language-plaintext highlighter-rouge">ab</code> and <code class="language-plaintext highlighter-rouge">ac</code> share <code class="language-plaintext highlighter-rouge">a</code>. <code class="language-plaintext highlighter-rouge">bc</code> and <code class="language-plaintext highlighter-rouge">ac</code> share <code class="language-plaintext highlighter-rouge">c</code>.
Each two-node group also meets each three-node group, because two plus three is more than four.
Paxos and Raft run on Q’(abcd) with no changes at all, and it tolerates strictly more failures than M(abcd).</p>

<p><strong>Majority is the first weak spot in Raft’s design.</strong>
By writing majority into the algorithm, Raft gives away availability that an even-sized cluster could have had.</p>

<h2 id="how-to-expand-a-majority">How to Expand a Majority</h2>

<p>Here is the general recipe. Let the node set be C, for example C = {a,b,c}.</p>

<ul>
  <li>
    <p>For an odd node count, n = 2k+1, keep the majorities. They are already maximal:</p>

    <p><img src="https://www.zhihu.com/equation?tex=Q_%7Bodd%7D%28C%29%20%3D%20M%28C%29%20%3D%20%5C%7B%20q%20%3A%20q%20%5Csubseteq%20C%2C%20%20%7Cq%7C%20%5Cgt%20%7CC%7C/2%20%5C%7D%5C%5C" alt="Q_{odd}(C) = M(C) = \{ q : q \subseteq C,  |q| \gt |C|/2 \}\\" class="ee_img tr_noresize" eeimg="1" /></p>
  </li>
  <li>
    <p>For an even node count, n = 2k, notice that <strong>any n/2 nodes must share a node with any n/2+1 nodes</strong>:
together they count n+1 nodes in a cluster of only n.
So we may add groups of size n/2 to M(C).
The only extra thing to check is that the added groups share nodes with each other.</p>

    <p>In our four-node example:</p>

    <ul>
      <li>Q’ = M(abcd) ∪ {ab, bc, ca} works: the three added groups pairwise share a node.</li>
      <li>Q’ = M(abcd) ∪ {bc, cd, bd} works for the same reason.</li>
      <li>Q’ = M(abcd) ∪ {ab, bc, cd} does not work: <code class="language-plaintext highlighter-rouge">ab</code> and <code class="language-plaintext highlighter-rouge">cd</code> share nothing,
so two leaders could be elected at the same time.</li>
    </ul>

    <p>There is an easy way to produce a good one.
Treat the even cluster as an odd cluster C plus one extra node x:</p>

    <p><img src="https://www.zhihu.com/equation?tex=%20D%20%3D%20C%20%5Ccup%20%5C%7Bx%5C%7D%20%5C%5C" alt=" D = C \cup \{x\} \\" class="ee_img tr_noresize" eeimg="1" /></p>

    <p>Then the quorum set for the even cluster can be an expansion of M(D):</p>

    <p><img src="https://www.zhihu.com/equation?tex=Q_%7Beven%7D%28D%29_x%20%3D%20M%28D%29%20%5Ccup%20M%28D%20%5Csetminus%20%5C%7Bx%5C%7D%29%5C%5C" alt="Q_{even}(D)_x = M(D) \cup M(D \setminus \{x\})\\" class="ee_img tr_noresize" eeimg="1" /></p>

    <p>In words: keep every majority of the four nodes, and also accept every majority of the three nodes left when you ignore x.
Picking x = d produces the first example above, and picking x = a produces the second.
Both hold more quorums than M(abcd), so both are more available, and both survive the datacenter split we started with.</p>
  </li>
</ul>

<h1 id="what-the-middle-state-really-needs">What the Middle State Really Needs</h1>

<p>Those examples make one thing clear.
The middle state of a membership change does not have to be a majority set.
It only has to be safe, and for our datacenter problem it has to contain <code class="language-plaintext highlighter-rouge">bc</code>.</p>

<p>Several middle states qualify:</p>

<ul>
  <li>M(abcd) ∪ {ab, bc, ac},</li>
  <li>{abc, abd, acd, bcd, bc},</li>
  <li>and even {abd, acd, bcd, bc}, with <code class="language-plaintext highlighter-rouge">abc</code> dropped.</li>
</ul>

<p>Joint consensus qualifies too. It looks complicated on paper, and it turns out to be the simplest of all.</p>

<h2 id="the-correctness-conditions">The Correctness Conditions</h2>

<p>Before comparing algorithms, let us write down what a membership change has to guarantee.
Describe each state by its quorum set, the way we have been doing, and let the change go from Q₁ to Q₂.
It has to meet three conditions:</p>

<ul>
  <li>
    <p><strong>A committed change stays visible.</strong>
If one change is already committed, every uncommitted change must be recognizable as uncommitted.
Otherwise a new leader cannot tell which of them to keep.</p>
  </li>
  <li>
    <p><strong>Concurrent changes exclude each other.</strong>
Only one of several concurrent changes may succeed,
so every process proposing a change must commit it against the same quorum set.
The only thing all processes already agree on is Q₁.
So a change must be committed to Q₁, or to an expansion of Q₁ that every process derives in the same way.</p>
  </li>
  <li>
    <p><strong>The change reaches the new configuration.</strong>
It must also be committed to a quorum of Q₂.
Otherwise a leader elected under Q₂ may never see it.</p>
  </li>
</ul>

<p>Raft’s original single-server change misses the first condition. The author fixed it later, and we will come back to that.</p>

<h2 id="joint-consensus-gives-us-exactly-that">Joint Consensus Gives Us Exactly That</h2>

<p>Joint consensus meets all three conditions.
It also handles our datacenter problem, without anyone designing it for that.</p>

<p>In a change from <code class="language-plaintext highlighter-rouge">abc</code> to <code class="language-plaintext highlighter-rouge">bcd</code>, the joint middle state is the product of the two majority sets:</p>

<p>Q = M(abc) x M(bcd)</p>

<p>A joint quorum is any group that contains one quorum of M(abc) and one quorum of M(bcd) at the same time.
With M(abc) = {ab, bc, ca} and M(bcd) = {bc, cd, bd}, the product is:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>M(abc) x M(bcd) = {
    ab ∪ bc,
    ab ∪ cd,
    ab ∪ bd,
    bc ∪ bc,
    bc ∪ cd,
    bc ∪ bd,
    ac ∪ bc,
    ac ∪ cd,
    ac ∪ bd,
} = {
    abc,
    abcd,
    abd,
    acd,
    bc,
    bcd,
}
</code></pre></div></div>

<p><strong>That is exactly M(abcd) ∪ {bc}</strong> — the very quorum set we built by hand a few sections ago.</p>

<p>So joint consensus hands us everything we were after:</p>

<ul>
  <li>It tolerates one node failure.</li>
  <li>It always contains <code class="language-plaintext highlighter-rouge">bc</code>, so it survives the <code class="language-plaintext highlighter-rouge">ad | bc</code> split that started this article.</li>
  <li>The whole change finishes with two committed log entries, whether or not the leader changes along the way.</li>
</ul>

<h1 id="the-bug-in-single-server-change">The Bug in Single-Server Change</h1>

<p>Single-server change has a second problem, and this one is heavier than availability.
As first published, it was simply incorrect.</p>

<p>The bug appears when a leader change and a membership change run at the same time.
The author announced it in 2015:</p>

<blockquote>
  <p>Unfortunately, I need to announce a bug in the dissertation version of
membership changes (the single-server changes, not joint consensus). The bug is
potentially severe, but the fix I’m proposing is easy to implement.</p>
</blockquote>

<p>Here is how it goes wrong.
The cluster starts with the four nodes <code class="language-plaintext highlighter-rouge">abcd</code>.
One process wants to add <code class="language-plaintext highlighter-rouge">u</code>, another wants to add <code class="language-plaintext highlighter-rouge">v</code>.
A leader change in the middle loses a committed entry:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>C₀ = {a, b, c, d}
Cᵤ = C₀ ∪ {u}
Cᵥ = C₀ ∪ {v}

Lᵢ: Leader in term `i`
Fᵢ: Follower in term `i`
☒ : crash

    |
 u  |         Cᵤ                  F₂  Cᵤ
--- | ----------------------------------
 a  | C₀  L₀  Cᵤ  ☒               L₂  Cᵤ
 b  | C₀  F₀          F₁          F₂  Cᵤ
 c  | C₀  F₀          F₁  Cᵥ          Cᵤ
 d  | C₀              L₁  Cᵥ  ☒       Cᵤ
--- | ----------------------------------
 v  |                     Cᵥ                  time
    +--------------------------------------------&gt;
          t₁  t₂  t₃  t₄  t₅  t₆  t₇  t₈
</code></pre></div></div>

<ul>
  <li>t₁: the four nodes <code class="language-plaintext highlighter-rouge">abcd</code> elect <code class="language-plaintext highlighter-rouge">a</code> as leader in term 0, with followers <code class="language-plaintext highlighter-rouge">b</code> and <code class="language-plaintext highlighter-rouge">c</code>.</li>
  <li>t₂: <code class="language-plaintext highlighter-rouge">a</code> appends a change entry <code class="language-plaintext highlighter-rouge">Cᵤ</code> and switches to the new config <code class="language-plaintext highlighter-rouge">Cᵤ</code> right away. The entry reaches only <code class="language-plaintext highlighter-rouge">a</code> and <code class="language-plaintext highlighter-rouge">u</code>, so it is not committed.</li>
  <li>t₃: <code class="language-plaintext highlighter-rouge">a</code> crashes.</li>
  <li>t₄: <code class="language-plaintext highlighter-rouge">d</code> is elected leader in term 1, with followers <code class="language-plaintext highlighter-rouge">b</code> and <code class="language-plaintext highlighter-rouge">c</code>.</li>
  <li>t₅: <code class="language-plaintext highlighter-rouge">d</code> appends another change entry <code class="language-plaintext highlighter-rouge">Cᵥ</code> and switches to <code class="language-plaintext highlighter-rouge">Cᵥ</code>. The entry reaches <code class="language-plaintext highlighter-rouge">c</code>, <code class="language-plaintext highlighter-rouge">d</code> and <code class="language-plaintext highlighter-rouge">v</code>, which is a majority of the five nodes in <code class="language-plaintext highlighter-rouge">Cᵥ</code>, so it is committed.</li>
  <li>t₆: <code class="language-plaintext highlighter-rouge">d</code> crashes.</li>
  <li>t₇: <code class="language-plaintext highlighter-rouge">a</code> comes back and is elected leader in term 2. It runs under <code class="language-plaintext highlighter-rouge">Cᵤ</code>, the config it sees in its own log, and collects votes from <code class="language-plaintext highlighter-rouge">u</code> and <code class="language-plaintext highlighter-rouge">b</code>.</li>
  <li>t₈: <code class="language-plaintext highlighter-rouge">a</code> replicates its own log to everyone, and the committed <code class="language-plaintext highlighter-rouge">Cᵥ</code> is gone.</li>
</ul>

<p>Read t₅ and t₈ together, because that is where the damage is.
<code class="language-plaintext highlighter-rouge">Cᵥ</code> was committed under every rule Raft gives us, and then it was overwritten.
The reason is that <code class="language-plaintext highlighter-rouge">a</code> was allowed to run an election under a configuration nobody else had ever committed.</p>

<p>The author’s fix is short, and it echoes a rule Raft already has for ordinary entries:</p>

<blockquote>
  <p>The solution I’m proposing is exactly like the dissertation describes except
that a leader may not append a new configuration entry until it has committed
an entry from its current term.</p>
</blockquote>

<p>In our timeline, <code class="language-plaintext highlighter-rouge">d</code> must commit a no-op entry in term 1 before it may append <code class="language-plaintext highlighter-rouge">Cᵥ</code>.
Once <code class="language-plaintext highlighter-rouge">b</code> and <code class="language-plaintext highlighter-rouge">c</code> hold that no-op, <code class="language-plaintext highlighter-rouge">a</code> can no longer win the term-2 election:
<code class="language-plaintext highlighter-rouge">b</code> sees that <code class="language-plaintext highlighter-rouge">a</code>’s log is behind and refuses to vote for it.
So <code class="language-plaintext highlighter-rouge">a</code> never becomes L₂, and the committed <code class="language-plaintext highlighter-rouge">Cᵥ</code> survives.</p>

<h2 id="look-closely-at-that-fix">Look Closely at That Fix</h2>

<p>The fix quietly turns single-server change into joint consensus.</p>

<p>Both end up doing the same job.
A change has to pass through a quorum of the old configuration first,
so that only one change out of several concurrent ones can be considered committed.
Single-server change reaches that point with an extra entry:
an ordinary application entry if one is handy, or a no-op if not.
Joint consensus reaches it directly, because its middle state already is
the old configuration and the new configuration at the same time.</p>

<p><strong>A correct single-server change costs two log commits, every time.</strong></p>

<p>Single-server change was proposed to make things simpler, and it does not.
Changing <code class="language-plaintext highlighter-rouge">abc</code> to <code class="language-plaintext highlighter-rouge">bcd</code> costs 2 to 4 log entries with single-server change.
With joint consensus it costs 2.</p>

<p>There is a fair objection here: single-server change often needs only 2 entries,
since the leader usually has a committed entry of its own term already and no no-op is required.
That is true, and it does not help.
Code is not a bet on probability.
Every branch that can run has to be written, tested and maintained,
including the one that fires once in ten thousand changes.
So a correct single-server change carries almost the same logic as joint consensus,
implements a two-step change anyway, and wins nothing at runtime.</p>

<h1 id="closing-thoughts">Closing Thoughts</h1>

<p>Raft is a beautiful bridge from theory to working code,
and that beauty is exactly why one design mistake in it travelled so far.</p>

<p>If you are building or maintaining a Raft implementation, the advice is short: use joint consensus.
It closes the availability hole in the middle state,
it removes the bug, and it is less code than a correct single-server change.</p>

<p>Reference:</p>

<ul>
  <li>
    <p>多数派读写的少数派实现 : <a href="https://blog.openacid.com/algo/quorum/">https://blog.openacid.com/algo/quorum/</a></p>
  </li>
  <li>
    <p>Raft : <a href="https://raft.github.io/">https://raft.github.io/</a></p>
  </li>
  <li>
    <p>Single-server membership change : <a href="https://gist.github.com/ongardie/a11f32b70581e20d6bcd">https://gist.github.com/ongardie/a11f32b70581e20d6bcd</a></p>
  </li>
</ul>]]></content><author><name>Zhang Yanpo (drdr.xp)</name></author><category term="distributed" /><category term="raft" /><category term="paxos" /><category term="consensus" /><category term="membership" /><summary type="html"><![CDATA[Raft's single-server membership change carries two problems: its middle state can turn one datacenter into a single point of failure, and the original algorithm could lose a committed entry. Let us look at membership change through quorum sets, and see why joint consensus is the answer.]]></summary></entry><entry><title type="html">Build a Distributed Timestamp Oracle with EzRaft</title><link href="https://blog.openacid.com/algo/ez-ts-oracle/" rel="alternate" type="text/html" title="Build a Distributed Timestamp Oracle with EzRaft" /><published>2026-08-11T00:00:00+00:00</published><updated>2026-08-11T00:00:00+00:00</updated><id>https://blog.openacid.com/algo/ez-ts-oracle</id><content type="html" xml:base="https://blog.openacid.com/algo/ez-ts-oracle/"><![CDATA[<p><img src="/post-res/ez-ts-oracle/b43c2a98b06d02ef-ez-time-server-banner.png" alt="" /></p>

<p>A timestamp oracle issues one globally increasing sequence. Transaction systems built on <a href="https://en.wikipedia.org/wiki/Multiversion_concurrency_control">MVCC</a> consume two of those values on every commit, so the oracle sits on the critical path of every write. The naive way to keep the sequence safe is one Raft commit per request. That design is correct. It is also too slow.</p>

<p>This article builds the other design: reserve a range with one Raft write, then allocate from memory. The service is <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs">timestamp_oracle</a>, about 200 lines on <a href="https://github.com/drmingdrmer/ezraft">EzRaft</a>. It runs as a three-node cluster, stays available after any one node fails, and answers <code class="language-plaintext highlighter-rouge">next_timestamp</code> without waiting for a Raft commit.</p>

<p><a href="https://github.com/drmingdrmer/ezraft">EzRaft</a>, from the <a href="https://blog.openacid.com/algo/ezraft/">previous article</a>, is a small <a href="https://www.rust-lang.org/">Rust</a> API over <a href="https://github.com/databendlabs/openraft">OpenRaft</a>. It hides the <a href="https://raft.github.io/">Raft</a>, networking, and storage machinery, so you can start with the service itself.</p>

<p>Three calls carry the design:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">EzRaft::write()</code> commits the upper bound of a reserved range and applies it to the state machine.</li>
  <li><code class="language-plaintext highlighter-rouge">EzRaft::wait_metrics()</code> lets a background task sleep until its node becomes the leader.</li>
  <li><code class="language-plaintext highlighter-rouge">EzRaft::linearizable(ReadPolicy::LeaseRead)</code> checks the leader lease and returns the current term, so each cached range is bound to one term.</li>
</ul>

<p>The idea underneath those calls is simple. Raft owns a ceiling. The leader spends from below that ceiling in memory. A lease and a term tag decide whether it is still allowed to spend.</p>

<p><img src="/post-res/ez-ts-oracle/0830bc2afe95fe2a-time-server-architecture.png" alt="Architecture overview: `next_timestamp` requests allocate timestamps from the leader's memory, while the reservation task connects the in-memory cache to a three-node EzRaft cluster" /></p>

<h2 id="what-a-timestamp-oracle-must-guarantee">What a Timestamp Oracle Must Guarantee</h2>

<p>The interface is one call: a client sends <code class="language-plaintext highlighter-rouge">next_timestamp</code>, and the service returns a timestamp.</p>

<p>Those values usually order distributed transactions. Models such as <a href="https://www.usenix.org/legacy/event/osdi10/tech/full_papers/Peng.pdf">Percolator</a> give each transaction a start timestamp and a commit timestamp. Those two numbers decide which transaction came first. TiDB’s <a href="https://docs.pingcap.com/tidb/stable/glossary/#timestamp-oracle-tso">TSO (Timestamp Oracle)</a> is the same component.</p>

<p>The service has three hard requirements:</p>

<ol>
  <li>
    <p>Monotonicity. Timestamps increase in the order requests reach the oracle, and they never go backward after a failover. Every timestamp from the new leader must be greater than every timestamp the old leader already returned.</p>
  </li>
  <li>
    <p>Fault tolerance. The service stays available while a majority of the cluster is alive. OpenRaft already provides that, so a three-node cluster can lose any one node and keep issuing timestamps.</p>
  </li>
  <li>
    <p>Low latency. <code class="language-plaintext highlighter-rouge">next_timestamp</code> sits on the critical path of every operation that needs an ordering point, so each request has to be cheap.</p>
  </li>
</ol>

<p>There is a softer requirement as well. The values should stay close to wall-clock time. Correctness only requires the sequence to increase. Closeness to wall-clock time is a quality goal, not a safety property.</p>

<h2 id="safe-baseline-one-raft-write-per-timestamp">Safe Baseline: One Raft Write per Timestamp</h2>

<p>The cluster only has to agree on one durable value: <code class="language-plaintext highlighter-rouge">reserved_end</code>, the exclusive end of all timestamp space reserved so far. Every value below <code class="language-plaintext highlighter-rouge">reserved_end</code> is permanently consumed, whether or not a client ever received it.</p>

<p>The obvious safe design writes one Raft log entry for every <code class="language-plaintext highlighter-rouge">next_timestamp</code> request. Once that entry is replicated and applied, the state machine returns the timestamp it claimed. Every leader walks the same log, so a failover cannot reuse an earlier value.</p>

<p>The state machine is correspondingly small:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">Reserve</span> <span class="p">{</span>
    <span class="n">reserve_upto_us</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">struct</span> <span class="n">Interval</span> <span class="p">{</span>
    <span class="n">start</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>
    <span class="n">end</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">struct</span> <span class="n">TimeState</span> <span class="p">{</span>
    <span class="n">reserved_end</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">impl</span> <span class="n">EzApp</span> <span class="k">for</span> <span class="n">TimeState</span> <span class="p">{</span>
    <span class="k">type</span> <span class="n">Request</span> <span class="o">=</span> <span class="n">Reserve</span><span class="p">;</span>
    <span class="k">type</span> <span class="n">Response</span> <span class="o">=</span> <span class="n">Interval</span><span class="p">;</span>

    <span class="k">async</span> <span class="k">fn</span> <span class="nf">apply</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">req</span><span class="p">:</span> <span class="n">Reserve</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="n">Interval</span> <span class="p">{</span>
        <span class="k">let</span> <span class="n">start</span> <span class="o">=</span> <span class="k">self</span><span class="py">.reserved_end</span><span class="p">;</span>
        <span class="k">let</span> <span class="n">end</span> <span class="o">=</span> <span class="n">start</span><span class="nf">.max</span><span class="p">(</span><span class="n">req</span><span class="py">.reserve_upto_us</span><span class="p">);</span>
        <span class="k">self</span><span class="py">.reserved_end</span> <span class="o">=</span> <span class="n">end</span><span class="p">;</span>
        <span class="n">Interval</span> <span class="p">{</span> <span class="n">start</span><span class="p">,</span> <span class="n">end</span> <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Reserve</code> asks the state machine to move <code class="language-plaintext highlighter-rouge">reserved_end</code> to at least <code class="language-plaintext highlighter-rouge">reserve_upto_us</code>. The response is the newly claimed half-open interval <code class="language-plaintext highlighter-rouge">[start, end)</code>. Those types and <code class="language-plaintext highlighter-rouge">apply()</code> are all that [<code class="language-plaintext highlighter-rouge">EzApp</code>][docs-ezraft-ezapp] needs. See <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L66-L112">timestamp_oracle.rs:66-112</a>.</p>

<p>The <code class="language-plaintext highlighter-rouge">max</code> in <code class="language-plaintext highlighter-rouge">apply()</code> makes the transition idempotent. Applying the same upper bound again leaves <code class="language-plaintext highlighter-rouge">reserved_end</code> unchanged, so a timed-out reservation can be retried safely.</p>

<p>This design is safe. It is also slow: every request pays for Raft replication and state-machine application. The rest of the article takes both of those costs off the request path.</p>

<h2 id="fast-path-reserve-timestamps-in-batches">Fast Path: Reserve Timestamps in Batches</h2>

<p>One Raft entry can reserve a wide range instead of a single value. By default the service reserves one second of timestamp space, then the leader hands out individual values from that range in memory.</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">reserved_end</code> is durable. The leader’s position inside the reserved range is not: it lives only in memory.
If the leader fails, its successor skips the unused suffix and reserves a new range.
The sequence may contain gaps. It never goes backward.</p>
</blockquote>

<p>A background reservation task keeps the leader’s cache filled. Each successful reservation is stored in a <code class="language-plaintext highlighter-rouge">Reserved</code> value with three fields: the term that owns the range, the next available timestamp, and the exclusive upper bound.</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">Reserved</span> <span class="p">{</span>
    <span class="n">term</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>
    <span class="n">next</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>
    <span class="n">end</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">run_reserver()</code> uses <code class="language-plaintext highlighter-rouge">wait_metrics()</code> to wait until the node becomes the leader. Once it is, the task calls <code class="language-plaintext highlighter-rouge">refill()</code> and repeats every half-reservation interval. See <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L170-L186">timestamp_oracle.rs:170-186</a>:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">TimeService</span> <span class="p">{</span>
    <span class="c1">// ...</span>
    <span class="n">reserved</span><span class="p">:</span> <span class="n">Mutex</span><span class="o">&lt;</span><span class="n">Reserved</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">impl</span> <span class="n">TimeService</span> <span class="p">{</span>
    <span class="k">async</span> <span class="k">fn</span> <span class="nf">run_reserver</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">let</span> <span class="n">refresh_interval</span> <span class="o">=</span> <span class="nn">Duration</span><span class="p">::</span><span class="nf">from_micros</span><span class="p">(</span><span class="k">self</span><span class="py">.reservation_width</span><span class="nf">.get</span><span class="p">()</span> <span class="o">/</span> <span class="mi">2</span><span class="p">);</span>
        <span class="k">loop</span> <span class="p">{</span>
            <span class="k">let</span> <span class="n">leads</span> <span class="o">=</span> <span class="k">self</span><span class="py">.raft</span><span class="nf">.wait_metrics</span><span class="p">(</span><span class="nb">None</span><span class="p">,</span> <span class="p">|</span><span class="n">m</span><span class="p">|</span> <span class="n">m</span><span class="py">.state</span> <span class="o">==</span> <span class="nn">ServerState</span><span class="p">::</span><span class="n">Leader</span><span class="p">,</span> <span class="s">"reserve timestamps"</span><span class="p">);</span>
            <span class="k">if</span> <span class="k">let</span> <span class="nf">Err</span><span class="p">(</span><span class="n">error</span><span class="p">)</span> <span class="o">=</span> <span class="n">leads</span><span class="k">.await</span> <span class="p">{</span>
                <span class="k">return</span><span class="p">;</span>
            <span class="p">}</span>
            <span class="k">if</span> <span class="k">let</span> <span class="nf">Err</span><span class="p">(</span><span class="n">error</span><span class="p">)</span> <span class="o">=</span> <span class="k">self</span><span class="nf">.refill</span><span class="p">()</span><span class="k">.await</span> <span class="p">{</span>
                <span class="nd">warn!</span><span class="p">(</span><span class="s">"{error}"</span><span class="p">);</span>
            <span class="p">}</span>
            <span class="nn">tokio</span><span class="p">::</span><span class="nn">time</span><span class="p">::</span><span class="nf">sleep</span><span class="p">(</span><span class="n">refresh_interval</span><span class="p">)</span><span class="k">.await</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">refill()</code> first obtains a valid leader term. It then computes <code class="language-plaintext highlighter-rouge">now + width</code>, commits that upper bound through Raft, and stores the returned interval in <code class="language-plaintext highlighter-rouge">TimeService.reserved</code>. See <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L188-L205">timestamp_oracle.rs:188-205</a>:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">fn</span> <span class="nf">refill</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nn">io</span><span class="p">::</span><span class="nb">Result</span><span class="o">&lt;</span><span class="p">()</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="c1">// ensure leader lease and get leader term.</span>
    <span class="k">let</span> <span class="n">term</span> <span class="o">=</span> <span class="k">self</span><span class="nf">.leader_term</span><span class="p">()</span><span class="k">.await</span><span class="o">?</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">now</span> <span class="o">=</span> <span class="nf">unix_timestamp_micros</span><span class="p">();</span>
    <span class="k">let</span> <span class="n">width</span> <span class="o">=</span> <span class="k">self</span><span class="py">.reservation_width</span><span class="nf">.get</span><span class="p">();</span>
    <span class="k">let</span> <span class="n">upto</span> <span class="o">=</span> <span class="n">now</span><span class="nf">.saturating_add</span><span class="p">(</span><span class="n">width</span><span class="p">);</span>

    <span class="k">let</span> <span class="n">interval</span> <span class="o">=</span> <span class="k">self</span><span class="py">.raft</span><span class="nf">.write</span><span class="p">(</span><span class="n">Reserve</span> <span class="p">{</span> <span class="n">reserve_upto_us</span><span class="p">:</span> <span class="n">upto</span> <span class="p">})</span><span class="k">.await</span><span class="o">?</span><span class="p">;</span>

    <span class="k">self</span><span class="py">.reserved</span><span class="nf">.lock</span><span class="p">()</span><span class="k">.await</span><span class="nf">.install</span><span class="p">(</span><span class="n">term</span><span class="p">,</span> <span class="n">interval</span><span class="p">);</span>
    <span class="nf">Ok</span><span class="p">(())</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The Raft write rate is now independent of the request rate. For a reservation width of <code class="language-plaintext highlighter-rouge">width</code>, the task writes once every <code class="language-plaintext highlighter-rouge">width / 2</code>. A single distributed commit can serve 100,000 <code class="language-plaintext highlighter-rouge">next_timestamp</code> requests.</p>

<h2 id="preserve-monotonicity-across-leader-failover">Preserve Monotonicity Across Leader Failover</h2>

<p>Batch allocation stays safe because the committed upper bound, not the in-memory cursor, is the handoff point. When a new leader is elected, it already has the log. After it applies those entries, <code class="language-plaintext highlighter-rouge">reserved_end</code> is at least as large as anything the old leader reserved. The new leader never starts from the old leader’s in-memory cursor, because that cursor was never replicated.</p>

<p><img src="/post-res/ez-ts-oracle/2df814a86a1bcc27-time-server-failover-safety.png" alt="Three-node leader failover: Raft log replication and state-machine replay keep `reserved_end` consistent across nodes, so the new leader begins from a safe timestamp range" /></p>

<p>That committed bound protects the new leader. It does not stop the old one. A network partition can isolate the old leader long enough for the other nodes to elect a replacement, while the old leader still believes it owns the cluster.</p>

<p>Once the new leader issues a timestamp from a later range, any later allocation from the old leader’s range would move time backward. The old leader must refuse requests as soon as it can no longer prove that it still holds leadership.</p>

<h2 id="reject-a-stale-leader-with-a-lease">Reject a Stale Leader with a Lease</h2>

<p>A lease is a time-bounded promise: as long as the leader has heard from a <a href="https://blog.openacid.com/algo/quorum/">quorum</a> recently, no other node can win an election. Checking that promise is a local read of heartbeat state, so it adds no network round trip to a timestamp request. <code class="language-plaintext highlighter-rouge">timestamp_oracle</code> performs that check with a <a href="https://blog.openacid.com/algo/linearizable/">linearizable</a> read using <code class="language-plaintext highlighter-rouge">ReadPolicy::LeaseRead</code>.</p>

<p>OpenRaft refreshes the lease from heartbeat acknowledgments. A recent acknowledgment from a quorum opens a window during which another leader cannot be elected. The current leader can allocate timestamps for the length of that window.</p>

<p>Once the latest quorum acknowledgment is older than the lease timeout, the node can no longer prove it is the only leader. <code class="language-plaintext highlighter-rouge">linearizable()</code> returns an error, and <code class="language-plaintext highlighter-rouge">next_timestamp()</code> stops before it touches the cache.</p>

<p><img src="/post-res/ez-ts-oracle/cac807cbb92f1fc1-time-server-lease-handoff.png" alt="Three-node timeline: heartbeat acknowledgments keep the old leader's lease valid; after the lease expires, a local check rejects `next_timestamp` requests, and the cluster then elects a new leader" /></p>

<p>[<code class="language-plaintext highlighter-rouge">EzRaft::linearizable(ReadPolicy::LeaseRead)</code>][code-ezraft-linearizable] reads the most recent heartbeat state in memory. It does not contact the quorum again. Internally it calls OpenRaft’s [<code class="language-plaintext highlighter-rouge">Raft::ensure_linearizable()</code>][docs-openraft-ensure-linearizable] and returns <code class="language-plaintext highlighter-rouge">(term, index)</code>. The term identifies the current leadership; the allocator uses it to validate the cached range:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">fn</span> <span class="nf">leader_term</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nn">io</span><span class="p">::</span><span class="nb">Result</span><span class="o">&lt;</span><span class="nb">u64</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">let</span> <span class="p">(</span><span class="n">term</span><span class="p">,</span> <span class="n">_index</span><span class="p">)</span> <span class="o">=</span> <span class="k">self</span><span class="py">.raft</span><span class="nf">.linearizable</span><span class="p">(</span><span class="nn">ReadPolicy</span><span class="p">::</span><span class="n">LeaseRead</span><span class="p">)</span><span class="k">.await</span><span class="o">?</span><span class="p">;</span>
    <span class="nf">Ok</span><span class="p">(</span><span class="n">term</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="serve">Serve</h2>
<p><code class="language-plaintext highlighter-rouge">next_timestamp</code>
 from Memory</p>

<p>With a reserved range in place, the request path has three steps:</p>

<ul>
  <li>Call <code class="language-plaintext highlighter-rouge">leader_term()</code> to validate the lease and obtain the current term.</li>
  <li>Allocate one timestamp from the cache and advance the cursor.</li>
  <li>Return the timestamp to the client.</li>
</ul>

<p>The HTTP handler, the request path, and the cache allocation fit in a few lines. See <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L124-L133">timestamp_oracle.rs:124-133</a> and <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L162-L168">timestamp_oracle.rs:162-168</a>:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">fn</span> <span class="nf">get_time</span><span class="p">(</span><span class="n">service</span><span class="p">:</span> <span class="nn">web</span><span class="p">::</span><span class="n">Data</span><span class="o">&lt;</span><span class="n">TimeService</span><span class="o">&gt;</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">Result</span><span class="o">&lt;</span><span class="nn">web</span><span class="p">::</span><span class="n">Json</span><span class="o">&lt;</span><span class="nb">String</span><span class="o">&gt;</span><span class="p">,</span> <span class="nn">actix_web</span><span class="p">::</span><span class="n">Error</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">micros</span> <span class="o">=</span> <span class="n">service</span><span class="nf">.next_timestamp</span><span class="p">()</span><span class="k">.await</span><span class="nf">.map_err</span><span class="p">(</span><span class="nn">actix_web</span><span class="p">::</span><span class="nn">error</span><span class="p">::</span><span class="n">ErrorServiceUnavailable</span><span class="p">)</span><span class="o">?</span><span class="p">;</span>
    <span class="nf">Ok</span><span class="p">(</span><span class="nn">web</span><span class="p">::</span><span class="nf">Json</span><span class="p">(</span><span class="nf">format_timestamp</span><span class="p">(</span><span class="n">micros</span><span class="p">)))</span>
<span class="p">}</span>

<span class="k">async</span> <span class="k">fn</span> <span class="nf">next_timestamp</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nn">io</span><span class="p">::</span><span class="nb">Result</span><span class="o">&lt;</span><span class="nb">u64</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">let</span> <span class="k">mut</span> <span class="n">reserved</span> <span class="o">=</span> <span class="k">self</span><span class="py">.reserved</span><span class="nf">.lock</span><span class="p">()</span><span class="k">.await</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">term</span> <span class="o">=</span> <span class="k">self</span><span class="nf">.leader_term</span><span class="p">()</span><span class="k">.await</span><span class="o">?</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">timestamp</span> <span class="o">=</span> <span class="n">reserved</span><span class="nf">.take</span><span class="p">(</span><span class="n">term</span><span class="p">,</span> <span class="nf">unix_timestamp_micros</span><span class="p">());</span>
    <span class="n">timestamp</span><span class="nf">.ok_or_else</span><span class="p">(||</span> <span class="nn">io</span><span class="p">::</span><span class="nn">Error</span><span class="p">::</span><span class="nf">other</span><span class="p">(</span><span class="s">"no reserved timestamp is currently available"</span><span class="p">))</span>
<span class="p">}</span>

<span class="k">fn</span> <span class="nf">take</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">leader_term</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span> <span class="n">now</span><span class="p">:</span> <span class="nb">u64</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="nb">u64</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">timestamp</span> <span class="o">=</span> <span class="n">now</span><span class="nf">.max</span><span class="p">(</span><span class="k">self</span><span class="py">.next</span><span class="p">);</span>
    <span class="k">if</span> <span class="k">self</span><span class="py">.term</span> <span class="o">!=</span> <span class="n">leader_term</span> <span class="p">||</span> <span class="n">timestamp</span> <span class="o">&gt;=</span> <span class="k">self</span><span class="py">.end</span> <span class="p">{</span>
        <span class="k">return</span> <span class="nb">None</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">self</span><span class="py">.next</span> <span class="o">=</span> <span class="n">timestamp</span> <span class="o">+</span> <span class="mi">1</span><span class="p">;</span>
    <span class="nf">Some</span><span class="p">(</span><span class="n">timestamp</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">now.max(self.next)</code> keeps timestamps close to wall-clock time while preserving local monotonicity. If the clock advances, the allocator follows it. If several requests arrive in the same microsecond—or the wall clock steps backward—<code class="language-plaintext highlighter-rouge">self.next</code> keeps every result unique and increasing.</p>

<p>The term comparison in <code class="language-plaintext highlighter-rouge">take()</code> is not part of the lease check. The next section is why that field exists. <code class="language-plaintext highlighter-rouge">leader_term()</code> keeps the term and discards the log index; see <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L207-L210">timestamp_oracle.rs:207-210</a>. The <a href="https://docs.rs/openraft/0.10.0-alpha.33/openraft/raft/enum.ReadPolicy.html">OpenRaft documentation</a> describes the other <code class="language-plaintext highlighter-rouge">ReadPolicy</code> options. The underlying read optimization is covered in <a href="https://blog.openacid.com/algo/openraft-read/">How OpenRaft Optimizes ReadIndex</a>.</p>

<h2 id="bind-cached-ranges-to-a-leader-term">Bind Cached Ranges to a Leader Term</h2>

<p>A valid lease proves that this node is the leader now. It does not prove that the cached range belongs to the current term.</p>

<p>Suppose node A reserved a range in term 1, lost leadership, and won it back in term 3. The leftover cache is still in the same process, so a restart is not there to wipe it. In between, node B may have been the leader in term 2 and may already have issued later timestamps. Node A’s leftover cache now sits behind time. Using it would go backward.</p>

<p>That is why <code class="language-plaintext highlighter-rouge">Reserved</code> stores the term next to <code class="language-plaintext highlighter-rouge">next</code> and <code class="language-plaintext highlighter-rouge">end</code>. A range reserved in term 1 is valid only in term 1. The term-3 leader must commit a new reservation before it can serve requests again.</p>

<p><img src="/post-res/ez-ts-oracle/5d18bdfd9b3d7222-time-server-reserved-lifecycle.png" alt="Cross-term timeline for three nodes: node A is the leader in terms 1 and 3, but its old reservation cache is invalidated; `next_timestamp` succeeds only after `refill` installs a new range for term 3" /></p>

<p><code class="language-plaintext highlighter-rouge">take()</code> enforces both cache invariants in one guard. A term mismatch rejects a range from an earlier leadership. <code class="language-plaintext highlighter-rouge">timestamp &gt;= self.end</code> rejects an exhausted range. Later requests succeed only after the background task installs a valid range.</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="k">self</span><span class="py">.term</span> <span class="o">!=</span> <span class="n">leader_term</span> <span class="p">||</span> <span class="n">timestamp</span> <span class="o">&gt;=</span> <span class="k">self</span><span class="py">.end</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nb">None</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>With the lease check and the term check in place, the in-memory path is safe across leader changes.</p>

<h2 id="run-the-service">Run the Service</h2>

<h3 id="start-a-three-node-cluster">Start a Three-Node Cluster</h3>

<p>Open three terminals and start one node in each. The first command creates the cluster; the other two use <code class="language-plaintext highlighter-rouge">--seed</code> to find the first node and join it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cargo run <span class="nt">--example</span> timestamp_oracle <span class="nt">--</span> <span class="nt">--addr</span> 127.0.0.1:8090
cargo run <span class="nt">--example</span> timestamp_oracle <span class="nt">--</span> <span class="nt">--addr</span> 127.0.0.1:8091 <span class="nt">--seed</span> 127.0.0.1:8090
cargo run <span class="nt">--example</span> timestamp_oracle <span class="nt">--</span> <span class="nt">--addr</span> 127.0.0.1:8092 <span class="nt">--seed</span> 127.0.0.1:8090
</code></pre></div></div>

<p>The cluster assigns node IDs automatically. When a node joins, EzRaft writes a blank log entry and uses its index as the new node’s ID. Other log entries may be committed between two joins, so IDs such as 0, 8, and 17 are expected. <code class="language-plaintext highlighter-rouge">/api/metrics</code> now shows three voters with node 0 as the leader:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"id"</span><span class="p">:</span><span class="mi">0</span><span class="p">,</span><span class="nl">"current_term"</span><span class="p">:</span><span class="mi">1</span><span class="p">,</span><span class="nl">"state"</span><span class="p">:</span><span class="s2">"Leader"</span><span class="p">,</span><span class="nl">"current_leader"</span><span class="p">:</span><span class="mi">0</span><span class="p">,</span><span class="w">
 </span><span class="nl">"membership_config"</span><span class="p">:{</span><span class="nl">"membership"</span><span class="p">:{</span><span class="nl">"configs"</span><span class="p">:[[</span><span class="mi">0</span><span class="p">,</span><span class="mi">8</span><span class="p">,</span><span class="mi">17</span><span class="p">]],</span><span class="w"> </span><span class="err">...</span><span class="p">}}}</span><span class="w">
</span></code></pre></div></div>

<h3 id="request-timestamps">Request Timestamps</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST 127.0.0.1:8090/time
</code></pre></div></div>

<p>Run the command three times. Each request returns an <a href="https://www.rfc-editor.org/rfc/rfc3339">RFC 3339</a> timestamp as a JSON string:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>"2026-08-11T14:43:16.966734Z"
"2026-08-11T14:43:16.973216Z"
"2026-08-11T14:43:16.979556Z"
</code></pre></div></div>

<p>The six-to-seven-millisecond gaps come from starting a new <code class="language-plaintext highlighter-rouge">curl</code> process for each request. The timestamp allocation itself stays entirely in the leader’s memory.</p>

<p>A follower rejects <code class="language-plaintext highlighter-rouge">/time</code> and names the current leader in its response:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ curl -s -X POST 127.0.0.1:8091/time
has to forward request to: Some(0), Some(BasicNode { addr: "127.0.0.1:8090" })   [HTTP 503]
</code></pre></div></div>

<h3 id="stop-the-leader">Stop the Leader</h3>

<p>Press Ctrl-C in the terminal running node 0, then keep sending <code class="language-plaintext highlighter-rouge">next_timestamp</code> requests to the two remaining nodes:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>14:43:28.998  curl 8090  -&gt;  "2026-08-11T14:43:28.998630Z"                        [200]
14:43:29      kill node 0
14:43:29.3    curl 8091  -&gt;  has to forward request to: Some(0), ...:8090          [503]
   ...        (for about five seconds, both 8091 and 8092 return the same 503)
14:43:34.8    curl 8091  -&gt;  has to forward request to: Some(17), ...:8092         [503]
14:43:34.8    curl 8092  -&gt;  no reserved timestamp is currently available          [503]
14:43:35.169  curl 8092  -&gt;  "2026-08-11T14:43:35.169756Z"                        [200]
</code></pre></div></div>

<p>The timeline shows two stages of recovery. At first the followers still point clients at node 0. Node 17 is then elected leader, but its first <code class="language-plaintext highlighter-rouge">Reserve</code> entry has not committed, so its in-memory cache is still empty. As soon as that reservation commits, the service resumes.</p>

<p>The timestamps stay monotonic. The first value from the new leader, <code class="language-plaintext highlighter-rouge">14:43:35.169756</code>, is greater than the last value from the old leader, <code class="language-plaintext highlighter-rouge">14:43:28.998630</code>. The unused interval between them is a harmless gap. <code class="language-plaintext highlighter-rouge">/api/metrics</code> confirms that the cluster has advanced from term 1 to term 2:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"id"</span><span class="p">:</span><span class="mi">17</span><span class="p">,</span><span class="nl">"current_term"</span><span class="p">:</span><span class="mi">2</span><span class="p">,</span><span class="nl">"state"</span><span class="p">:</span><span class="s2">"Leader"</span><span class="p">,</span><span class="nl">"current_leader"</span><span class="p">:</span><span class="mi">17</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"id"</span><span class="p">:</span><span class="mi">8</span><span class="p">,</span><span class="nl">"current_term"</span><span class="p">:</span><span class="mi">2</span><span class="p">,</span><span class="nl">"state"</span><span class="p">:</span><span class="s2">"Follower"</span><span class="p">,</span><span class="nl">"current_leader"</span><span class="p">:</span><span class="mi">17</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<!-- No diagram: the timestamped output already shows the sequence before and after leader failover, so another illustration would be redundant. -->

<h2 id="what-raft-owns">What Raft Owns</h2>

<p>The consensus layer does not need to see every timestamp. It needs to see the ceiling, and it needs to say who may spend below that ceiling. Once those two facts are durable and current, <code class="language-plaintext highlighter-rouge">next_timestamp</code> is a local increment.</p>

<p>That split is the whole service. EzRaft handles replication, election, and failover. The application code persists the reservation boundary, tags each cached range with a term, and advances a cursor.</p>

<h2 id="links">Links</h2>

<ul>
  <li>Example: <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs">timestamp_oracle.rs</a></li>
  <li>EzRaft: <a href="https://github.com/drmingdrmer/ezraft">github.com/drmingdrmer/ezraft</a></li>
  <li>crates.io: <a href="https://crates.io/crates/ezraft">crates.io/crates/ezraft</a></li>
  <li>Documentation: <a href="https://docs.rs/ezraft">docs.rs/ezraft</a></li>
  <li>OpenRaft: <a href="https://github.com/databendlabs/openraft">github.com/databendlabs/openraft</a></li>
  <li>Previous article: <a href="https://blog.openacid.com/algo/ezraft/">EzRaft: Build a Distributed KV Store in 100 Lines</a></li>
</ul>

<p>Reference:</p>

<ul>
  <li>
    <p>Reserve / Interval / TimeState / impl EzApp : <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L66-L112">https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L66-L112</a></p>
  </li>
  <li>
    <p>TimeService::leader_term : <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L207-L210">https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L207-L210</a></p>
  </li>
  <li>
    <p>TimeService::next_timestamp : <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L162-L168">https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L162-L168</a></p>
  </li>
  <li>
    <p>TimeService::refill : <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L188-L205">https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L188-L205</a></p>
  </li>
  <li>
    <p>TimeService::run_reserver : <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L170-L186">https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L170-L186</a></p>
  </li>
  <li>
    <p>Reserved::take : <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L124-L133">https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs#L124-L133</a></p>
  </li>
  <li>
    <p>ezraft on crates.io : <a href="https://crates.io/crates/ezraft">https://crates.io/crates/ezraft</a></p>
  </li>
  <li>
    <p>TiDB: Timestamp Oracle (TSO) : <a href="https://docs.pingcap.com/tidb/stable/glossary/#timestamp-oracle-tso">https://docs.pingcap.com/tidb/stable/glossary/#timestamp-oracle-tso</a></p>
  </li>
  <li>
    <p>ezraft docs : <a href="https://docs.rs/ezraft">https://docs.rs/ezraft</a></p>
  </li>
  <li>
    <p>OpenRaft ReadPolicy : <a href="https://docs.rs/openraft/0.10.0-alpha.33/openraft/raft/enum.ReadPolicy.html">https://docs.rs/openraft/0.10.0-alpha.33/openraft/raft/enum.ReadPolicy.html</a></p>
  </li>
  <li>
    <p>EzRaft: Build a Distributed KV Store in 100 Lines : <a href="https://blog.openacid.com/algo/ezraft/">https://blog.openacid.com/algo/ezraft/</a></p>
  </li>
  <li>
    <p>Linearizable Transactions in Distributed Systems : <a href="https://blog.openacid.com/algo/linearizable/">https://blog.openacid.com/algo/linearizable/</a></p>
  </li>
  <li>
    <p>How OpenRaft Optimizes ReadIndex : <a href="https://blog.openacid.com/algo/openraft-read/">https://blog.openacid.com/algo/openraft-read/</a></p>
  </li>
  <li>
    <p>Quorum Reads and Writes with a Minority : <a href="https://blog.openacid.com/algo/quorum/">https://blog.openacid.com/algo/quorum/</a></p>
  </li>
  <li>
    <p>Raft : <a href="https://raft.github.io/">https://raft.github.io/</a></p>
  </li>
  <li>
    <p>Large-scale Incremental Processing Using Distributed Transactions and Notifications : <a href="https://www.usenix.org/legacy/event/osdi10/tech/full_papers/Peng.pdf">https://www.usenix.org/legacy/event/osdi10/tech/full_papers/Peng.pdf</a></p>
  </li>
  <li>
    <p>ezraft : <a href="https://github.com/drmingdrmer/ezraft">https://github.com/drmingdrmer/ezraft</a></p>
  </li>
  <li>
    <p>ezraft timestamp_oracle example : <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs">https://github.com/drmingdrmer/ezraft/blob/main/examples/timestamp_oracle.rs</a></p>
  </li>
  <li>
    <p>OpenRaft : <a href="https://github.com/databendlabs/openraft">https://github.com/databendlabs/openraft</a></p>
  </li>
  <li>
    <p>RFC 3339: Date and Time on the Internet : <a href="https://www.rfc-editor.org/rfc/rfc3339">https://www.rfc-editor.org/rfc/rfc3339</a></p>
  </li>
  <li>
    <p>Rust : <a href="https://www.rust-lang.org/">https://www.rust-lang.org/</a></p>
  </li>
  <li>
    <p>Multiversion concurrency control : <a href="https://en.wikipedia.org/wiki/Multiversion_concurrency_control">https://en.wikipedia.org/wiki/Multiversion_concurrency_control</a></p>
  </li>
</ul>]]></content><author><name>Zhang Yanpo (drdr.xp)</name></author><category term="algo" /><category term="raft" /><category term="consensus" /><category term="distributed" /><summary type="html"><![CDATA[`timestamp_oracle` is a 200-line timestamp service on EzRaft. A background task reserves ranges through Raft; `next_timestamp` allocates from memory. Leader leases and term-scoped caches keep the sequence strictly increasing across failovers, with no Raft write on the request path.]]></summary></entry><entry><title type="html">EzRaft: Build a Distributed KV Store in 100 Lines</title><link href="https://blog.openacid.com/algo/ezraft/" rel="alternate" type="text/html" title="EzRaft: Build a Distributed KV Store in 100 Lines" /><published>2026-08-01T00:00:00+00:00</published><updated>2026-08-01T00:00:00+00:00</updated><id>https://blog.openacid.com/algo/ezraft</id><content type="html" xml:base="https://blog.openacid.com/algo/ezraft/"><![CDATA[<p><img src="/post-res/ezraft/f45129e0aa8db968-ezraft-banner.png" alt="" /></p>

<h1 id="ezraft-build-a-distributed-kv-store-in-100-lines">EzRaft: Build a Distributed KV Store in 100 Lines</h1>

<p>For the past few years, I have spent most of my time working on <a href="https://github.com/databendlabs/openraft">OpenRaft</a>.</p>

<p>OpenRaft is an open-source project that runs in production at companies around the world. To support such a wide range of applications, it has to be highly flexible. Log entries, log IDs, state machines, and storage are all expressed as <a href="https://www.rust-lang.org/">Rust</a> traits, so users can adapt them to their own needs.</p>

<p>Today, OpenRaft’s API and performance are both fairly mature. The trade-off is that its model of the <a href="https://raft.github.io/">Raft</a> algorithm remains quite abstract.</p>

<h2 id="the-openraft-learning-curve">The OpenRaft Learning Curve</h2>

<p>That abstraction creates a steep learning curve. There are three main parts:</p>

<ol>
  <li>
    <p>Understanding the Raft consensus algorithm:
Before you can build a distributed store on OpenRaft, you need to understand what Raft guarantees—and what it does not.</p>
  </li>
  <li>
    <p>Understanding Rust:
OpenRaft is written in Rust. Reading the code and building an application requires some familiarity with Rust’s abstractions and generics.</p>
  </li>
  <li>
    <p>Understanding OpenRaft’s own abstractions:
Abstraction makes software easier to use once you understand the model. It gives experienced users convenience and flexibility. But when you are learning the system from the bottom up, the abstraction itself becomes another thing you must first understand.</p>
  </li>
</ol>

<p>This is a common problem with abstractions: before the whole system makes sense, you must understand how all of its abstract pieces fit together. Most people learn more naturally in the other direction—from concrete examples to general ideas.</p>

<p>Give someone an abstraction and ask them to imagine a concrete example, and the idea can be hard to grasp. Give them a concrete example first, and the underlying pattern is much easier to see. OpenRaft’s abstractions make it a powerful, stable tool, but not necessarily an easy place to start learning.</p>

<h2 id="why-i-built-ezraft">Why I Built EzRaft</h2>

<p>As the maintainer of OpenRaft, I regularly hear from developers who are new to distributed systems, Rust, or OpenRaft itself. They want to build something of their own—perhaps a distributed storage system—but the amount they need to learn first can be overwhelming.</p>

<p>I began to wonder: could I build a layer that lets people focus on their business logic first, without requiring them to master Raft, Rust, and OpenRaft up front?</p>

<p>That question led to EzRaft.</p>

<p><a href="https://github.com/drmingdrmer/ezraft">EzRaft</a> is a streamlined distributed storage library built on top of OpenRaft. It hides the details you do not need at the beginning, so you can concentrate on the application rather than the machinery underneath it.</p>

<p>Here is a rough comparison:</p>

<table>
<tr class="header">
<th></th>
<th>OpenRaft</th>
<th>EzRaft</th>
</tr>
<tr class="odd">
<td>Traits to implement</td>
<td>7+ (<code>RaftLogStorage</code>, <code>RaftStateMachine</code>, …)</td>
<td>2 (<code>EzApp</code>, <code>EzStorage</code>)</td>
</tr>
<tr class="even">
<td>Methods to implement</td>
<td>21+</td>
<td>4</td>
</tr>
<tr class="odd">
<td>Types to define</td>
<td>12 generic parameters</td>
<td>2 (<code>Request</code>, <code>Response</code>)</td>
</tr>
<tr class="even">
<td>Network code</td>
<td>About 100 lines, written by the user</td>
<td>Built in, 0 lines</td>
</tr>
</table>

<p><img src="/post-res/ezraft/db3f610ca0318cb2-ezraft-layers.png" alt="The layers of EzRaft and OpenRaft: users implement EzApp, EzRaft provides storage, networking, and service APIs, and OpenRaft handles consensus" /></p>

<h2 id="the-four-things-users-define">The Four Things Users Define</h2>

<p>EzRaft asks you to define just four things:</p>

<ol>
  <li>The shape of a request: <code class="language-plaintext highlighter-rouge">Request</code>.</li>
  <li>The shape of a response: <code class="language-plaintext highlighter-rouge">Response</code>. Requests and responses represent your application’s operations, so only you can define them.</li>
  <li>How a request changes the state machine: <code class="language-plaintext highlighter-rouge">apply()</code>.</li>
  <li>How to read from the state machine: <code class="language-plaintext highlighter-rouge">read()</code>. You can think of the state machine as a key-value store and use a key to retrieve one part of it.</li>
</ol>

<p>Together, these four pieces define the boundary of your application. EzRaft handles everything below that boundary: storage, network communication, service APIs, and more. When building directly on OpenRaft, you must provide these pieces yourself.</p>

<p>With EzRaft, you can build the business logic first and explore the lower-level details later, when you actually need them.</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">trait</span> <span class="n">EzApp</span><span class="p">:</span> <span class="n">Serialize</span> <span class="o">+</span> <span class="n">DeserializeOwned</span> <span class="p">{</span>
    <span class="k">type</span> <span class="n">Request</span><span class="p">;</span>
    <span class="k">type</span> <span class="n">Response</span><span class="p">;</span>

    <span class="k">async</span> <span class="k">fn</span> <span class="nf">apply</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">req</span><span class="p">:</span> <span class="k">Self</span><span class="p">::</span><span class="n">Request</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="k">Self</span><span class="p">::</span><span class="n">Response</span><span class="p">;</span>
    <span class="k">fn</span> <span class="nf">read</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">,</span> <span class="n">key</span><span class="p">:</span> <span class="o">&amp;</span><span class="nb">str</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="nn">serde_json</span><span class="p">::</span><span class="n">Value</span><span class="o">&gt;</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That is the complete application-facing API. Define those four pieces, add a few lines of boilerplate to connect them, and let EzRaft handle the rest.</p>

<p>The <code class="language-plaintext highlighter-rouge">EzApp</code> value holds the application’s entire state. EzRaft persists and restores that state through serialization and deserialization, which means a snapshot is simply the serialized form of the whole application.</p>

<p>This deliberate simplification makes EzRaft a good fit for applications whose state fits in memory. We will return to that constraint later.</p>

<h2 id="build-a-distributed-kv-store-in-three-steps">Build a Distributed KV Store in Three Steps</h2>

<p>Building our KV store takes three steps:</p>

<ol>
  <li>Implement <code class="language-plaintext highlighter-rouge">EzApp</code> to define the business model.</li>
  <li>Assemble a service from the components EzRaft provides.</li>
  <li>Start three nodes and try the cluster for yourself.</li>
</ol>

<h2 id="step-one-define-the-application">Step One: Define the Application</h2>

<p>Let us start with the application itself. The service supports two write operations: set a key and delete a key.</p>

<p>The complete application state lives in a KV map, backed here by a <a href="https://doc.rust-lang.org/std/collections/struct.BTreeMap.html">B-tree map</a>.</p>

<p><code class="language-plaintext highlighter-rouge">apply</code> takes a set or delete request and applies it to the map.</p>

<p><code class="language-plaintext highlighter-rouge">read</code> looks up a value by key and returns it.</p>

<p>That is all the application needs to do. EzRaft takes care of everything else.</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">enum</span> <span class="n">Request</span> <span class="p">{</span>
    <span class="n">Set</span> <span class="p">{</span> <span class="n">key</span><span class="p">:</span> <span class="nb">String</span><span class="p">,</span> <span class="n">value</span><span class="p">:</span> <span class="nb">String</span> <span class="p">},</span>
    <span class="n">Delete</span> <span class="p">{</span> <span class="n">key</span><span class="p">:</span> <span class="nb">String</span> <span class="p">},</span>
<span class="p">}</span>

<span class="k">struct</span> <span class="n">KvApp</span> <span class="p">{</span>
    <span class="n">data</span><span class="p">:</span> <span class="n">BTreeMap</span><span class="o">&lt;</span><span class="nb">String</span><span class="p">,</span> <span class="nb">String</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">impl</span> <span class="n">EzApp</span> <span class="k">for</span> <span class="n">KvApp</span> <span class="p">{</span>
    <span class="k">type</span> <span class="n">Request</span> <span class="o">=</span> <span class="n">Request</span><span class="p">;</span>
    <span class="k">type</span> <span class="n">Response</span> <span class="o">=</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="nb">String</span><span class="o">&gt;</span><span class="p">;</span>

    <span class="k">async</span> <span class="k">fn</span> <span class="nf">apply</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">req</span><span class="p">:</span> <span class="n">Request</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="nb">String</span><span class="o">&gt;</span> <span class="p">{</span>
        <span class="k">match</span> <span class="n">req</span> <span class="p">{</span>
            <span class="nn">Request</span><span class="p">::</span><span class="n">Set</span> <span class="p">{</span> <span class="n">key</span><span class="p">,</span> <span class="n">value</span> <span class="p">}</span> <span class="k">=&gt;</span> <span class="k">self</span><span class="py">.data</span><span class="nf">.insert</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">value</span><span class="p">),</span>
            <span class="nn">Request</span><span class="p">::</span><span class="n">Delete</span> <span class="p">{</span> <span class="n">key</span> <span class="p">}</span> <span class="k">=&gt;</span> <span class="k">self</span><span class="py">.data</span><span class="nf">.remove</span><span class="p">(</span><span class="o">&amp;</span><span class="n">key</span><span class="p">),</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="k">fn</span> <span class="nf">read</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">,</span> <span class="n">key</span><span class="p">:</span> <span class="o">&amp;</span><span class="nb">str</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="nn">serde_json</span><span class="p">::</span><span class="n">Value</span><span class="o">&gt;</span> <span class="p">{</span>
        <span class="k">self</span><span class="py">.data</span><span class="nf">.get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span><span class="nf">.cloned</span><span class="p">()</span><span class="nf">.map</span><span class="p">(</span><span class="nn">serde_json</span><span class="p">::</span><span class="nn">Value</span><span class="p">::</span><span class="nb">String</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Both <code class="language-plaintext highlighter-rouge">insert</code> and <code class="language-plaintext highlighter-rouge">remove</code> return the key’s previous value, so <code class="language-plaintext highlighter-rouge">apply</code> returns it as well. Each write tells the caller exactly what it replaced or removed, without an extra read.</p>

<p>To keep the example readable, I omitted the derive attributes. A real implementation needs them. <code class="language-plaintext highlighter-rouge">Request</code> travels over the network and is written to the Raft log, so it must support serialization through <a href="https://serde.rs/">serde</a>.</p>

<p>OpenRaft also prints requests in its diagnostic logs, which requires <code class="language-plaintext highlighter-rouge">Debug</code> and <code class="language-plaintext highlighter-rouge">Display</code>. EzRaft may need to retain a copy while forwarding a request to the leader, so <code class="language-plaintext highlighter-rouge">Request</code> also needs <code class="language-plaintext highlighter-rouge">Clone</code>. Finally, <code class="language-plaintext highlighter-rouge">KvApp</code> is the state itself, and serde turns that state into snapshots.</p>

<p>The repository includes a complete, runnable version in <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/kvstore.rs">examples/kvstore.rs</a>, with all derives and imports in place. You can run it directly with <code class="language-plaintext highlighter-rouge">cargo run</code>.</p>

<p>It differs from the code in this article in two small ways. First, it uses clap to parse <code class="language-plaintext highlighter-rouge">--addr</code> and <code class="language-plaintext highlighter-rouge">--seed</code> instead of positional arguments. Second, it returns a <code class="language-plaintext highlighter-rouge">Response</code> struct rather than <code class="language-plaintext highlighter-rouge">Option&lt;String&gt;</code>, so the HTTP response is <code class="language-plaintext highlighter-rouge">{"value":"world"}</code> instead of <code class="language-plaintext highlighter-rouge">"world"</code>.</p>

<h2 id="step-two-assemble-a-working-service">Step Two: Assemble a Working Service</h2>

<p>Now we can turn the business logic into a working server. The server accepts two arguments at startup:</p>

<ol>
  <li>This node’s address. Other EzRaft nodes use it for cluster communication, and clients use it to send requests.</li>
  <li>A seed address. A new node with uninitialized storage contacts the seed to join an existing cluster.</li>
</ol>

<p>The first node does not need a seed. It creates a single-node cluster and can begin serving requests immediately.</p>

<p>The second and third nodes use the first node as their seed. Each asks to join the existing cluster, and together they form a three-node cluster.</p>

<p>EzRaft and OpenRaft handle the entire process: admitting each node, assigning its ID, and synchronizing its data.</p>

<p><img src="/post-res/ezraft/1b91902c8e63f4b5-ezraft-cluster-formation.png" alt="How three EzRaft nodes form a cluster: the first creates it, while two more join through the seed, catch up, and become voters" /></p>

<p>The server code is short: read the command-line arguments, then initialize storage.</p>

<p>This example uses EzRaft’s built-in <code class="language-plaintext highlighter-rouge">FileStorage</code>. It demonstrates every responsibility of a storage implementation, with clarity taking priority over performance.</p>

<p>A production system should replace it with a proper storage backend. <code class="language-plaintext highlighter-rouge">FileStorage</code> does not call fsync, so a power failure could lose a write that the cluster has already acknowledged to the client. That can <a href="https://blog.openacid.com/algo/raft-io-order-complete-cn/">break Raft’s correctness guarantees</a>.</p>

<p>Fortunately, <code class="language-plaintext highlighter-rouge">EzStorage</code> has only three methods. The <code class="language-plaintext highlighter-rouge">FileStorage</code> source provides a complete template for implementing them.</p>

<p>After initializing storage, create the <code class="language-plaintext highlighter-rouge">KvApp</code> instance and pass it, the storage backend, and a configuration to EzRaft. The server is then ready to run.</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">#[tokio::main]</span>
<span class="k">async</span> <span class="k">fn</span> <span class="nf">main</span><span class="p">()</span> <span class="k">-&gt;</span> <span class="nn">io</span><span class="p">::</span><span class="nb">Result</span><span class="o">&lt;</span><span class="p">()</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="c1">// Usage: kvstore [addr] [seed-addr]</span>
    <span class="k">let</span> <span class="n">addr</span> <span class="o">=</span> <span class="nn">std</span><span class="p">::</span><span class="nn">env</span><span class="p">::</span><span class="nf">args</span><span class="p">()</span><span class="nf">.nth</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span><span class="nf">.unwrap_or</span><span class="p">(</span><span class="s">"127.0.0.1:8080"</span><span class="nf">.to_string</span><span class="p">());</span>
    <span class="k">let</span> <span class="n">seed</span> <span class="o">=</span> <span class="nn">std</span><span class="p">::</span><span class="nn">env</span><span class="p">::</span><span class="nf">args</span><span class="p">()</span><span class="nf">.nth</span><span class="p">(</span><span class="mi">2</span><span class="p">);</span>

    <span class="k">let</span> <span class="n">storage</span> <span class="o">=</span> <span class="nn">FileStorage</span><span class="p">::</span><span class="nf">new</span><span class="p">(</span><span class="nd">format!</span><span class="p">(</span><span class="s">"./data/{}"</span><span class="p">,</span> <span class="n">addr</span><span class="nf">.replace</span><span class="p">(</span><span class="sc">':'</span><span class="p">,</span> <span class="s">"-"</span><span class="p">)))</span><span class="k">.await</span><span class="o">?</span><span class="p">;</span>
    <span class="k">let</span> <span class="p">(</span><span class="n">app</span><span class="p">,</span> <span class="n">config</span><span class="p">)</span> <span class="o">=</span> <span class="p">(</span><span class="nn">KvApp</span><span class="p">::</span><span class="nf">default</span><span class="p">(),</span> <span class="nn">EzConfig</span><span class="p">::</span><span class="nf">default</span><span class="p">());</span>

    <span class="k">let</span> <span class="n">raft</span> <span class="o">=</span> <span class="k">match</span> <span class="n">seed</span> <span class="p">{</span>
        <span class="nf">Some</span><span class="p">(</span><span class="n">seed</span><span class="p">)</span> <span class="k">=&gt;</span> <span class="nn">EzRaft</span><span class="p">::</span><span class="nf">join</span><span class="p">(</span><span class="o">&amp;</span><span class="n">addr</span><span class="p">,</span> <span class="n">seed</span><span class="p">,</span> <span class="n">app</span><span class="p">,</span> <span class="n">storage</span><span class="p">,</span> <span class="n">config</span><span class="p">)</span><span class="k">.await</span><span class="o">?</span><span class="p">,</span>
        <span class="nb">None</span> <span class="k">=&gt;</span> <span class="nn">EzRaft</span><span class="p">::</span><span class="nf">create</span><span class="p">(</span><span class="o">&amp;</span><span class="n">addr</span><span class="p">,</span> <span class="n">app</span><span class="p">,</span> <span class="n">storage</span><span class="p">,</span> <span class="n">config</span><span class="p">)</span><span class="k">.await</span><span class="o">?</span><span class="p">,</span>
    <span class="p">};</span>

    <span class="n">raft</span><span class="nf">.serve</span><span class="p">()</span><span class="k">.await</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">create</code> and <code class="language-plaintext highlighter-rouge">join</code> are deliberately separate methods rather than a single method with an <code class="language-plaintext highlighter-rouge">Option&lt;seed&gt;</code> parameter. Creating a cluster and joining an existing one are fundamentally different operations.</p>

<p>Suppose a node is meant to join a cluster, but its seed is accidentally omitted. If it calls <code class="language-plaintext highlighter-rouge">create</code>, it forms a second, independent single-node cluster—and the two clusters will never merge. Requiring the caller to choose explicitly is safer than letting an empty configuration value make that decision.</p>

<h2 id="step-three-start-a-three-node-cluster">Step Three: Start a Three-Node Cluster</h2>

<p>Open three terminals and start one node in each:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cargo run <span class="nt">--</span> 127.0.0.1:8080
cargo run <span class="nt">--</span> 127.0.0.1:8081 127.0.0.1:8080
cargo run <span class="nt">--</span> 127.0.0.1:8082 127.0.0.1:8080
</code></pre></div></div>

<p>Open a fourth terminal for client requests.</p>

<p>First, write a value. A write returns the key’s previous value. Because <code class="language-plaintext highlighter-rouge">hello</code> does not exist yet, the response is <code class="language-plaintext highlighter-rouge">null</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST 127.0.0.1:8080/api/write <span class="se">\</span>
    <span class="nt">-H</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
    <span class="nt">-d</span> <span class="s1">'{"Set": {"key": "hello", "value": "world"}}'</span>
<span class="c"># null</span>
</code></pre></div></div>

<p>Now read the value from another node. The request goes to 8082, even though no client has written to that node directly:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="s1">'127.0.0.1:8082/api/read?key=hello'</span>
<span class="c"># "world"</span>
</code></pre></div></div>

<p>Every node holds a complete copy of the application state. A read can therefore go straight to local memory, without contacting the leader or running a round of consensus.</p>

<p>This local read is fast, but it can be slightly stale. The node may not yet have received a write that the cluster just acknowledged. If you need a strictly <a href="https://blog.openacid.com/algo/linearizable/">linearizable</a> read, call <code class="language-plaintext highlighter-rouge">linearizable()</code> first. It <a href="https://blog.openacid.com/algo/openraft-read/">confirms the leader’s identity</a> and waits for the local state to catch up; the following <code class="language-plaintext highlighter-rouge">read()</code> will then see every earlier acknowledged write.</p>

<h2 id="send-writes-to-any-node">Send Writes to Any Node</h2>

<p>In Raft, only the leader can coordinate a client write, but EzRaft hides that restriction from the client. Every node exposes the same write endpoint. A follower forwards the request to the leader, waits for it to commit, and then returns the result to the caller.</p>

<p>That is why the following request succeeds when sent to 8081. The response, <code class="language-plaintext highlighter-rouge">"world"</code>, is the old value replaced by the write:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST 127.0.0.1:8081/api/write <span class="se">\</span>
    <span class="nt">-H</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
    <span class="nt">-d</span> <span class="s1">'{"Set": {"key": "hello", "value": "again"}}'</span>
<span class="c"># "world"</span>
</code></pre></div></div>

<p>The client never needs to discover or track the leader; it can connect to any node. A delete works the same way and returns the value it removed:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST 127.0.0.1:8080/api/write <span class="se">\</span>
    <span class="nt">-H</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
    <span class="nt">-d</span> <span class="s1">'{"Delete": {"key": "hello"}}'</span>
<span class="c"># "again"</span>
</code></pre></div></div>

<p>After the deletion, the key no longer exists, so another read returns HTTP 404:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="s1">'127.0.0.1:8080/api/read?key=hello'</span>
<span class="c"># no value for key "hello"     [HTTP 404]</span>
</code></pre></div></div>

<h2 id="test-a-node-failure">Test a Node Failure</h2>

<p>So far, a single HashMap on one machine could provide the same behavior. The reason to run three nodes is fault tolerance: the service keeps working when one node fails.</p>

<p>Write a key, then stop the leader—the first node, running on 8080:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST 127.0.0.1:8080/api/write <span class="se">\</span>
    <span class="nt">-H</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
    <span class="nt">-d</span> <span class="s1">'{"Set": {"key": "k", "value": "before-failover"}}'</span>
<span class="c"># null</span>

<span class="c"># Press Ctrl-C in the first terminal, or kill the process.</span>
</code></pre></div></div>

<p>The two remaining nodes still form a <a href="https://blog.openacid.com/algo/quorum/">majority</a>, so they elect a new leader within a few heartbeats. The data written before the failure remains available, and the cluster continues to accept writes:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="s1">'127.0.0.1:8081/api/read?key=k'</span>
<span class="c"># "before-failover"</span>

curl <span class="nt">-X</span> POST 127.0.0.1:8081/api/write <span class="se">\</span>
    <span class="nt">-H</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
    <span class="nt">-d</span> <span class="s1">'{"Set": {"key": "k", "value": "after-failover"}}'</span>
<span class="c"># "before-failover"</span>

curl 127.0.0.1:8081/api/metrics | jq <span class="nt">-c</span> <span class="s1">'{id, state, current_leader}'</span>
<span class="c"># {"id":2,"state":"Follower","current_leader":6}</span>
</code></pre></div></div>

<p>Node 6 is now the leader. The cluster completed the failover without any manual intervention.</p>

<p><img src="/post-res/ezraft/85aed0f352257dfd-ezraft-failover.png" alt="Failover in a three-node cluster: after the original leader fails, the other two nodes form a majority and elect a new leader" /></p>

<p>Now restart the failed node with exactly the same command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cargo run <span class="nt">--</span> 127.0.0.1:8080
</code></pre></div></div>

<p>Notice that the command does not include a seed. The node ID is already stored in <code class="language-plaintext highlighter-rouge">./data/127.0.0.1-8080/</code>, so the node loads it during startup instead of joining again.</p>

<p>The original seed is no longer needed, even if it has since left the cluster. Once the restarted node is running, it automatically catches up on the write it missed:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="s1">'127.0.0.1:8080/api/read?key=k'</span>
<span class="c"># "after-failover"</span>
</code></pre></div></div>

<p>A three-node cluster tolerates one failure; a five-node cluster tolerates two. Even-numbered clusters provide no additional fault tolerance: four nodes, like three, can still tolerate only one failure.</p>

<h2 id="cluster-state">Cluster State</h2>

<p>EzRaft includes a default endpoint for inspecting the current state of a node and its cluster:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="s1">'127.0.0.1:8080/api/metrics'</span> | jq
</code></pre></div></div>

<p>The response comes directly from OpenRaft and exposes its full set of metrics:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"running_state"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"Ok"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
  </span><span class="nl">"current_term"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
  </span><span class="nl">"vote"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"leader_id"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"term"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nl">"voted_for"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="nl">"committed"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"last_log_index"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="p">,</span><span class="w">
  </span><span class="nl">"local_committed"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"leader_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nl">"index"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="nl">"cluster_committed"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"leader_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nl">"index"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="nl">"committed"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"leader_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nl">"index"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="nl">"last_applied"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"leader_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nl">"index"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="nl">"snapshot"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
  </span><span class="nl">"purged"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
  </span><span class="nl">"state"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Leader"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"current_leader"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
  </span><span class="nl">"millis_since_quorum_ack"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w">
  </span><span class="nl">"last_quorum_acked"</span><span class="p">:</span><span class="w"> </span><span class="mi">1785595777894002000</span><span class="p">,</span><span class="w">
  </span><span class="nl">"membership_config"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"log_id"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"leader_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nl">"index"</span><span class="p">:</span><span class="w"> </span><span class="mi">9</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="nl">"membership"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"configs"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">6</span><span class="w"> </span><span class="p">]</span><span class="w"> </span><span class="p">],</span><span class="w">
      </span><span class="nl">"nodes"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"0"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"addr"</span><span class="p">:</span><span class="w"> </span><span class="s2">"127.0.0.1:8080"</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"2"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"addr"</span><span class="p">:</span><span class="w"> </span><span class="s2">"127.0.0.1:8081"</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"6"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"addr"</span><span class="p">:</span><span class="w"> </span><span class="s2">"127.0.0.1:8082"</span><span class="w"> </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"committed_membership_config"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"log_id"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"leader_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nl">"index"</span><span class="p">:</span><span class="w"> </span><span class="mi">9</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="nl">"membership"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"configs"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">6</span><span class="w"> </span><span class="p">]</span><span class="w"> </span><span class="p">],</span><span class="w">
      </span><span class="nl">"nodes"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"0"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"addr"</span><span class="p">:</span><span class="w"> </span><span class="s2">"127.0.0.1:8080"</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"2"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"addr"</span><span class="p">:</span><span class="w"> </span><span class="s2">"127.0.0.1:8081"</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"6"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"addr"</span><span class="p">:</span><span class="w"> </span><span class="s2">"127.0.0.1:8082"</span><span class="w"> </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"heartbeat"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"0"</span><span class="p">:</span><span class="w"> </span><span class="mi">1785595777894002750</span><span class="p">,</span><span class="w">
    </span><span class="nl">"2"</span><span class="p">:</span><span class="w"> </span><span class="mi">1785595777894002209</span><span class="p">,</span><span class="w">
    </span><span class="nl">"6"</span><span class="p">:</span><span class="w"> </span><span class="mi">1785595777894002125</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"replication"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"0"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"leader_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nl">"index"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="nl">"2"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"leader_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nl">"index"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="nl">"6"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"leader_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nl">"index"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The three node IDs are 0, 2, and 6—not 0, 1, and 2. That is because the cluster assigns node IDs instead of reading them from configuration.</p>

<p>When a node joins, the leader first writes a blank log entry, then uses that entry’s index as the new node’s ID. Log indexes are unique, so the resulting node IDs are unique as well. They are not consecutive because other entries are written between two joins: one adds the new node as a learner, and a membership change promotes it to a voter. The gaps between IDs are a visible trace of those steps.</p>

<p><img src="/post-res/ezraft/57254dc9bd5dea86-ezraft-node-id-log.png" alt="How node IDs 0, 2, and 6 relate to Raft log indexes written by join, add learner, and membership change operations" /></p>

<p>A new node first joins as a learner. It receives log entries, but it does not vote and does not count toward the cluster’s majority. Once it has caught up, EzRaft automatically promotes it to a voter.</p>

<p>From the user’s perspective, joining the node is enough. EzRaft handles synchronization and promotion, so the node begins contributing to fault tolerance automatically.</p>

<h2 id="when-to-use-ezraft">When to Use EzRaft</h2>

<p>EzRaft is designed for applications whose state fits in memory but must never be lost or allowed to diverge across nodes. Configuration services, service registries, discovery systems, metadata stores, distributed locks, and task schedulers all fit this pattern.</p>

<p>The data is usually small, but it is critical: losing it or allowing replicas to disagree can disrupt every system built on top of it. <a href="https://zookeeper.apache.org/">ZooKeeper</a> and <a href="https://etcd.io/">etcd</a> serve this same class of workload.</p>

<p>This focus follows directly from EzRaft’s snapshot model. A snapshot is the serialized form of the complete <code class="language-plaintext highlighter-rouge">EzApp</code>, and installing one simply deserializes that state back into the application.</p>

<p>As a result, snapshot support shrinks from “implement an entire snapshot mechanism” to “add one derive annotation to a struct.” Applications whose state fits in memory get the full benefit of that simplification.</p>

<p>EzRaft also works well for read-heavy workloads with relatively few writes. Every node holds a complete copy of the state, and reads go straight to local memory without contacting the leader or running consensus. Clients can read from any node, so adding nodes increases read throughput.</p>

<p>EzRaft is also a practical way to see Raft working end to end. The roughly one hundred lines above already exercise leader election, log replication, membership changes, snapshots, and failover.</p>

<p>That makes the example easy to experiment with. Stop a node and watch the cluster elect a new leader. Follow the log index in the metrics. Change the heartbeat interval in <code class="language-plaintext highlighter-rouge">EzConfig</code> and see how it affects election time. Once these ideas are concrete, moving down a layer to <a href="https://github.com/databendlabs/openraft">OpenRaft</a> is much easier than starting with a set of trait definitions.</p>

<p>To turn this example into a production system, you need to replace two pieces. The first is <code class="language-plaintext highlighter-rouge">FileStorage</code>, which should become a production-grade storage backend.</p>

<p>The second is the service API. The built-in HTTP endpoints are designed for the example and provide neither authentication nor encryption. Use <code class="language-plaintext highlighter-rouge">EzServer</code> as a reference, define routes that fit your application, and call <code class="language-plaintext highlighter-rouge">EzRaft::write</code> and <code class="language-plaintext highlighter-rouge">EzRaft::read</code> underneath. The rest of the stack can remain unchanged.</p>

<h2 id="links">Links</h2>

<ul>
  <li>Code: <a href="https://github.com/drmingdrmer/ezraft">github.com/drmingdrmer/ezraft</a></li>
  <li>crates.io: <a href="https://crates.io/crates/ezraft">crates.io/crates/ezraft</a></li>
  <li>Documentation: <a href="https://docs.rs/ezraft">docs.rs/ezraft</a></li>
  <li>OpenRaft: <a href="https://github.com/databendlabs/openraft">github.com/databendlabs/openraft</a></li>
</ul>

<p>Reference:</p>

<ul>
  <li>
    <p>ezraft on crates.io : <a href="https://crates.io/crates/ezraft">https://crates.io/crates/ezraft</a></p>
  </li>
  <li>
    <p>ezraft docs : <a href="https://docs.rs/ezraft">https://docs.rs/ezraft</a></p>
  </li>
  <li>
    <p>etcd : <a href="https://etcd.io/">https://etcd.io/</a></p>
  </li>
  <li>
    <p>Linearizable Transactions in Distributed Systems : <a href="https://blog.openacid.com/algo/linearizable/">https://blog.openacid.com/algo/linearizable/</a></p>
  </li>
  <li>
    <p>How OpenRaft Optimizes ReadIndex : <a href="https://blog.openacid.com/algo/openraft-read/">https://blog.openacid.com/algo/openraft-read/</a></p>
  </li>
  <li>
    <p>Quorum Reads and Writes with a Minority : <a href="https://blog.openacid.com/algo/quorum/">https://blog.openacid.com/algo/quorum/</a></p>
  </li>
  <li>
    <p>I/O Ordering in Raft : <a href="https://blog.openacid.com/algo/raft-io-order-complete-cn/">https://blog.openacid.com/algo/raft-io-order-complete-cn/</a></p>
  </li>
  <li>
    <p>Raft : <a href="https://raft.github.io/">https://raft.github.io/</a></p>
  </li>
  <li>
    <p>ezraft : <a href="https://github.com/drmingdrmer/ezraft">https://github.com/drmingdrmer/ezraft</a></p>
  </li>
  <li>
    <p>ezraft kvstore example : <a href="https://github.com/drmingdrmer/ezraft/blob/main/examples/kvstore.rs">https://github.com/drmingdrmer/ezraft/blob/main/examples/kvstore.rs</a></p>
  </li>
  <li>
    <p>openraft : <a href="https://github.com/databendlabs/openraft">https://github.com/databendlabs/openraft</a></p>
  </li>
  <li>
    <p>BTreeMap : <a href="https://doc.rust-lang.org/std/collections/struct.BTreeMap.html">https://doc.rust-lang.org/std/collections/struct.BTreeMap.html</a></p>
  </li>
  <li>
    <p>Rust : <a href="https://www.rust-lang.org/">https://www.rust-lang.org/</a></p>
  </li>
  <li>
    <p>serde : <a href="https://serde.rs/">https://serde.rs/</a></p>
  </li>
  <li>
    <p>Apache ZooKeeper : <a href="https://zookeeper.apache.org/">https://zookeeper.apache.org/</a></p>
  </li>
</ul>]]></content><author><name>Zhang Yanpo (drdr.xp)</name></author><category term="algo" /><category term="raft" /><category term="consensus" /><category term="distributed" /><summary type="html"><![CDATA[EzRaft builds on OpenRaft and hides the storage, networking, and service layers. Define just four things—Request, Response, apply, and read—and you have a fault-tolerant, three-node distributed KV store.]]></summary></entry><entry><title type="html">Do Not Read: Raf Is a Useless and Valueless Failed Experiment</title><link href="https://blog.openacid.com/algo/raf-without-term/" rel="alternate" type="text/html" title="Do Not Read: Raf Is a Useless and Valueless Failed Experiment" /><published>2026-05-11T00:00:00+00:00</published><updated>2026-05-11T00:00:00+00:00</updated><id>https://blog.openacid.com/algo/raf-without-term</id><content type="html" xml:base="https://blog.openacid.com/algo/raf-without-term/"><![CDATA[<p><img src="/post-res/raf-without-term/a9a9ca7fa5a1b329-raf-banner-small.png" alt="" /></p>

<blockquote>
  <p>Summary: <code class="language-plaintext highlighter-rouge">raf: Raft without [T]erm</code> is an experimental Raft variant. It does not persist <code class="language-plaintext highlighter-rouge">currentTerm</code> as a separate piece of state. Instead, a candidate reserves a log index when it starts an election, and that index becomes the leader term. This does not remove Raft’s logical time model. It only changes where the term is derived from in storage.</p>
</blockquote>

<blockquote>
  <p>Declaration: This approach is just the same as saving terms in the separate first extra slot in the terms array. So it actually still stores the term and has no value at all. Please do not read this as a useful design; it is only a failed personal experiment.</p>
</blockquote>

<blockquote>
  <p>Note: The idea in this article came from Zhang Yanpo. The code was implemented by Zhang Yanpo by hand. This article was drafted and refined with Codex.</p>
</blockquote>

<p>Repository: <a href="https://github.com/drmingdrmer/raf/tree/v0.1.1">raf</a> (<code class="language-plaintext highlighter-rouge">v0.1.1</code>).</p>

<h2 id="introduction">Introduction</h2>

<p>I have seen a few interesting proposals that try to remove the term from Raft. The idea is appealing: if a consensus protocol can maintain one less piece of persistent state, perhaps both the model and the implementation become simpler. But Raft’s term is not just a counter. It represents logical time, lets nodes distinguish old leaders from new ones, and participates in Raft’s commit safety rule. So the real question is not whether we can simply delete the term. The useful question is whether we can express it in a different way.</p>

<p>The name <code class="language-plaintext highlighter-rouge">raf</code> comes from <code class="language-plaintext highlighter-rouge">Raft without [T]erm</code>. Here, “without term” does not mean the protocol has no term at all. It means <code class="language-plaintext highlighter-rouge">currentTerm</code> is no longer persisted as an independent field. The project turns this idea into a small but serious implementation: avoid storing <code class="language-plaintext highlighter-rouge">currentTerm</code> separately, while still giving every part of Raft that needs a term a reliable source of logical time.</p>

<p>This article explains that core idea. The term still exists as a concept. Logs are still compared by <code class="language-plaintext highlighter-rouge">(term, index)</code>. What changes is the source of the term: it no longer comes from a separately incremented persistent counter; it comes from a log index reserved by an election. The goal of this implementation is not to prove that it is a drop-in replacement for standard Raft in every engineering detail. The goal is to see whether this storage representation preserves the most important safety intuition behind Raft.</p>

<p>We will walk through the storage model, election, replication, commit, and the three-node example in the repository. I assume the reader is already familiar with the basic Raft flow: leader election, AppendEntries, quorum commit, and log ids in the form <code class="language-plaintext highlighter-rouge">(term, index)</code>.</p>

<h2 id="why-term-cannot-disappear">Why Term Cannot Disappear</h2>

<p>In a consensus algorithm, the log records events that have been chosen or are being proposed. The term tells us which logical time those events belong to.</p>

<p>Standard Raft uses the term for several jobs:</p>

<ul>
  <li>Leader election advances the term before choosing a leader. The term gives leaders an ordering.</li>
  <li>Because of that, log freshness is compared by term first, then by index.</li>
  <li>A leader may directly commit only entries from its own term.</li>
</ul>

<p>This role is similar to the ballot number in Paxos. It lets the system decide which history is newer and which candidate is eligible to become leader, even when a node does not know every other node’s complete log.</p>

<p>In short: <strong>a log index is a local event position; a term is the logical time used to compare histories across nodes.</strong></p>

<p><code class="language-plaintext highlighter-rouge">raf</code> keeps the concept of term, but removes its separate storage.</p>

<h2 id="core-idea">Core Idea</h2>

<p>Standard Raft usually persists state shaped roughly like this:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">StandardRaftStorage</span> <span class="p">{</span>
    <span class="n">current_term</span><span class="p">:</span> <span class="n">Term</span><span class="p">,</span>
    <span class="n">voted_for</span><span class="p">:</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="n">NodeId</span><span class="o">&gt;</span><span class="p">,</span>
    <span class="k">log</span><span class="p">:</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="n">LogEntry</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">struct</span> <span class="n">LogEntry</span> <span class="p">{</span>
    <span class="n">term</span><span class="p">:</span> <span class="n">Term</span><span class="p">,</span>
    <span class="n">cmd</span><span class="p">:</span> <span class="n">Cmd</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">raf</code> represents the persistent state as two <code class="language-plaintext highlighter-rouge">Vec</code>s aligned by log index:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">RafStorage</span> <span class="p">{</span>
    <span class="n">terms</span><span class="p">:</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Term</span><span class="o">&gt;</span><span class="p">,</span>
    <span class="n">cmds</span><span class="p">:</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Cmd</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Here:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">terms[i]</code> is the leader term of the log entry at index <code class="language-plaintext highlighter-rouge">i</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">cmds[i]</code> is the application command of the log entry at index <code class="language-plaintext highlighter-rouge">i</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">log_id(i)</code> is still <code class="language-plaintext highlighter-rouge">(terms[i], i)</code>.</li>
</ul>

<p>The following is one possible storage state. <code class="language-plaintext highlighter-rouge">ø</code> means empty command. <code class="language-plaintext highlighter-rouge">cmds</code> only reaches index <code class="language-plaintext highlighter-rouge">7</code>, so indexes <code class="language-plaintext highlighter-rouge">8</code> and <code class="language-plaintext highlighter-rouge">9</code> are not complete log entries yet.</p>

<!-- [ASCII source](assets/storage-layout.txt) -->

<p><img src="/post-res/raf-without-term/10e4df1ad2741f72-storage-layout.png" alt="Storage layout" /></p>

<p>Index by index, this state means:</p>

<ul>
  <li>Index <code class="language-plaintext highlighter-rouge">0</code>: the fixed default entry. <code class="language-plaintext highlighter-rouge">terms[0] = 0</code>, and <code class="language-plaintext highlighter-rouge">cmds[0]</code> is an empty command.</li>
  <li>Index <code class="language-plaintext highlighter-rouge">1</code>: a successful election for term <code class="language-plaintext highlighter-rouge">1</code>. The leader reserved index <code class="language-plaintext highlighter-rouge">1</code> when it was elected and wrote its first empty command there.</li>
  <li>Index <code class="language-plaintext highlighter-rouge">2</code>: a successful election for term <code class="language-plaintext highlighter-rouge">2</code>. The new leader reserved index <code class="language-plaintext highlighter-rouge">2</code>; its first log entry is also an empty command.</li>
  <li>Index <code class="language-plaintext highlighter-rouge">3</code>: a user log entry <code class="language-plaintext highlighter-rouge">C3</code> written by the leader of term <code class="language-plaintext highlighter-rouge">2</code>, so <code class="language-plaintext highlighter-rouge">terms[3] = 2</code> and <code class="language-plaintext highlighter-rouge">cmds[3] = C3</code>.</li>
  <li>Index <code class="language-plaintext highlighter-rouge">4</code>: this position used to be an election attempt for term <code class="language-plaintext highlighter-rouge">4</code>, but it did not produce an established leader. Later, when the leader of term <code class="language-plaintext highlighter-rouge">6</code> was established, this position was filled as an empty command owned by term <code class="language-plaintext highlighter-rouge">6</code>, so now <code class="language-plaintext highlighter-rouge">terms[4] = 6</code>.</li>
  <li>Index <code class="language-plaintext highlighter-rouge">5</code>: this position used to be another failed election attempt for term <code class="language-plaintext highlighter-rouge">5</code>. It was also later filled as an empty command owned by term <code class="language-plaintext highlighter-rouge">6</code>, so now <code class="language-plaintext highlighter-rouge">terms[5] = 6</code>.</li>
  <li>Index <code class="language-plaintext highlighter-rouge">6</code>: a successful election for term <code class="language-plaintext highlighter-rouge">6</code>. The leader reserved index <code class="language-plaintext highlighter-rouge">6</code> when it was elected, and wrote its first empty command there.</li>
  <li>Index <code class="language-plaintext highlighter-rouge">7</code>: a user log entry <code class="language-plaintext highlighter-rouge">C7</code> written by the leader of term <code class="language-plaintext highlighter-rouge">6</code>, so <code class="language-plaintext highlighter-rouge">terms[7] = 6</code> and <code class="language-plaintext highlighter-rouge">cmds[7] = C7</code>.</li>
  <li>Index <code class="language-plaintext highlighter-rouge">8</code>: a new election attempt for term <code class="language-plaintext highlighter-rouge">8</code>. So far we have only seen <code class="language-plaintext highlighter-rouge">terms[8] = 8</code>; there is no command for this index yet, so it is not a complete log entry.</li>
  <li>Index <code class="language-plaintext highlighter-rouge">9</code>: another election attempt for term <code class="language-plaintext highlighter-rouge">9</code>. Like index <code class="language-plaintext highlighter-rouge">8</code>, it currently has only a term record and no command. From this state alone, we cannot tell whether it will eventually become a leader.</li>
</ul>

<p><em>Standard Raft persists a separate <code class="language-plaintext highlighter-rouge">current_term</code> and an array of <code class="language-plaintext highlighter-rouge">(term, command)</code> log entries. <code class="language-plaintext highlighter-rouge">raf</code> is similar, but splits the term and command at each index into two aligned <code class="language-plaintext highlighter-rouge">Vec</code>s.</em></p>

<h2 id="storage-model">Storage Model</h2>

<p>Index <code class="language-plaintext highlighter-rouge">0</code> is a fixed default entry. This keeps the types simple and avoids using <code class="language-plaintext highlighter-rouge">Option</code> for the initial position:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>terms[0] = 0
cmds[0]  = empty
</code></pre></div></div>

<p>When both <code class="language-plaintext highlighter-rouge">Vec</code>s have a value at the same index, that index is a complete log entry. Otherwise, the index has a term but no command. That state represents an election in progress: the term has been observed, but no log entry has been written at that position yet.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>log[index] = (terms[index], cmds[index])
log_id     = (terms[index], index)
</code></pre></div></div>

<p><em><code class="language-plaintext highlighter-rouge">terms</code> and <code class="language-plaintext highlighter-rouge">cmds</code> share the same log index. During election, <code class="language-plaintext highlighter-rouge">terms</code> may be ahead of <code class="language-plaintext highlighter-rouge">cmds</code>.</em></p>

<p>During an election, <code class="language-plaintext highlighter-rouge">terms</code> can temporarily be longer than <code class="language-plaintext highlighter-rouge">cmds</code>. A candidate first reserves an index as its term. Multiple failed elections may reserve multiple indexes. Only after some candidate becomes an established leader are the positions with missing commands filled with empty commands.</p>

<p>So this implementation maintains a few basic facts:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">cmds</code> should never be longer than <code class="language-plaintext highlighter-rouge">terms</code>.</li>
  <li>When an election first reserves a term slot, <code class="language-plaintext highlighter-rouge">terms[term] = term</code>.</li>
  <li>When an established leader fills positions that still miss commands, those positions are rewritten to the current <code class="language-plaintext highlighter-rouge">leader.term</code>.</li>
</ul>

<p>Therefore <code class="language-plaintext highlighter-rouge">terms[i] &lt;= i</code> is not a protocol invariant. A backfilled empty entry may have <code class="language-plaintext highlighter-rouge">terms[i] &gt; i</code>. That is not a problem by itself; standard Raft terms can also be greater than log indexes. The important property is different: every complete log entry must carry the term of an established leader, except for the fixed default entry at index <code class="language-plaintext highlighter-rouge">0</code>.</p>

<h2 id="why-split">Why Split</h2>
<p><code class="language-plaintext highlighter-rouge">terms</code>
 and 
<code class="language-plaintext highlighter-rouge">cmds</code></p>

<p>This implementation separates leader terms and application commands into two <code class="language-plaintext highlighter-rouge">Vec</code>s. Raft’s protocol semantics do not require this layout. It is mainly a storage design choice that creates room for cleaner optimization.</p>

<p>For example, a leader may write many consecutive log entries during one term, and all of those entries have the same term. A storage engine could compress long runs of identical terms into a compact representation, while storing commands according to application needs. Once the two streams are separated, term compression, command persistence, and payload encoding can evolve independently.</p>

<p>This is the experimental value of the project. It expresses the relationship between “logical time” and “log position” in Raft more directly, then asks whether that expression can simplify persistent state.</p>

<h2 id="starting-an-election">Starting an Election</h2>

<p>When a candidate starts an election, it uses the next index of the <code class="language-plaintext highlighter-rouge">terms</code> array as the new term:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="n">term</span> <span class="o">=</span> <span class="n">terms</span><span class="nf">.len</span><span class="p">();</span>
<span class="n">terms</span><span class="nf">.push</span><span class="p">(</span><span class="n">term</span><span class="p">);</span>
</code></pre></div></div>

<p>In this code:</p>

<ul>
  <li>The candidate declares that it wants to use <code class="language-plaintext highlighter-rouge">term</code> as its leader term.</li>
  <li>The local persistent state records that this index has been reserved by an election.</li>
</ul>

<p>The candidate then sends <code class="language-plaintext highlighter-rouge">RequestVote</code>. This part is the same as standard Raft. The request carries two important pieces of information:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">term</code>: the leader term the candidate wants to use.</li>
  <li><code class="language-plaintext highlighter-rouge">last_log_id</code>: the <code class="language-plaintext highlighter-rouge">(term, index)</code> of the candidate’s last complete log entry.</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">last_log_id</code> is computed from the last index in <code class="language-plaintext highlighter-rouge">cmds</code>:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="n">last_log_index</span> <span class="o">=</span> <span class="n">cmds</span><span class="nf">.len</span><span class="p">()</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>
<span class="k">let</span> <span class="n">last_log_id</span> <span class="o">=</span> <span class="p">(</span><span class="n">terms</span><span class="p">[</span><span class="n">last_log_index</span><span class="p">],</span> <span class="n">last_log_index</span><span class="p">);</span>
</code></pre></div></div>

<p>The meaning of <code class="language-plaintext highlighter-rouge">last_log_id</code> is the same as in standard Raft: the voter uses it to check whether the candidate’s log is at least as up to date as its own. Notice the separation here. The new election term comes from <code class="language-plaintext highlighter-rouge">terms.len()</code>, while <code class="language-plaintext highlighter-rouge">last_log_id</code> comes from <code class="language-plaintext highlighter-rouge">cmds.len() - 1</code>. These can point to different indexes.</p>

<p>In the following example, the complete log currently reaches only index <code class="language-plaintext highlighter-rouge">3</code>, so <code class="language-plaintext highlighter-rouge">last_log_id = (2, 3)</code>. When the candidate starts a new election, it uses <code class="language-plaintext highlighter-rouge">terms.len() = 4</code> as the new term and first writes index <code class="language-plaintext highlighter-rouge">4</code> into <code class="language-plaintext highlighter-rouge">terms</code>. At this point, <code class="language-plaintext highlighter-rouge">cmds</code> still reaches only index <code class="language-plaintext highlighter-rouge">3</code>, because this candidate has not become an established leader yet.</p>

<!-- [ASCII source](assets/leader-election-term4.txt) -->

<p><img src="/post-res/raf-without-term/289a45a69ad1dd8d-leader-election-term4.png" alt="Leader election term=4" /></p>

<p>If the election for term <code class="language-plaintext highlighter-rouge">4</code> does not reach a quorum, it leaves only an observed term index in <code class="language-plaintext highlighter-rouge">terms</code>; it does not create a new command. The next election will use <code class="language-plaintext highlighter-rouge">terms.len()</code> again, which is now term <code class="language-plaintext highlighter-rouge">5</code>.</p>

<!-- [ASCII source](assets/leader-election-term5.txt) -->

<p><img src="/post-res/raf-without-term/3863b4a533d8cdf5-leader-election-term5.png" alt="Leader election retry term=5" /></p>

<p>At this point <code class="language-plaintext highlighter-rouge">last_log_id</code> is still <code class="language-plaintext highlighter-rouge">(2, 3)</code>, because <code class="language-plaintext highlighter-rouge">cmds</code> has not moved beyond index <code class="language-plaintext highlighter-rouge">3</code>. What changed is the candidate term: it advanced from <code class="language-plaintext highlighter-rouge">4</code> to <code class="language-plaintext highlighter-rouge">5</code>. Only after an election succeeds and a leader is established does the system rewrite the positions with missing commands to this leader’s term and fill <code class="language-plaintext highlighter-rouge">cmds</code> with empty commands.</p>

<h2 id="how-a-voter-handles-requestvote">How a Voter Handles RequestVote</h2>

<p>When a voter receives <code class="language-plaintext highlighter-rouge">RequestVote</code>, it checks three things:</p>

<ol>
  <li>Whether the requested term is greater than the last term observed locally.</li>
  <li>Whether the requested term slot does not already exist locally.</li>
  <li>Whether the candidate’s log, represented by <code class="language-plaintext highlighter-rouge">last_log_id</code>, is fresh enough. It must not be older than the voter’s own log.</li>
</ol>

<p>Aside from the term check, the rest of the logic is standard Raft. The subtle difference is that the term is no longer stored independently, so term freshness is expressed through <code class="language-plaintext highlighter-rouge">terms</code>.</p>

<p>The core condition looks like this:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="n">local_last_log_index</span> <span class="o">=</span> <span class="n">cmds</span><span class="nf">.len</span><span class="p">()</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>
<span class="k">let</span> <span class="n">local_last_log_id</span> <span class="o">=</span> <span class="p">(</span><span class="n">terms</span><span class="p">[</span><span class="n">local_last_log_index</span><span class="p">],</span> <span class="n">local_last_log_index</span><span class="p">);</span>
<span class="k">let</span> <span class="n">local_last_term</span> <span class="o">=</span> <span class="n">terms</span><span class="p">[</span><span class="n">terms</span><span class="nf">.len</span><span class="p">()</span> <span class="o">-</span> <span class="mi">1</span><span class="p">];</span>

<span class="k">let</span> <span class="n">can_vote</span> <span class="o">=</span>
    <span class="n">req</span><span class="py">.term</span> <span class="o">&gt;</span> <span class="n">local_last_term</span>
        <span class="o">&amp;&amp;</span> <span class="n">req</span><span class="py">.term</span> <span class="o">&gt;=</span> <span class="n">terms</span><span class="nf">.len</span><span class="p">()</span>
        <span class="o">&amp;&amp;</span> <span class="n">req</span><span class="py">.last_log_id</span> <span class="o">&gt;=</span> <span class="n">local_last_log_id</span><span class="p">;</span>
</code></pre></div></div>

<p>The next diagram sends the same <code class="language-plaintext highlighter-rouge">RequestVote { term: 5, last_log_id: (2, 3) }</code> to voters with three different local states. The candidate’s own state is at the top. The three branches below it show how each voter makes its decision.</p>

<!-- [ASCII source](assets/request-vote.txt) -->

<p><img src="/post-res/raf-without-term/d310c7c93bee8cbb-request-vote.png" alt="RequestVote scenarios" /></p>

<p>The three outcomes are:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">granted</code>: the voter’s <code class="language-plaintext highlighter-rouge">terms</code> reaches only index <code class="language-plaintext highlighter-rouge">3</code>, and <code class="language-plaintext highlighter-rouge">cmds</code> also reaches only index <code class="language-plaintext highlighter-rouge">3</code>. Therefore <code class="language-plaintext highlighter-rouge">req.term = 5</code> is greater than the last locally observed term, names a term slot that has not appeared locally, and <code class="language-plaintext highlighter-rouge">req.last_log_id = (2, 3)</code> is not behind the voter. The vote can be granted.</li>
  <li><code class="language-plaintext highlighter-rouge">rejected: term=7</code>: the voter has already observed a later term <code class="language-plaintext highlighter-rouge">7</code>. Since <code class="language-plaintext highlighter-rouge">req.term = 5</code> is not greater than the last locally observed term, the candidate’s requested term is stale from this voter’s perspective, so the vote is rejected.</li>
  <li><code class="language-plaintext highlighter-rouge">rejected: last log id = (4,4)</code>: the voter’s last complete log entry is <code class="language-plaintext highlighter-rouge">(4, 4)</code>, which is newer than the candidate’s <code class="language-plaintext highlighter-rouge">(2, 3)</code>. Even if the requested term could be recorded, the log freshness check still fails, so the vote is rejected.</li>
</ul>

<p>If the request is valid, the voter records the term in local <code class="language-plaintext highlighter-rouge">terms</code>. If local <code class="language-plaintext highlighter-rouge">terms</code> is shorter than <code class="language-plaintext highlighter-rouge">req.term</code>, it fills the missing positions with default indexes until local state contains index <code class="language-plaintext highlighter-rouge">req.term</code>:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">can_vote</span> <span class="p">{</span>
    <span class="k">while</span> <span class="n">terms</span><span class="nf">.len</span><span class="p">()</span> <span class="o">&lt;=</span> <span class="n">req</span><span class="py">.term</span> <span class="p">{</span>
        <span class="k">let</span> <span class="n">index</span> <span class="o">=</span> <span class="n">terms</span><span class="nf">.len</span><span class="p">();</span>
        <span class="n">terms</span><span class="nf">.push</span><span class="p">(</span><span class="n">index</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>These default items have term values equal to their own indexes. They mean the node has observed the corresponding term indexes. They do not mean those indexes are complete log entries, because the matching <code class="language-plaintext highlighter-rouge">cmds</code> may not exist yet. If a later leader fills those positions as complete empty entries, it rewrites their terms to its own <code class="language-plaintext highlighter-rouge">leader.term</code>. On the final iteration, <code class="language-plaintext highlighter-rouge">index == req.term</code>, so the voter has observed and accepted that term. Later, it will not accept an older term or a term index that already exists locally.</p>

<p>This replaces the role of standard Raft’s persisted <code class="language-plaintext highlighter-rouge">currentTerm</code>, but it is not fully equivalent to standard Raft’s <code class="language-plaintext highlighter-rouge">votedFor</code>. The current implementation does not persist “which candidate this term was granted to.” As a result, RequestVote retry and restart behavior are more conservative. We will return to this trade-off in “Current Boundaries.”</p>

<p><em>The candidate chooses <code class="language-plaintext highlighter-rouge">terms.len()</code> as its term. Other nodes record that term in their local <code class="language-plaintext highlighter-rouge">terms</code> when they grant the vote.</em></p>

<p>After the candidate receives granted replies from a quorum, it becomes an established leader. It first rewrites the local <code class="language-plaintext highlighter-rouge">cmds.len()..terms.len()</code> range to its own <code class="language-plaintext highlighter-rouge">leader.term</code>, then appends empty commands so that <code class="language-plaintext highlighter-rouge">cmds.len()</code> catches up with <code class="language-plaintext highlighter-rouge">terms.len()</code>. The index reserved by the leader’s election becomes this leader’s first complete log entry.</p>

<h2 id="establishing-leader-state">Establishing Leader State</h2>

<p>After a candidate becomes an established leader, it keeps the core state of this leadership in memory. You can think of it as the following structure:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">LeaderState</span> <span class="p">{</span>
    <span class="n">term</span><span class="p">:</span> <span class="n">Term</span><span class="p">,</span>
    <span class="n">granted_nodes</span><span class="p">:</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="n">NodeId</span><span class="o">&gt;</span><span class="p">,</span>
    <span class="n">replications</span><span class="p">:</span> <span class="n">BTreeMap</span><span class="o">&lt;</span><span class="n">NodeId</span><span class="p">,</span> <span class="n">ReplicationState</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">struct</span> <span class="n">ReplicationState</span> <span class="p">{</span>
    <span class="n">matched</span><span class="p">:</span> <span class="n">LogIndex</span><span class="p">,</span>
    <span class="n">end</span><span class="p">:</span> <span class="n">LogIndex</span><span class="p">,</span>
    <span class="n">inflight</span><span class="p">:</span> <span class="nb">bool</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The important fields are:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">term</code>: the log index reserved when this leader was elected. All later log entries produced by this leader write this term into the corresponding positions of the <code class="language-plaintext highlighter-rouge">terms</code> array.</li>
  <li><code class="language-plaintext highlighter-rouge">granted_nodes</code>: the node ids that granted this leadership. This proves the leader was chosen by a quorum.</li>
  <li><code class="language-plaintext highlighter-rouge">replications</code>: the replication progress of each node from the leader’s point of view. <code class="language-plaintext highlighter-rouge">matched</code> is the largest index known to match on that node; <code class="language-plaintext highlighter-rouge">end</code> is the upper bound used for further probing or replication; <code class="language-plaintext highlighter-rouge">inflight</code> prevents sending multiple Append requests to the same node at the same time.</li>
</ul>

<blockquote>
  <p>The leader also has its own replication state.
This makes commit calculation uniform:
look at which nodes have <code class="language-plaintext highlighter-rouge">matched</code> covering an index, then check whether those nodes form a quorum.</p>
</blockquote>

<p>After the leader is established, every position that already exists in local <code class="language-plaintext highlighter-rouge">terms</code> but is still missing from <code class="language-plaintext highlighter-rouge">cmds</code> is taken over by the current leader: the term at that position is rewritten to <code class="language-plaintext highlighter-rouge">leader.term</code>, and the command is filled with an empty command. After that, every local index on the leader has a corresponding command, and new application writes can start at the next index.</p>

<!-- [ASCII source](assets/establish-leader.txt) -->

<p><img src="/post-res/raf-without-term/3494a2b8ffc3e936-establish-leader.png" alt="Establish leader" /></p>

<p>In this example, index <code class="language-plaintext highlighter-rouge">4</code> used to be a term slot left behind by a failed election, and term <code class="language-plaintext highlighter-rouge">5</code> is the index reserved by the current leader. When the candidate for term <code class="language-plaintext highlighter-rouge">5</code> becomes an established leader, indexes <code class="language-plaintext highlighter-rouge">4</code> and <code class="language-plaintext highlighter-rouge">5</code> are both rewritten as term <code class="language-plaintext highlighter-rouge">5</code> empty log entries. The <code class="language-plaintext highlighter-rouge">ø</code> at index <code class="language-plaintext highlighter-rouge">5</code> is the entry reserved by this leader’s election; the <code class="language-plaintext highlighter-rouge">ø</code> at index <code class="language-plaintext highlighter-rouge">4</code> is the backfilled entry that keeps the log prefix contiguous.</p>

<h2 id="appending-a-log-entry">Appending a Log Entry</h2>

<p>After a node becomes leader, each new application write appends a log entry. The term does not change. It is still the term chosen when the leader was elected:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">terms</span><span class="nf">.push</span><span class="p">(</span><span class="n">leader</span><span class="py">.term</span><span class="p">);</span>
<span class="n">cmds</span><span class="nf">.push</span><span class="p">(</span><span class="n">user_cmd</span><span class="p">);</span>
</code></pre></div></div>

<p>So within one leader term, all later log entries have the same <code class="language-plaintext highlighter-rouge">terms[i]</code>. This matches standard Raft behavior. Only the source of the term is different.</p>

<h2 id="log-replication">Log Replication</h2>

<p>The leader sends Append requests to the other nodes. Conceptually, each request first names a previous log id that is already known to match, then carries a contiguous segment of log entries after that position:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">Append</span> <span class="p">{</span>
    <span class="n">term</span><span class="p">:</span> <span class="n">Term</span><span class="p">,</span>
    <span class="n">prev_log_id</span><span class="p">:</span> <span class="n">LogId</span><span class="p">,</span>
    <span class="n">terms</span><span class="p">:</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Term</span><span class="o">&gt;</span><span class="p">,</span>
    <span class="n">cmds</span><span class="p">:</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Cmd</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Here <code class="language-plaintext highlighter-rouge">prev_log_id</code> is the matching point. <code class="language-plaintext highlighter-rouge">terms</code> and <code class="language-plaintext highlighter-rouge">cmds</code> are the real entries after that point. They must have the same length, and their first item corresponds to <code class="language-plaintext highlighter-rouge">prev_log_id.index + 1</code>. The follower first checks whether its local log has the same <code class="language-plaintext highlighter-rouge">LogId</code> at <code class="language-plaintext highlighter-rouge">prev_log_id.index</code>. Only if that previous position matches does it accept the entries that follow.</p>

<p>This design is close to standard Raft’s AppendEntries. Standard Raft carries <code class="language-plaintext highlighter-rouge">prevLogIndex</code> and <code class="language-plaintext highlighter-rouge">prevLogTerm</code> separately; <code class="language-plaintext highlighter-rouge">raf</code> combines them into a single <code class="language-plaintext highlighter-rouge">prev_log_id</code>. That is clearer than using the first entry in the request as the matching point, because the request’s <code class="language-plaintext highlighter-rouge">terms</code> and <code class="language-plaintext highlighter-rouge">cmds</code> represent only the real entries to replicate.</p>

<p>The following diagram shows one Append request applied to several follower states. The request has <code class="language-plaintext highlighter-rouge">term = 5</code>, <code class="language-plaintext highlighter-rouge">prev_log_id = (2, 3)</code>, and carries the consecutive log entries for indexes <code class="language-plaintext highlighter-rouge">4..=5</code>.</p>

<!-- [ASCII source](assets/append-replication.txt) -->

<p><img src="/post-res/raf-without-term/a0e8c4e1f7671c25-append-replication.png" alt="Append replication scenarios" /></p>

<p>The three outcomes are:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">accepted</code>: the follower matches the leader at <code class="language-plaintext highlighter-rouge">prev_log_id = (2, 3)</code>, so it can accept this Append. <code class="language-plaintext highlighter-rouge">{...}</code> marks the <code class="language-plaintext highlighter-rouge">terms</code> range overwritten by this request, as well as the command range appended because it was missing locally. Here the term at index <code class="language-plaintext highlighter-rouge">4</code> is updated from an old value to <code class="language-plaintext highlighter-rouge">5</code>, and command <code class="language-plaintext highlighter-rouge">c5</code> is appended at index <code class="language-plaintext highlighter-rouge">5</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">conflict at prev_log_id</code>: <code class="language-plaintext highlighter-rouge">*</code> marks the conflict position. The follower’s term at index <code class="language-plaintext highlighter-rouge">3</code> is <code class="language-plaintext highlighter-rouge">3</code>, while the request’s <code class="language-plaintext highlighter-rouge">prev_log_id</code> is <code class="language-plaintext highlighter-rouge">(2, 3)</code>. Since the previous log id does not match, the follower returns a conflict index immediately. The leader must try again with an earlier <code class="language-plaintext highlighter-rouge">prev_log_id</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">rejected: follower has newer term</code>: the follower has already observed term <code class="language-plaintext highlighter-rouge">6</code> at index <code class="language-plaintext highlighter-rouge">6</code>, while the Append request is from term <code class="language-plaintext highlighter-rouge">5</code>. This request comes from a stale leader, so the follower rejects it without modifying the log.</li>
</ul>

<p>The handling logic is:</p>

<ol>
  <li>If the request term is older than the last observed local term, reject it.</li>
  <li>Check the follower’s local log at <code class="language-plaintext highlighter-rouge">prev_log_id</code>. If it does not match, return the conflict index.</li>
  <li>If <code class="language-plaintext highlighter-rouge">prev_log_id</code> matches, process the real entries starting at <code class="language-plaintext highlighter-rouge">prev_log_id.index + 1</code>.</li>
  <li>If later local commands diverge from the leader, truncate the local commands.</li>
  <li>Overwrite the local <code class="language-plaintext highlighter-rouge">terms</code> range covered by this request.</li>
  <li>Append only the commands that are missing locally.</li>
</ol>

<p><em>Append first finds the common prefix with <code class="language-plaintext highlighter-rouge">prev_log_id</code>, then truncates the follower’s conflicting suffix, and finally copies the leader’s entries that the follower is missing.</em></p>

<p>This is still Raft’s core replication model: the leader finds a shared log prefix, then replaces the follower’s divergent suffix with its own.</p>

<h2 id="advancing-commit">Advancing Commit</h2>

<p>Replication to a quorum does not mean every historical entry can be committed immediately. Standard Raft has an important rule: a leader may only directly commit log entries from its own current term. Entries from older terms become committed only as a consequence of committing an entry from the current term.</p>

<p><code class="language-plaintext highlighter-rouge">raf</code> keeps this rule. Although an established leader rewrites earlier gap slots to its own term, the leader still only directly commits matched indexes that are not smaller than its election index. Empty entries backfilled before <code class="language-plaintext highlighter-rouge">leader.term</code> are committed only indirectly, together with the leader’s election index or a later entry.</p>

<p>Intuitively:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="nf">quorum_has_matched</span><span class="p">(</span><span class="n">index</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">index</span> <span class="o">&gt;=</span> <span class="n">leader</span><span class="py">.term</span> <span class="p">{</span>
    <span class="nf">commit</span><span class="p">(</span><span class="n">index</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The reason is the same as in standard Raft: once an index is committed, every future valid leader must contain it and must not overwrite it.</p>

<p>The following state shows why “replicated to a quorum” is not enough. The leader of term <code class="language-plaintext highlighter-rouge">6</code> has replicated the old log entries at indexes <code class="language-plaintext highlighter-rouge">4</code> and <code class="language-plaintext highlighter-rouge">5</code> to quorum <code class="language-plaintext highlighter-rouge">A+B</code>, but that quorum has not yet matched the leader’s own term index <code class="language-plaintext highlighter-rouge">6</code>. Therefore indexes <code class="language-plaintext highlighter-rouge">4</code> and <code class="language-plaintext highlighter-rouge">5</code> still cannot be committed:</p>

<!-- [ASCII source](assets/not-committed.txt) -->

<p><img src="/post-res/raf-without-term/d98978c7f1b7fab2-not-committed.png" alt="Not committed yet" /></p>

<p>If a new leader for term <code class="language-plaintext highlighter-rouge">7</code> appears later, and its <code class="language-plaintext highlighter-rouge">last_log_id=(5,6)</code> is newer, it can overwrite those uncommitted log entries. In the diagram, <code class="language-plaintext highlighter-rouge">{x}</code> marks the range replaced by the new leader.</p>

<p><em>A leader only directly commits an index that is both covered by a quorum and inside the current leader’s term range.</em></p>

<h2 id="example">Example</h2>

<p>The repository includes a three-node in-process example that demonstrates the basic flow described in this article. It creates three <code class="language-plaintext highlighter-rouge">Raf</code> nodes, connects them with <code class="language-plaintext highlighter-rouge">InProcessNetwork</code>, explicitly triggers an election on node 1, and then writes a few log entries through the leader. Metrics show the role, term, commit index, and replication progress.</p>

<p>The example source is here:</p>

<p>https://github.com/drmingdrmer/raf/blob/v0.1.1/examples/three_node.rs</p>

<p>Run it from the repository root:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cargo run <span class="nt">--example</span> three_node
</code></pre></div></div>

<p>This example is not a production deployment template. It is a minimal demonstration for observing the core protocol state transitions. It logs to stderr and does not include an election timer, heartbeat, snapshots, or membership changes.</p>

<h2 id="current-boundaries">Current Boundaries</h2>

<p>The current implementation is intentionally small. It leaves out several features that a complete production system would usually need:</p>

<ul>
  <li>Automatic election triggering.</li>
  <li>Snapshots and log compaction.</li>
  <li>Membership changes.</li>
  <li>Heartbeats.</li>
  <li>RequestVote retry logic.</li>
  <li>Persistence semantics for application payloads.</li>
</ul>

<p>Automatic election triggering corresponds to the election timer in standard Raft. A node periodically checks whether it has gone too long without seeing a valid leader. If it times out, it starts a new election. This can be implemented by an external timer that calls <code class="language-plaintext highlighter-rouge">Raf::elect()</code>. It does not need to live inside the core <code class="language-plaintext highlighter-rouge">raf</code> state machine, so the current implementation leaves it out.</p>

<p>RequestVote retry has a subtler boundary. Suppose a target node successfully handles a <code class="language-plaintext highlighter-rouge">RequestVote</code>, but the reply is lost in the network. If the candidate retries the same request, the target node has already recorded this term in <code class="language-plaintext highlighter-rouge">terms[req.term]</code>. Under the current rule, it rejects the retry because that term index already exists.</p>

<p>One optional fix is to add an in-memory <code class="language-plaintext highlighter-rouge">voted_for</code> field that records which candidate owns a term. Then a retry from the same candidate for the same term can be recognized and granted again. This field does not necessarily need to be persisted. If a node restarts and loses <code class="language-plaintext highlighter-rouge">voted_for</code>, it can conservatively reject every <code class="language-plaintext highlighter-rouge">RequestVote</code> that uses a term already present locally. That creates a small availability issue, but only after restart; it does not change the persisted relationship between logs and terms.</p>

<p><em>If a RequestVote reply is lost, the retry sees an already existing term. An optional in-memory <code class="language-plaintext highlighter-rouge">voted_for</code> field can improve availability in that case.</em></p>

<p>These features can all be added around the core model. This article focuses on the central question: if the term comes from the log index, can Raft election, replication, and commit still be expressed in the familiar way?</p>

<h2 id="summary">Summary</h2>

<p><code class="language-plaintext highlighter-rouge">raf</code> is not “Raft without any term.” It still has terms, and it still compares logs by <code class="language-plaintext highlighter-rouge">(term, index)</code>. What it removes is the independently persisted <code class="language-plaintext highlighter-rouge">currentTerm</code>; the leader term is bound to the log index reserved by an election.</p>

<p>This change turns the storage state into two <code class="language-plaintext highlighter-rouge">Vec</code>s aligned by index:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">RafStorage</span> <span class="p">{</span>
    <span class="n">terms</span><span class="p">:</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Term</span><span class="o">&gt;</span><span class="p">,</span>
    <span class="n">cmds</span><span class="p">:</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Cmd</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>During election, a candidate chooses <code class="language-plaintext highlighter-rouge">terms.len()</code> as its term. After it becomes leader, both the missing-command gap slots and later log entries use this leader term. Replication and commitment still follow the basic Raft rules.</p>

<p>That is the core of this experimental implementation: keep Raft’s logical time model, but change where that logical time comes from in persistent state.</p>

<p>Repository: <a href="https://github.com/drmingdrmer/raf/tree/v0.1.1">raf</a> (<code class="language-plaintext highlighter-rouge">v0.1.1</code>).</p>

<p>Reference:</p>

<ul>
  <li>raf : <a href="https://github.com/drmingdrmer/raf/tree/v0.1.1">https://github.com/drmingdrmer/raf/tree/v0.1.1</a></li>
</ul>]]></content><author><name>Zhang Yanpo (drdr.xp)</name></author><category term="algo" /><category term="distributed" /><category term="raft" /><category term="consensus" /><category term="storage" /><summary type="html"><![CDATA[raf is an experimental Raft variant that does not persist currentTerm as a separate field. Instead, each election reserves a log index, and that index becomes the leader term.]]></summary></entry><entry><title type="html">Histogram Done Right: 2KB Memory, 0.2% Error</title><link href="https://blog.openacid.com/algo/histogram/" rel="alternate" type="text/html" title="Histogram Done Right: 2KB Memory, 0.2% Error" /><published>2026-04-02T00:00:00+00:00</published><updated>2026-04-02T00:00:00+00:00</updated><id>https://blog.openacid.com/algo/histogram</id><content type="html" xml:base="https://blog.openacid.com/algo/histogram/"><![CDATA[<p><img src="/post-res/histogram/7b72af58792aed59-histagram-banner.png" alt="" /></p>

<h2 id="the-problem-tracking-request-latency-without-slowing-things-down">The Problem: Tracking Request Latency Without Slowing Things Down</h2>

<p>When we were building <a href="https://github.com/databendlabs/databend">Databend</a> and <a href="https://github.com/databendlabs/openraft">OpenRaft</a>,
we ran into a familiar need: we wanted to see how request latency was distributed across the system, in real time, without burning CPU or memory to do it.
This article explains the design behind <a href="https://github.com/drmingdrmer/base2histogram">base2histogram</a>, the library we built to solve it.</p>

<p>Consider the life of a single Raft log entry. It passes through several stages, and each one has its own latency profile:</p>

<ul>
  <li>Received → written to storage</li>
  <li>Persisted to local disk</li>
  <li>Replicated to remote nodes</li>
  <li>Acknowledged by a majority quorum</li>
  <li>Committed → applied to the state machine</li>
</ul>

<p>A <a href="https://en.wikipedia.org/wiki/Histogram">histogram</a> is the natural tool here — plot latency on the x-axis, request count on the y-axis, and you get an immediate picture of where time is being spent.</p>

<p><img src="/post-res/histogram/d0883e49611bab5c-001-article-latency.png" alt="001-latency histogram" /></p>

<p>This kind of visibility is what lets you find bottlenecks and fix the right thing.</p>

<p>But there’s a catch: collecting metrics can’t get in the way of doing actual work. So the histogram needs to be:</p>

<ul>
  <li><strong>O(1) to record</strong> — no sorting, no rebalancing, nothing that can stall a hot path</li>
  <li><strong>Tiny in memory</strong> — the system may run hundreds or thousands of these at once</li>
  <li><strong>Queryable for <a href="https://en.wikipedia.org/wiki/Percentile">percentiles</a></strong> — P50, P95, P99</li>
</ul>

<p>Let’s walk through how we designed one that hits all three.</p>

<h2 id="recording-getting-samples-into-buckets">Recording: Getting Samples Into Buckets</h2>

<h3 id="why-log-scale-buckets">Why Log-Scale Buckets</h3>

<p>Most requests cluster around some typical latency, with a few outliers on both ends. This is a <a href="https://en.wikipedia.org/wiki/Log-normal_distribution">log-normal distribution</a> — take the log of the latency values, and the shape becomes a classic <a href="https://en.wikipedia.org/wiki/Normal_distribution">bell curve</a>.</p>

<p>The signature look: a peak at lower values, then a gradual <a href="https://en.wikipedia.org/wiki/Long_tail">long tail</a> stretching to the right.</p>

<p><img src="/post-res/histogram/1e4a9be7908c5de8-002-lognormal-distribution.png" alt="" /></p>

<p>To build a histogram, we divide the x-axis into buckets and count how many samples land in each one.</p>

<p>The key question is how to size those buckets.
Equal-width buckets work great for a normal distribution, but latency is log-normal — the data only looks uniform on a logarithmic scale.
So the buckets need to grow on a <strong><a href="https://en.wikipedia.org/wiki/Logarithmic_scale">log scale</a></strong>, not a <strong>linear</strong> one.</p>

<p>The simplest version of this: each bucket is twice as wide as the one before it.</p>

<p><code class="language-plaintext highlighter-rouge">[0,1), [1,2), [2,4), [4,8), [8,16), ...</code></p>

<p>Why powers of 2? Because multiplying by 2 is free on a CPU, and mapping a value to its bucket takes a single <a href="https://en.wikipedia.org/wiki/Find_first_set#CLZ">leading zero count</a> instruction.</p>

<p>Simulate a log-normal workload, plot the bucket counts with the bucket index on the x-axis (effectively a log transform), and the result is a clean bell curve:</p>

<p><img src="/post-res/histogram/0587ffc4d78c5e01-003-log2-bucketing.png" alt="" /></p>

<p>Storage-wise, this is great — 65 buckets cover the entire u64 range.
Resolution-wise, not so much. The last bucket spans half of all possible values. Everything that lands there is a blur.</p>

<p><img src="/post-res/histogram/823fba54ce3daa43-004-log2-coarse.png" alt="" /></p>

<h3 id="a-tempting-fix-we-passed-on">A Tempting Fix We Passed On</h3>

<p>An obvious improvement: use a smaller growth factor, like 1.1× instead of 2×.
More buckets, finer resolution:</p>

<p><img src="/post-res/histogram/4431414f6473152b-005-1.1x-buckets.png" alt="" /></p>

<p>The problem is cost. Finding the right bucket for a value <code class="language-plaintext highlighter-rouge">l</code> means solving for the smallest <code class="language-plaintext highlighter-rouge">x</code> where <code class="language-plaintext highlighter-rouge">1 + 1.1 + 1.1^2 + ... + 1.1^x &gt;= l</code> — and that requires floating-point logarithms. That’s real overhead on a hot path.</p>

<p>We wanted to stay in the world of integers and bit operations.</p>

<h3 id="the-trick-float-like-encoding">The Trick: Float-Like Encoding</h3>

<p>Here’s the idea that makes everything work. We keep the roughly exponential bucket sizes, but we encode each bucket using a fixed number of bits — a parameter we call WIDTH.</p>

<p>Think of a bucket’s lower bound as a tiny <a href="https://en.wikipedia.org/wiki/Floating-point_arithmetic">floating-point number</a>.
The <a href="https://en.wikipedia.org/wiki/Bit_numbering#Most_significant_bit">MSB</a> position gives you the exponent (which group of buckets you’re in),
and the next few bits give you the offset within that group.</p>

<p>With WIDTH=3 (the default), a bucket boundary looks like this in binary:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>00..00 1 xx 00..00
       |
       MSB
&lt;- significant
</code></pre></div></div>

<p>The position of the leading <code class="language-plaintext highlighter-rouge">1</code> picks the group. The two bits that follow pick the bucket within the group.</p>

<p>Here’s what the first few groups look like — each bucket is fully described by just 3 bits:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WIDTH = 3:

range     bucket index        bucket size
[0, 1)     0  0b0 ..... 000    1
[1, 2)     1  0b0 ..... 001    1
[2, 3)     2  0b0 ..... 010    1
[3, 4)     3  0b0 ..... 011    1

[4, 5)     4  0b0 ..... 100    1
[5, 6)     5  0b0 ..... 101    1
[6, 7)     6  0b0 ..... 110    1
[7, 8)     7  0b0 ..... 111    1

[8, 10)    8  0b0 .... 1000    2
[10, 12)   9  0b0 .... 1010    2
[12, 14)  10  0b0 .... 1100    2
[14, 16)  11  0b0 .... 1110    2

[16, 20)  12  0b0 ... 10000    4
[20, 24)  13  0b0 ... 10100    4
[24, 28)  14  0b0 ... 11000    4
[28, 32)  15  0b0 ... 11100    4

[32, 40)  16  0b0 .. 100000    8
[40, 48)  17  0b0 .. 101000    8
[48, 56)  18  0b0 .. 110000    8
[56, 64)  19  0b0 .. 111000    8
</code></pre></div></div>

<p>The pattern is clean:</p>

<ul>
  <li>Each group contains <code class="language-plaintext highlighter-rouge">2^(WIDTH-1) = 4</code> buckets</li>
  <li>The 2 bits after the MSB select the bucket within the group</li>
  <li>It’s a 3-bit float: 1 implicit leading bit + 2 fractional bits</li>
</ul>

<p><img src="/post-res/histogram/077aac24f7b55056-006-bit-decomposition.png" alt="" /></p>

<p>Bucket sizes grow roughly logarithmically,
and computing the bucket index is just a matter of extracting the top WIDTH bits — a handful of integer and bit ops. Recording a sample is <strong>O(1)</strong>.</p>

<p>Walk-through with latency = 42:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>value = 42 (binary: 0b101010)
  MSB position: 5
  group: 5 - 2 = 3
  2 bits after MSB: 01 (from 1[01]010)
  offset in group: 1
  Bucket index: 4 + (3 × 4) + 1 = 17
</code></pre></div></div>

<p><img src="/post-res/histogram/126a7a9d50366663-007-bucket-layout.png" alt="" /></p>

<h3 id="tuning-width-the-precisionmemory-knob">Tuning WIDTH: The Precision–Memory Knob</h3>

<p>WIDTH sets how many buckets each group gets (<code class="language-plaintext highlighter-rouge">2^(WIDTH-1)</code>).
Groups top out at 64 (they still double in size, covering the full u64 range).</p>

<p>Here’s how the trade-off plays out:</p>

<table>
<tr class="header">
<th>WIDTH</th>
<th>Buckets</th>
<th>Mem/slot</th>
<th>Buckets per group</th>
</tr>
<tr class="odd">
<td>1</td>
<td>65</td>
<td>520 B</td>
<td>1</td>
</tr>
<tr class="even">
<td>2</td>
<td>128</td>
<td>1.0 KB</td>
<td>2</td>
</tr>
<tr class="odd">
<td>3</td>
<td>252</td>
<td>2.0 KB</td>
<td>4 (default)</td>
</tr>
<tr class="even">
<td>4</td>
<td>496</td>
<td>3.9 KB</td>
<td>8</td>
</tr>
<tr class="odd">
<td>5</td>
<td>976</td>
<td>7.6 KB</td>
<td>16</td>
</tr>
<tr class="even">
<td>6</td>
<td>1920</td>
<td>15.0 KB</td>
<td>32</td>
</tr>
</table>

<p>At the default WIDTH=3, one histogram costs 2 KB and records every sample in O(1).</p>

<p>That’s the write side sorted. Now for the read side.</p>

<h2 id="percentile-estimation-getting-answers-out">Percentile Estimation: Getting Answers Out</h2>

<p>Once we’ve collected the counts, we want percentiles: at what latency have 50% of requests finished (P50)? 90% (P90)? 99% (P99)?</p>

<h3 id="locating-the-right-bucket">Locating the Right Bucket</h3>

<p>The basic idea is simple.
For P50: count the total samples, take 50% to get a target rank <code class="language-plaintext highlighter-rouge">p</code>, then walk through the buckets from the start, accumulating counts until you pass <code class="language-plaintext highlighter-rouge">p</code>. That’s your bucket.</p>

<p>But a bucket spans a range, not a point. We still need to estimate where inside the bucket the percentile actually falls.</p>

<p>Here are a few ways to do that, from rough to precise.
All error numbers below come from a log-normal distribution (API latency scenario), WIDTH=3, 1,000,000 samples.</p>

<p><strong>Midpoint</strong>: just return <code class="language-plaintext highlighter-rouge">(min + max) / 2</code>.
Many histogram libraries do this (e.g., <a href="https://github.com/iopsystems/histogram">iopsystems/histogram</a>).
It’s a blind guess — it ignores everything about how samples are distributed within the bucket.</p>

<table>
<tr class="header">
<th></th>
<th>P50</th>
<th>P95</th>
<th>P99</th>
</tr>
<tr class="odd">
<td>midpoint</td>
<td>5.018%</td>
<td>7.732%</td>
<td>4.861%</td>
</tr>
</table>

<p><strong>Uniform interpolation</strong>: assume samples are spread evenly across the bucket (a flat rectangle), then interpolate linearly: <code class="language-plaintext highlighter-rouge">estimate = min + (max - min) × rank / count</code>.</p>

<p>Better than midpoint — at least it uses where in the bucket the target rank falls. But “evenly spread” is a rough assumption. Log-normal data is skewed even within a single bucket.</p>

<h3 id="trapezoid-interpolation-our-approach">Trapezoid Interpolation (Our Approach)</h3>

<p>Uniform interpolation treats the density inside a bucket as flat. In reality, it’s sloped — denser on the side closer to the peak of the distribution.</p>

<p>If we know which way the density tilts, we can swap the rectangle for a trapezoid and land much closer to the true value.</p>

<p><img src="/post-res/histogram/851f5482f43e8c4c-008-trapezoid.png" alt="" /></p>

<p>Each bucket stores only a count — we don’t want to add extra fields. So where does the slope information come from? The neighbors.</p>

<p><strong>The densities of the left and right buckets tell us how the density slopes through the current bucket.</strong></p>

<p>Here’s the recipe.
Compute the average density of the left bucket: <code class="language-plaintext highlighter-rouge">d0 = c0/(x1-x0)</code>, and treat it as the density at that bucket’s midpoint <code class="language-plaintext highlighter-rouge">m0</code>.
Do the same for the right bucket: <code class="language-plaintext highlighter-rouge">d2 = c2/(x3-x2)</code> at midpoint <code class="language-plaintext highlighter-rouge">m2</code>.
Assume density varies linearly from <code class="language-plaintext highlighter-rouge">m0</code> to <code class="language-plaintext highlighter-rouge">m2</code> — over this short range, that’s a reasonable approximation.
This gives us the slope <code class="language-plaintext highlighter-rouge">k</code>.</p>

<p>Inside the target bucket, the density now forms a trapezoid: a sloped line with slope <code class="language-plaintext highlighter-rouge">k</code>, pinned so that the density at the bucket’s midpoint <code class="language-plaintext highlighter-rouge">(x1+x2)/2</code> equals the bucket’s own average density <code class="language-plaintext highlighter-rouge">d1 = c1/(x2-x1)</code> (the midpoint of a linear function always equals its average).</p>

<p>To find the percentile, we solve for the x-position where the trapezoid’s area from <code class="language-plaintext highlighter-rouge">x1</code> equals the target rank.</p>

<p>Same distribution, same buckets — here’s how it stacks up:</p>

<table>
<tr class="header">
<th></th>
<th>P50</th>
<th>P95</th>
<th>P99</th>
</tr>
<tr class="odd">
<td>midpoint</td>
<td>5.018%</td>
<td>7.732%</td>
<td>4.861%</td>
</tr>
<tr class="even">
<td>trapezoid</td>
<td>0.000%</td>
<td>0.080%</td>
<td>0.086%</td>
</tr>
</table>

<p>Two orders of magnitude better, with zero additional storage.</p>

<p>The three-bucket layout:</p>

<p><img src="/post-res/histogram/bd85430d86e35843-009-slope-estimation.png" alt="" /></p>

<table>
<tr class="header">
<th>Variable</th>
<th>Meaning</th>
</tr>
<tr class="odd">
<td><code>x0, x1, x2, x3</code></td>
<td>Boundaries of the three adjacent buckets</td>
</tr>
<tr class="even">
<td><code>w0, w1, w2</code></td>
<td>Bucket widths: <code>w0 = x1-x0</code>, <code>w1 = x2-x1</code>, <code>w2 = x3-x2</code></td>
</tr>
<tr class="odd">
<td><code>c0, c1, c2</code></td>
<td>Sample counts in each bucket</td>
</tr>
<tr class="even">
<td><code>rank</code></td>
<td>How many samples into the target bucket the percentile falls</td>
</tr>
</table>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>d0 = c0 / w0       -- left bucket density
d1 = c1 / w1       -- target bucket density
d2 = c2 / w2       -- right bucket density
</code></pre></div></div>

<p>Midpoints of the left and right buckets: <code class="language-plaintext highlighter-rouge">m0 = (x0+x1)/2</code>, <code class="language-plaintext highlighter-rouge">m2 = (x2+x3)/2</code>.
Slope:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>k = (d2 - d0) / (m2 - m0)
</code></pre></div></div>

<p>Then solve for the x-position where the trapezoid’s cumulative area from <code class="language-plaintext highlighter-rouge">x1</code> equals the rank.</p>

<p>The whole thing runs on three counts and their bucket boundaries. Nothing else stored, nothing else needed.</p>

<h2 id="benchmarks-seven-distributions-six-width-settings">Benchmarks: Seven Distributions, Six WIDTH Settings</h2>

<p>We tested across 7 representative distributions with 1,000,000 samples each, using trapezoid interpolation.</p>

<p>The rows to watch are <strong>LN-API</strong> and <strong>LN-DB</strong> at <strong>W=3</strong> — these are the real-world latency cases, running on the default 2 KB configuration:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>|                   W=1      W=2      W=3      W=4      W=5      W=6
| ------------------------------------------------------------------
| Uniform P50    0.108%   0.028%   0.012%   0.018%   0.019%   0.002%
|         P95    2.317%   1.988%   1.035%   0.475%   0.005%   0.005%
|         P99    4.290%   4.129%   3.706%   1.486%   0.298%   0.162%
| 
| LN-API  P50    2.281%   0.182%   0.000%   0.000%   0.000%   0.000%
|         P95   20.256%   3.963%   0.080%   0.040%   0.040%   0.000%
|         P99   11.951%   3.594%   0.086%   0.000%   0.029%   0.000%
| 
| Bimodal P50    1.381%   0.394%   0.394%   0.197%   0.197%   0.197%
|         P95    3.918%   0.172%   0.012%   0.028%   0.038%   0.008%
|         P99    1.521%   1.344%   0.543%   0.078%   0.016%   0.014%
| 
| Expon   P50    1.012%   0.000%   0.145%   0.145%   0.145%   0.000%
|         P95   10.989%   0.200%   0.000%   0.000%   0.033%   0.033%
|         P99   18.665%   4.574%   0.824%   0.022%   0.022%   0.022%
| 
| LN-DB   P50    2.018%   0.034%   0.000%   0.000%   0.000%   0.034%
|         P95    2.027%   0.368%   0.039%   0.006%   0.019%   0.026%
|         P99    3.764%   1.066%   0.187%   0.007%   0.003%   0.062%
| 
| Sequent P50    0.095%   0.000%   0.000%   0.000%   0.000%   0.000%
|         P95    2.271%   1.967%   1.011%   0.496%   0.000%   0.000%
|         P99    4.272%   4.118%   3.696%   1.521%   0.305%   0.169%
| 
| Pareto  P50   10.127%   1.899%   0.633%   0.633%   0.633%   0.000%
|         P95    9.239%   0.272%   0.000%   0.136%   0.000%   0.000%
|         P99    3.517%   0.879%   0.231%   0.093%   0.046%   0.046%
| 
| ------------------------------------------------------------------
| Buckets            65      128      252      496      976     1920
| Mem/slot        520 B   1.0 KB   2.0 KB   3.9 KB   7.6 KB  15.0 KB
| Mem total      1.0 KB   2.0 KB   3.9 KB   7.8 KB  15.2 KB  30.0 KB
</code></pre></div></div>

<p>What each distribution models:</p>

<ul>
  <li><strong>Uniform (<a href="https://en.wikipedia.org/wiki/Continuous_uniform_distribution">uniform distribution</a>)</strong>: synthetic benchmarks</li>
  <li><strong>LN-API (<a href="https://en.wikipedia.org/wiki/Log-normal_distribution">log-normal</a> σ=0.5)</strong>: API and microservice latency</li>
  <li><strong>Bimodal (<a href="https://en.wikipedia.org/wiki/Multimodal_distribution">bimodal distribution</a>)</strong>: cache hit/miss — 90% fast path ~500μs, 10% slow path ~50ms</li>
  <li><strong>Expon (<a href="https://en.wikipedia.org/wiki/Exponential_distribution">exponential distribution</a>)</strong>: network and I/O waits</li>
  <li><strong>LN-DB (log-normal σ=1.0)</strong>: database query latency, with a wider tail</li>
  <li><strong>Sequent (sequential)</strong>: adversarial worst case</li>
  <li><strong>Pareto (<a href="https://en.wikipedia.org/wiki/Pareto_distribution">Pareto distribution</a> α=1.5)</strong>: heavy-tailed workloads like request sizes</li>
</ul>

<p>For the latency distributions we care about most — LN-API and LN-DB — WIDTH=3 delivers sub-0.2% error on 2 KB of memory.</p>

<h2 id="summary">Summary</h2>

<ul>
  <li><strong>2 KB memory</strong> (WIDTH=3, 252 buckets of u64), P50/P95/P99 error under 0.2% for log-normal latency</li>
  <li><strong>O(1) recording</strong>, O(buckets) querying</li>
  <li><strong>Trapezoid interpolation</strong> is what makes it work — over 10× more accurate than midpoint, with zero extra storage</li>
  <li><strong>WIDTH is tunable</strong>: 520 B for bare-minimum tracking, up to 15 KB for maximum precision</li>
  <li>Code: <a href="https://github.com/drmingdrmer/base2histogram">base2histogram</a></li>
</ul>

<hr />

<p>Reference:</p>

<ul>
  <li>
    <p>Databend : <a href="https://github.com/databendlabs/databend">https://github.com/databendlabs/databend</a></p>
  </li>
  <li>
    <p>MSB : <a href="https://en.wikipedia.org/wiki/Bit_numbering#Most_significant_bit">https://en.wikipedia.org/wiki/Bit_numbering#Most_significant_bit</a></p>
  </li>
  <li>
    <p>OpenRaft : <a href="https://github.com/databendlabs/openraft">https://github.com/databendlabs/openraft</a></p>
  </li>
  <li>
    <p>Pareto distribution : <a href="https://en.wikipedia.org/wiki/Pareto_distribution">https://en.wikipedia.org/wiki/Pareto_distribution</a></p>
  </li>
  <li>
    <p>base2histogram : <a href="https://github.com/drmingdrmer/base2histogram">https://github.com/drmingdrmer/base2histogram</a></p>
  </li>
  <li>
    <p>bimodal distribution : <a href="https://en.wikipedia.org/wiki/Multimodal_distribution">https://en.wikipedia.org/wiki/Multimodal_distribution</a></p>
  </li>
  <li>
    <p>exponential distribution : <a href="https://en.wikipedia.org/wiki/Exponential_distribution">https://en.wikipedia.org/wiki/Exponential_distribution</a></p>
  </li>
  <li>
    <p>floating-point number : <a href="https://en.wikipedia.org/wiki/Floating-point_arithmetic">https://en.wikipedia.org/wiki/Floating-point_arithmetic</a></p>
  </li>
  <li>
    <p>histogram : <a href="https://en.wikipedia.org/wiki/Histogram">https://en.wikipedia.org/wiki/Histogram</a></p>
  </li>
  <li>
    <p>iopsystems/histogram : <a href="https://github.com/iopsystems/histogram">https://github.com/iopsystems/histogram</a></p>
  </li>
  <li>
    <p>leading zero counting : <a href="https://en.wikipedia.org/wiki/Find_first_set#CLZ">https://en.wikipedia.org/wiki/Find_first_set#CLZ</a></p>
  </li>
  <li>
    <p>log scale : <a href="https://en.wikipedia.org/wiki/Logarithmic_scale">https://en.wikipedia.org/wiki/Logarithmic_scale</a></p>
  </li>
  <li>
    <p>log-normal distribution : <a href="https://en.wikipedia.org/wiki/Log-normal_distribution">https://en.wikipedia.org/wiki/Log-normal_distribution</a></p>
  </li>
  <li>
    <p>long tail : <a href="https://en.wikipedia.org/wiki/Long_tail">https://en.wikipedia.org/wiki/Long_tail</a></p>
  </li>
  <li>
    <p>normal distribution : <a href="https://en.wikipedia.org/wiki/Normal_distribution">https://en.wikipedia.org/wiki/Normal_distribution</a></p>
  </li>
  <li>
    <p>percentile : <a href="https://en.wikipedia.org/wiki/Percentile">https://en.wikipedia.org/wiki/Percentile</a></p>
  </li>
  <li>
    <p>uniform distribution : <a href="https://en.wikipedia.org/wiki/Continuous_uniform_distribution">https://en.wikipedia.org/wiki/Continuous_uniform_distribution</a></p>
  </li>
</ul>]]></content><author><name>Zhang Yanpo (drdr.xp)</name></author><category term="algo" /><category term="histogram" /><category term="latency" /><category term="percentile" /><category term="metrics" /><category term="performance" /><summary type="html"><![CDATA[A lightweight histogram that tracks latency distributions in 2KB of memory with sub-0.2% error. Uses a float-like encoding for O(1) bucket indexing and trapezoid interpolation for accurate percentile estimation — no floating-point math needed.]]></summary></entry><entry><title type="html">xp 的 AI 工作流</title><link href="https://blog.openacid.com/life/xp-vibe-coding/" rel="alternate" type="text/html" title="xp 的 AI 工作流" /><published>2026-01-04T00:00:00+00:00</published><updated>2026-01-04T00:00:00+00:00</updated><id>https://blog.openacid.com/life/xp-vibe-coding</id><content type="html" xml:base="https://blog.openacid.com/life/xp-vibe-coding/"><![CDATA[<p>目前我的日常开发基本只需要”动嘴”就能完成。这篇文章分享我在用的工具组合。</p>

<p><strong>偏见声明</strong>：我更希望把精力放在问题分析上，所以市面上流行的工具并没有一一尝试。只要满足需要就不会替换，除非发现明显的效率瓶颈。</p>

<hr />

<h2 id="工作流概览">工作流概览</h2>

<ol>
  <li><strong>分析与决策</strong> — 在 IDE 中浏览代码，理解项目，决定要做什么</li>
  <li><strong>描述需求</strong> — 用语音输入把想法描述出来</li>
  <li><strong>代码生成</strong> — 交给 AI 命令行工具生成代码</li>
  <li><strong>审核与提交</strong> — 用 tig 逐行 review，增量提交到 Git</li>
</ol>

<p>注意上面的几个步骤一般是穿插进行的, 一个这样的修改看做一个 session 的话, 一般我会同时做 2, 3 个工作流 session, 这是因为大模型输出代码比我 review 的速度慢一些, 我自己经常会有 IO 等待, 所以同时开 2,3 个 session 可以把我的 CPU 占满. 但是切换任务也会让自己脑袋里的 context 频繁切换, 降低效率, 所以一般我开 1 个需要我仔细思考的困难任务(例如增加新 feature), 和 1 或 2 个不太需要深度思考的简单任务(例如代码重构), 就不会产生大量颅内 context 切换. 而且忙起来似乎有助于多巴胺分泌.</p>

<p><img src="/post-res/xp-vibe-coding/ef3c8085ef56b7b8-workflow.webp" alt="workflow" /></p>

<hr />

<h2 id="第一步分析与决策--rust-rover">第一步：分析与决策 — Rust Rover</h2>

<p><a href="https://www.jetbrains.com/rust/">Rust Rover</a> 是 JetBrains 开发的 Rust IDE。JetBrains 的 IDE 系列（IntelliJ IDEA、PyCharm、WebStorm 等）在 AI 时代之前一直是开发者的首选，以代码跳转、重构、调试等功能著称。</p>

<p>虽然 Rust Rover 在 AI 功能上相对保守，但传统的代码跳转、全局搜索等功能依然扎实，足以快速建立对项目的整体认知。</p>

<p>在这个阶段，我主要根据要做的事情回顾代码结构，确定具体的实现思路；或者在没有具体任务时浏览项目，寻找需要改进的地方。</p>

<p><img src="/post-res/xp-vibe-coding/5499429f7c2ff0a1-rust-rover.webp" alt="Rust Rover" /></p>

<h2 id="第二步描述需求--语音输入">第二步：描述需求 — 语音输入</h2>

<p>口述比打字快，语音输入法是重要补充。</p>

<p>语音输入的错误可被大模型理解能力覆盖——只要大致表达清晰，就能完成任务。因此我对语音输入的准确率要求不高，即使口述转文字时有些错误也没关系。只要描述足够详细、提供足够的冗余信息，大模型就能准确理解需求。</p>

<p>以下是几种我尝试过、都能满足需要的语音输入法：</p>

<h3 id="闪电说"><a href="https://shandianshuo.cn/">闪电说</a></h3>
<p>（本地模型）</p>

<p><img src="/post-res/xp-vibe-coding/3676e2058bc85027-shandianshuo.webp" alt="闪电说" /></p>

<ul>
  <li>识别准确率中等</li>
  <li>离线可用，响应快</li>
</ul>

<h3 id="豆包桌面版"><a href="https://www.doubao.com/">豆包桌面版</a></h3>

<p><img src="/post-res/xp-vibe-coding/53677e8f2cbbc865-doubao.webp" alt="豆包" /></p>

<ul>
  <li>在线识别更精准</li>
  <li>网络延迟所以稍慢</li>
</ul>

<h3 id="智谱语音输入法"><a href="https://autoglm.zhipuai.cn/autotyper/">智谱语音输入法</a></h3>

<p><img src="/post-res/xp-vibe-coding/264208e2cb986c5f-zhipu.webp" alt="智谱语音输入法" /></p>

<ul>
  <li>AI 矫正能力强，准确率高</li>
  <li>网络延迟所以稍慢</li>
</ul>

<hr />

<h2 id="第三步代码生成--claude-code--glm-47">第三步：代码生成 — Claude Code + GLM 4.7</h2>

<p><a href="https://github.com/anthropics/claude-code">Claude Code</a> 是我的主力工具，CLI 框架成熟，即便不用内置模型，作为命令行交互载体也很高效。</p>

<p>日常开发中，我直接口述需求给 Claude Code，全程不需要键盘打字。</p>

<p>为了简化开发流程（也为了好玩），我买了一个只有三个键的小键盘：一个键触发语音输入，一个键是删除，一个键是回车。目前正在尝试只用这三个键完成日常开发。</p>

<p><img src="/post-res/xp-vibe-coding/ba4b118935fc9e66-3-key.webp" alt="三键小键盘" /></p>

<p>模型用的是 <a href="https://open.bigmodel.cn/">GLM 4.7</a>（<a href="https://www.zhipuai.cn/">智谱 AI</a> 发布）。需求描述清晰的话，完成”文字到代码”的转化没问题。与顶尖模型相比，处理抽象需求时有差距，但性价比高，国内网络环境下响应快。</p>

<p>就算说的乱 78 糟, 大模型也知道我说的 <code class="language-plaintext highlighter-rouge">IOarrow</code> 是 <code class="language-plaintext highlighter-rouge">io::Error</code>:
<img src="/post-res/xp-vibe-coding/4757c5dfa48f6dc4-claude.webp" alt="Claude Code" /></p>

<p>我已完全摒弃交互式辅助模式，改用”全量需求描述 + AI 独立实现”。</p>

<hr />

<h2 id="第四步审核与提交--tig">第四步：审核与提交 — tig</h2>

<p>我发现最高效的工作方式不是等大模型写完 review, 而是：在大模型生成代码的过程中就开始 review 产生的变更，逐段将变更加入版本控制。确认一个变更正确且符合要求后，立即加入 Git stage(git add)。这样后续修改时, review 工作区变化可以忽略已确认(git add)的部分，效率很高。</p>

<p>因此这个阶段最需要的工具，是能够高效的逐段将代码加入 Git 的交互式工具。<code class="language-plaintext highlighter-rouge">git add -p</code> 是最基本的选择，我使用的是 tig。</p>

<blockquote>
  <p><a href="https://github.com/jonas/tig">tig</a> 是基于 ncurses 的 Git 文本界面工具。</p>
</blockquote>

<h3 id="为什么用-tig-而非">为什么用 tig 而非</h3>
<p><code class="language-plaintext highlighter-rouge">git add -p</code></p>

<p><code class="language-plaintext highlighter-rouge">git add -p</code> 只能显示固定的 2-3 行上下文，有时不够理解修改(我的 context 太小 🤔)。</p>

<p>tig 通过 <code class="language-plaintext highlighter-rouge">tig status</code> 后进入交互界面, 可以：</p>

<ul>
  <li>逐行查看差异，像 Vim 一样导航(<code class="language-plaintext highlighter-rouge">j/k</code>)</li>
  <li>快捷键<code class="language-plaintext highlighter-rouge">[</code> 和 <code class="language-plaintext highlighter-rouge">]</code> 随时调整 diff 上下文大小</li>
  <li>逐行(<code class="language-plaintext highlighter-rouge">1</code>)或逐块(<code class="language-plaintext highlighter-rouge">u</code>)将修改加入 Git stage(stage 部分也叫 cached 或 index)</li>
  <li><code class="language-plaintext highlighter-rouge">R</code>(shift-r) 刷新页面, 显示最新修改</li>
</ul>

<p>一般我习惯在 tmux 里左右分两屏, 右边 pua 大模型干活, 左边逐行确认修改. 因为有时大模型修改的滚动太快了, 看不清它到底做了什么. <code class="language-plaintext highlighter-rouge">tig</code> 的逐行交互式 review 容许我异步查看每一行修改, 一旦看到有问题的修改, 就可以及时切到右边叫停努力的大模型, 重新调整:</p>

<p><img src="/post-res/xp-vibe-coding/05dc7b8862daff46-tig.webp" alt="tig" /></p>

<p>可以看到 tig 的上下文可以用<code class="language-plaintext highlighter-rouge">[</code>和<code class="language-plaintext highlighter-rouge">]</code>任意宽度展开</p>

<p>Vim 的 Git fugitive 插件也可以提供类似功能但需要先启动 Vim，而且操作稍繁琐。</p>

<h3 id="增量-review">增量 Review</h3>

<p>确认 OK 的部分立即加入 Git，后续再次让大模型做出修改后只 review not-staged 部分, 不再重复审查上一步已经加入 stage, 确认 OK 的代码。AI 增量修改时只需关注变化的部分。</p>

<hr />

<h2 id="vibe-coding-的核心理念">Vibe Coding 的核心理念</h2>

<p>AI 能极大提升效率——前提是: 你了解这个领域。否则无法判断对错，错误会吃掉所有效率提升。用 AI 开发的前提是我比 AI 更了解这个项目。</p>

<hr />

<h2 id="工具清单">工具清单</h2>

<table>
<tr class="header">
<th>工具</th>
<th>用途</th>
</tr>
<tr class="odd">
<td><a href="https://www.jetbrains.com/rust/">Rust Rover</a></td>
<td>代码分析</td>
</tr>
<tr class="even">
<td><a href="https://shandianshuo.cn/">闪电说</a> / <a href="https://www.doubao.com/">豆包</a> / <a href="https://autoglm.zhipuai.cn/autotyper/">智谱语音输入法</a></td>
<td>语音输入</td>
</tr>
<tr class="odd">
<td><a href="https://github.com/anthropics/claude-code">Claude Code</a></td>
<td>AI 编程</td>
</tr>
<tr class="even">
<td><a href="https://open.bigmodel.cn/">GLM 4.7</a></td>
<td>后端模型</td>
</tr>
<tr class="odd">
<td><a href="https://github.com/jonas/tig">tig</a></td>
<td>代码审核，增量提交</td>
</tr>
</table>

<hr />

<p><em>这篇文章是口述完成的。</em></p>

<!-- 链接定义 -->

<p>Reference:</p>

<ul>
  <li>
    <p>Claude Code : <a href="https://github.com/anthropics/claude-code">https://github.com/anthropics/claude-code</a></p>
  </li>
  <li>
    <p>GLM 4.7 : <a href="https://open.bigmodel.cn/">https://open.bigmodel.cn/</a></p>
  </li>
  <li>
    <p>Rust Rover : <a href="https://www.jetbrains.com/rust/">https://www.jetbrains.com/rust/</a></p>
  </li>
  <li>
    <p>tig : <a href="https://github.com/jonas/tig">https://github.com/jonas/tig</a></p>
  </li>
  <li>
    <p>智谱 AI : <a href="https://www.zhipuai.cn/">https://www.zhipuai.cn/</a></p>
  </li>
  <li>
    <p>智谱语音输入法 : <a href="https://autoglm.zhipuai.cn/autotyper/">https://autoglm.zhipuai.cn/autotyper/</a></p>
  </li>
  <li>
    <p>豆包 : <a href="https://www.doubao.com/">https://www.doubao.com/</a></p>
  </li>
  <li>
    <p>闪电说 : <a href="https://shandianshuo.cn/">https://shandianshuo.cn/</a></p>
  </li>
</ul>]]></content><author><name>Zhang Yanpo (drdr.xp)</name></author><category term="life" /><category term="vide-coding" /><category term="ai" /><category term="voice-input" /><summary type="html"><![CDATA[xp 的 AI 开发工作流]]></summary></entry><entry><title type="html">Raft Node Rejoin Bug</title><link href="https://blog.openacid.com/algo/raft-rejoin-bug/" rel="alternate" type="text/html" title="Raft Node Rejoin Bug" /><published>2025-11-20T00:00:00+00:00</published><updated>2025-11-20T00:00:00+00:00</updated><id>https://blog.openacid.com/algo/raft-rejoin-bug</id><content type="html" xml:base="https://blog.openacid.com/algo/raft-rejoin-bug/"><![CDATA[<p><img src="/post-res/raft-rejoin-bug/1d34a13b1c0631e8-raft-rejoin-bug-banner.webp" alt="" /></p>

<p>In Raft cluster operations, there’s an easily overlooked bug: when a node is removed and then re-added to the cluster within the same term, delayed AppendEntries responses from the old membership configuration can corrupt the leader’s replication progress tracking for that node, causing the leader to enter an infinite retry loop.</p>

<p>The root cause is the lack of a <strong>replication session isolation mechanism</strong>. When the same node joins the cluster at different times, these should be treated as different replication sessions. However, without explicit session identifiers, the leader cannot distinguish which session a response belongs to. The result is that delayed responses from old sessions incorrectly update the progress records of new sessions.</p>

<p>While this creates operational challenges—continuous resource consumption and nodes unable to catch up with the cluster—the good news is that Raft’s commit protocol ensures data safety remains intact.</p>

<p>Analyzed Raft libs:</p>

<table>
<tr class="header">
<th>Implementation</th>
<th style="text-align: right;">Stars</th>
<th>Language</th>
<th>Status</th>
<th>Analysis</th>
</tr>
<tr class="odd">
<td>Apache Ratis</td>
<td style="text-align: right;">1,418</td>
<td>Java</td>
<td>✓ PROTECTED</td>
<td><a href="analysis/apache-ratis.md">Report</a></td>
</tr>
<tr class="even">
<td>NuRaft</td>
<td style="text-align: right;">1,140</td>
<td>C++</td>
<td>✓ PROTECTED</td>
<td><a href="analysis/nuraft.md">Report</a></td>
</tr>
<tr class="odd">
<td>OpenRaft</td>
<td style="text-align: right;">1,700</td>
<td>Rust</td>
<td>✓ PROTECTED</td>
<td><a href="analysis/openraft.md">Report</a></td>
</tr>
<tr class="even">
<td>RabbitMQ Ra</td>
<td style="text-align: right;">908</td>
<td>Erlang</td>
<td>✓ PROTECTED</td>
<td><a href="analysis/rabbitmq-ra.md">Report</a></td>
</tr>
<tr class="odd">
<td>braft</td>
<td style="text-align: right;">4,174</td>
<td>C++</td>
<td>✓ PROTECTED</td>
<td><a href="analysis/braft.md">Report</a></td>
</tr>
<tr class="even">
<td>canonical/raft</td>
<td style="text-align: right;">954</td>
<td>C</td>
<td>✓ PROTECTED</td>
<td><a href="analysis/canonical-raft.md">Report</a></td>
</tr>
<tr class="odd">
<td>sofa-jraft</td>
<td style="text-align: right;">3,762</td>
<td>Java</td>
<td>✓ PROTECTED</td>
<td><a href="analysis/sofa-jraft-analysis.md">Report</a></td>
</tr>
<tr class="even">
<td><strong>LogCabin</strong></td>
<td style="text-align: right;"><strong>1,945</strong></td>
<td><strong>C++</strong></td>
<td><strong>✗ VULNERABLE</strong></td>
<td><a href="analysis/logcabin.md">Report</a></td>
</tr>
<tr class="odd">
<td><strong>PySyncObj</strong></td>
<td style="text-align: right;"><strong>738</strong></td>
<td><strong>Python</strong></td>
<td><strong>✗ VULNERABLE</strong></td>
<td><a href="analysis/pysyncobj.md">Report</a></td>
</tr>
<tr class="even">
<td><strong>dragonboat</strong></td>
<td style="text-align: right;"><strong>5,262</strong></td>
<td><strong>Go</strong></td>
<td><strong>✗ VULNERABLE</strong></td>
<td><a href="analysis/dragonboat.md">Report</a></td>
</tr>
<tr class="odd">
<td><strong>etcd-io/raft</strong></td>
<td style="text-align: right;"><strong>943</strong></td>
<td><strong>Go</strong></td>
<td><strong>✗ VULNERABLE</strong></td>
<td><a href="analysis/etcd-raft.md">Report</a></td>
</tr>
<tr class="even">
<td><strong>hashicorp/raft</strong></td>
<td style="text-align: right;"><strong>8,826</strong></td>
<td><strong>Go</strong></td>
<td><strong>✗ VULNERABLE</strong></td>
<td><a href="analysis/hashicorp-raft-analysis.md">Report</a></td>
</tr>
<tr class="odd">
<td><strong>raft-java</strong></td>
<td style="text-align: right;"><strong>1,234</strong></td>
<td><strong>Java</strong></td>
<td><strong>✗ VULNERABLE</strong></td>
<td><a href="analysis/raft-java.md">Report</a></td>
</tr>
<tr class="even">
<td><strong>raft-rs (TiKV)</strong></td>
<td style="text-align: right;"><strong>3,224</strong></td>
<td><strong>Rust</strong></td>
<td><strong>✗ VULNERABLE</strong></td>
<td><a href="analysis/raft-rs.md">Report</a></td>
</tr>
<tr class="odd">
<td><strong>redisraft</strong></td>
<td style="text-align: right;"><strong>841</strong></td>
<td><strong>C</strong></td>
<td><strong>✗ VULNERABLE</strong></td>
<td><a href="analysis/redisraft.md">Report</a></td>
</tr>
<tr class="even">
<td><strong>willemt/raft</strong></td>
<td style="text-align: right;"><strong>1,160</strong></td>
<td><strong>C</strong></td>
<td><strong>✗ VULNERABLE</strong></td>
<td><a href="analysis/willemt-raft.md">Report</a></td>
</tr>
<tr class="odd">
<td>eliben/raft</td>
<td style="text-align: right;">1,232</td>
<td>Go</td>
<td>N/A</td>
<td><a href="analysis/eliben-raft.md">Report</a></td>
</tr>
</table>

<p>This article uses raft-rs, the Raft implementation used by TiKV, as a case study to analyze this bug’s trigger conditions, impact, and potential solutions.</p>

<p>Complete analysis and survey of other Raft implementations can be found in the <a href="https://github.com/drmingdrmer/raft-rejoin-bug">Raft Rejoin Bug Survey</a></p>

<h2 id="raft-log-replication-basics">Raft Log Replication Basics</h2>

<p>In Raft, the leader replicates log entries to followers through AppendEntries RPC calls, while maintaining a replication state machine for each follower to track replication progress.</p>

<h3 id="appendentries-request-response-flow">AppendEntries Request-Response Flow</h3>

<p>Here’s how it works: The leader sends AppendEntries requests with the current <code class="language-plaintext highlighter-rouge">term</code>, the <code class="language-plaintext highlighter-rouge">prev_log_index</code> and <code class="language-plaintext highlighter-rouge">prev_log_term</code> pointing to the position just before the new entries, the <code class="language-plaintext highlighter-rouge">entries[]</code> array to replicate, and the leader’s <code class="language-plaintext highlighter-rouge">leader_commit</code> index. The follower responds with its own <code class="language-plaintext highlighter-rouge">term</code>, the highest log <code class="language-plaintext highlighter-rouge">index</code> it replicated, and whether the operation succeeded.</p>

<h3 id="progress-tracking">Progress Tracking</h3>

<p>The leader relies on these responses to track each follower’s replication status. It uses <code class="language-plaintext highlighter-rouge">matched</code> to record the highest log index confirmed to be replicated on that follower, and <code class="language-plaintext highlighter-rouge">next_idx</code> to mark where to send next. When a successful response comes back with <code class="language-plaintext highlighter-rouge">index=N</code>, the leader updates <code class="language-plaintext highlighter-rouge">matched=N</code> and calculates <code class="language-plaintext highlighter-rouge">next_idx=N+1</code> for the next round.</p>

<p>This tracking mechanism has an implicit assumption: responses correspond to the current replication session.</p>

<p>If this assumption isn’t handled properly, when a node rejoins the cluster, the leader can get stuck in an infinite retry loop. It keeps sending AppendEntries requests, the node keeps rejecting them, and the cycle repeats endlessly while that node never manages to catch up with the cluster.</p>

<h2 id="raft-rs-progress-tracking">raft-rs Progress Tracking</h2>

<p>raft-rs tracks replication progress using a Progress structure for each follower node:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// From raft-rs/src/tracker/progress.rs</span>
<span class="k">pub</span> <span class="k">struct</span> <span class="n">Progress</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="n">matched</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>      <span class="c1">// Highest log index known to be replicated</span>
    <span class="k">pub</span> <span class="n">next_idx</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>     <span class="c1">// Next log index to send</span>
    <span class="k">pub</span> <span class="n">state</span><span class="p">:</span> <span class="n">ProgressState</span><span class="p">,</span>
    <span class="c1">// ... other fields</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">matched</code> field records the highest log index successfully replicated to this follower. Whenever the leader receives a successful AppendEntries response, it updates this field:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// From raft-rs/src/tracker/progress.rs</span>
<span class="k">pub</span> <span class="k">fn</span> <span class="nf">maybe_update</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">n</span><span class="p">:</span> <span class="nb">u64</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">bool</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">need_update</span> <span class="o">=</span> <span class="k">self</span><span class="py">.matched</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span>  <span class="c1">// Only check monotonicity</span>
    <span class="k">if</span> <span class="n">need_update</span> <span class="p">{</span>
        <span class="k">self</span><span class="py">.matched</span> <span class="o">=</span> <span class="n">n</span><span class="p">;</span>  <span class="c1">// Accept the update!</span>
        <span class="k">self</span><span class="nf">.resume</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="n">need_update</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Notice the update logic is quite simple: as long as the new index is higher than the current <code class="language-plaintext highlighter-rouge">matched</code>, it accepts the update. When a node gets removed from the cluster, its Progress record is deleted. When it rejoins, a brand new Progress record is created with <code class="language-plaintext highlighter-rouge">matched = 0</code>.</p>

<h2 id="bug-reproduction-sequence">Bug Reproduction Sequence</h2>

<p>Let’s walk through a concrete timeline to see how this bug unfolds. Pay special attention to the fact that all events happen within a single term (term=5)—this is key to understanding why term-based validation fails.</p>

<h3 id="event-timeline">Event Timeline</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>| Time | Event                                         | Progress State
|------|-----------------------------------------------|----------------
| T1   | log=1, members={a,b,c}                        | C: matched=0
|      | Leader sends AppendEntries(index=1) to C      |
|      | (Network delay causes slow delivery)          |
|      |                                               |
| T2   | log=5, members={a,b}                          | C: [deleted]
|      | Node C removed from cluster                   |
|      | Progress[C] deleted from leader's tracker     |
|      |                                               |
| T3   | log=100, members={a,b,c}                      | C: matched=0 (new)
|      | Node C rejoins the cluster                    |
|      | New Progress[C] created with matched=0        |
|      |                                               |
| T4   | Delayed response arrives from T1:             |
|      | {from: C, index: 1, success: true}            |
|      | Leader finds Progress[C] (the new one!)       |
|      | maybe_update(1) called: 0 &lt; 1, so update!     | C: matched=1 ❌
|      |                                               |
| T5   | Leader calculates next_idx = matched + 1 = 2  |
|      | Sends AppendEntries(prev_index=1)             |
|      | Node C rejects (doesn't have index 1!)        |
|      | Leader can't decrement (matched == rejected)  |
|      | Infinite loop begins...                       |
</code></pre></div></div>

<h3 id="response-handling-at-t4">Response Handling at T4</h3>

<p>At time T4, that response sent at T1 and delayed in the network finally arrives. Here’s how the leader handles it:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// From raft-rs/src/raft.rs</span>
<span class="k">fn</span> <span class="nf">handle_append_response</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">m</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">Message</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Find the progress record</span>
    <span class="k">let</span> <span class="n">pr</span> <span class="o">=</span> <span class="k">match</span> <span class="k">self</span><span class="py">.prs</span><span class="nf">.get_mut</span><span class="p">(</span><span class="n">m</span><span class="py">.from</span><span class="p">)</span> <span class="p">{</span>
        <span class="nf">Some</span><span class="p">(</span><span class="n">pr</span><span class="p">)</span> <span class="k">=&gt;</span> <span class="n">pr</span><span class="p">,</span>
        <span class="nb">None</span> <span class="k">=&gt;</span> <span class="p">{</span>
            <span class="nd">debug!</span><span class="p">(</span><span class="k">self</span><span class="py">.logger</span><span class="p">,</span> <span class="s">"no progress available for {}"</span><span class="p">,</span> <span class="n">m</span><span class="py">.from</span><span class="p">);</span>
            <span class="k">return</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">};</span>

    <span class="c1">// Update progress if the index is higher</span>
    <span class="k">if</span> <span class="o">!</span><span class="n">pr</span><span class="nf">.maybe_update</span><span class="p">(</span><span class="n">m</span><span class="py">.index</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Here’s where things go wrong: The leader does find a Progress record for node C, but it’s the new one created at T3. Since the message’s term matches the current term, it passes the term check in the <a href="https://github.com/tikv/raft-rs/blob/master/src/raft.rs#L1346-L1478"><code class="language-plaintext highlighter-rouge">step()</code> function</a>, and the leader updates progress with this stale index value.</p>

<h2 id="root-cause-analysis">Root Cause Analysis</h2>

<p>The root of this bug is that <strong>request-response messages lack replication session identification</strong>. When node C gets removed at T2 and rejoins at T3, these should be two distinct replication sessions—but the leader has no way to distinguish between responses from requests sent at T1 versus responses from requests sent after T3.</p>

<p>Look at raft-rs’s Message structure:</p>

<p>File: <a href="https://github.com/tikv/raft-rs/blob/master/proto/proto/eraftpb.proto#L71-L98"><code class="language-plaintext highlighter-rouge">proto/proto/eraftpb.proto:71-98</code></a></p>

<div class="language-protobuf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">message</span> <span class="nc">Message</span> <span class="p">{</span>
    <span class="n">MessageType</span> <span class="na">msg_type</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
    <span class="kt">uint64</span> <span class="k">to</span> <span class="o">=</span> <span class="mi">2</span><span class="p">;</span>
    <span class="kt">uint64</span> <span class="na">from</span> <span class="o">=</span> <span class="mi">3</span><span class="p">;</span>
    <span class="kt">uint64</span> <span class="na">term</span> <span class="o">=</span> <span class="mi">4</span><span class="p">;</span>        <span class="c1">// Only term, no session identifier!</span>
    <span class="kt">uint64</span> <span class="na">log_term</span> <span class="o">=</span> <span class="mi">5</span><span class="p">;</span>
    <span class="kt">uint64</span> <span class="na">index</span> <span class="o">=</span> <span class="mi">6</span><span class="p">;</span>
    <span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The Message only has a <code class="language-plaintext highlighter-rouge">from</code> field identifying the sending node, but the same node ID joining the cluster at different times should be treated as different replication sessions. The leader needs to distinguish: is this response from node C’s first session or its second session? But the current Message structure provides no way to tell.</p>

<h2 id="impact-analysis">Impact Analysis</h2>

<h3 id="infinite-retry-loop">Infinite Retry Loop</h3>

<p>Once the leader incorrectly sets <code class="language-plaintext highlighter-rouge">matched=1</code>, trouble begins. Here’s what happens:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// From raft-rs/src/tracker/progress.rs</span>
<span class="k">pub</span> <span class="k">fn</span> <span class="nf">maybe_decr_to</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">rejected</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span> <span class="n">match_hint</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span> <span class="o">...</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">bool</span> <span class="p">{</span>
    <span class="k">if</span> <span class="k">self</span><span class="py">.state</span> <span class="o">==</span> <span class="nn">ProgressState</span><span class="p">::</span><span class="n">Replicate</span> <span class="p">{</span>
        <span class="c1">// Can't decrement if rejected &lt;= matched</span>
        <span class="k">if</span> <span class="n">rejected</span> <span class="o">&lt;</span> <span class="k">self</span><span class="py">.matched</span>
            <span class="p">||</span> <span class="p">(</span><span class="n">rejected</span> <span class="o">==</span> <span class="k">self</span><span class="py">.matched</span> <span class="o">&amp;&amp;</span> <span class="n">request_snapshot</span> <span class="o">==</span> <span class="n">INVALID_INDEX</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="k">false</span><span class="p">;</span>  <span class="c1">// Ignore the rejection!</span>
        <span class="p">}</span>
        <span class="c1">// ...</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The leader sends AppendEntries with <code class="language-plaintext highlighter-rouge">prev_log_index=1</code>, but node C’s log is empty—it doesn’t have index 1. Node C rejects the request. The leader wants to decrement <code class="language-plaintext highlighter-rouge">next_idx</code> to retry an earlier position, but here’s the problem: because <code class="language-plaintext highlighter-rouge">rejected (1) == matched (1)</code>, the decrement logic returns false and refuses to decrement. So the leader just sends the same request again, node C rejects it again, and this cycle continues forever.</p>

<h3 id="operational-impact">Operational Impact</h3>

<p>This bug creates a series of operational problems. First, there’s resource exhaustion: the continuous AppendEntries-rejection cycle keeps consuming CPU and network bandwidth.</p>

<h2 id="why-data-remains-safe">Why Data Remains Safe</h2>

<p>Despite all the operational chaos, there’s good news: data integrity remains intact. Raft’s safety properties ensure that even with corrupted progress tracking, the cluster won’t lose any committed data.</p>

<p>The reason is that commit index calculation still works correctly. Even if the leader mistakenly thinks node C has <code class="language-plaintext highlighter-rouge">matched=1</code>, it calculates the commit index based on the actual majority. For example, node A has matched=100, node B has matched=100, and node C has matched=1 (which is wrong, but doesn’t matter). The majority looks at A and B with matched=100, so the commit index is correctly calculated as 100. Combined with Raft’s overlapping majorities property, any newly elected leader will necessarily have all committed entries, keeping data safe.</p>

<h2 id="solutions">Solutions</h2>

<h3 id="solution-1-add-membership-version-recommended">Solution 1: Add Membership Version (Recommended)</h3>

<p>The most straightforward fix is to add a membership configuration version to messages:</p>

<div class="language-protobuf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">message</span> <span class="nc">Message</span> <span class="p">{</span>
    <span class="c1">// ... existing fields</span>
    <span class="kt">uint64</span> <span class="na">membership_log_id</span> <span class="o">=</span> <span class="mi">17</span><span class="p">;</span>  <span class="c1">// New field</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Then validate it when processing responses:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">handle_append_response</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">m</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">Message</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">pr</span> <span class="o">=</span> <span class="k">self</span><span class="py">.prs</span><span class="nf">.get_mut</span><span class="p">(</span><span class="n">m</span><span class="py">.from</span><span class="p">)</span><span class="o">?</span><span class="p">;</span>

    <span class="c1">// Check membership version</span>
    <span class="k">if</span> <span class="n">m</span><span class="py">.membership_log_id</span> <span class="o">!=</span> <span class="k">self</span><span class="py">.current_membership_log_id</span> <span class="p">{</span>
        <span class="nd">debug!</span><span class="p">(</span><span class="s">"stale message from different membership"</span><span class="p">);</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="n">pr</span><span class="nf">.maybe_update</span><span class="p">(</span><span class="n">m</span><span class="py">.index</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This directly fixes the root cause—the leader can now tell which membership configuration a message comes from.</p>

<h3 id="solution-2-generation-counters">Solution 2: Generation Counters</h3>

<p>Another approach is to add a generation counter to Progress that increments each time a node rejoins:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">struct</span> <span class="n">Progress</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="n">matched</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>
    <span class="k">pub</span> <span class="n">next_idx</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>
    <span class="k">pub</span> <span class="n">generation</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>  <span class="c1">// Incremented on each rejoin</span>
    <span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Include the generation in messages and validate it when responses arrive. This is lighter weight than solution 1, but you need to carefully manage the generation lifecycle.</p>

<h2 id="summary">Summary</h2>

<p>This bug shows us that when membership changes happen within the same term, relying on term-based validation alone isn’t enough to ensure message freshness. Without explicit session isolation, delayed responses from old membership configurations can corrupt progress tracking.</p>

<p>Fortunately, because Raft’s commit index calculation and overlapping quorum mechanisms provide strong guarantees, this bug doesn’t compromise data safety. The main impact is operational—the symptoms look like data corruption, which can send operations teams down the rabbit hole investigating a data loss problem that doesn’t actually exist.</p>

<p>For production Raft implementations, it’s recommended to introduce explicit session management mechanisms. This can be achieved through membership versioning or generation counters. The most recommended approach is to add a membership_log_id field to messages, which lets the leader clearly distinguish which membership configuration a response comes from.</p>

<p>Complete analysis and survey of other Raft implementations can be found in the <a href="https://github.com/drmingdrmer/raft-rejoin-bug">Raft Rejoin Bug Survey</a></p>

<p>Reference:</p>]]></content><author><name>Zhang Yanpo (drdr.xp)</name></author><category term="algo" /><category term="distributed" /><category term="分布式" /><category term="raft" /><category term="en" /><summary type="html"><![CDATA[Analyzes a replication session isolation bug in Raft implementations. When a node rejoins the cluster within the same term, delayed AppendEntries responses can corrupt progress tracking, causing infinite retry loops. While data safety remains intact, it creates operational issues like resource exhaustion. Uses raft-rs as a case study to examine trigger conditions and solutions.]]></summary></entry><entry><title type="html">Raft 中的 IO 执行顺序：内存状态与持久化状态的陷阱</title><link href="https://blog.openacid.com/algo/raft-io-order-complete-cn/" rel="alternate" type="text/html" title="Raft 中的 IO 执行顺序：内存状态与持久化状态的陷阱" /><published>2025-10-09T00:00:00+00:00</published><updated>2025-10-09T00:00:00+00:00</updated><id>https://blog.openacid.com/algo/raft-io-order-complete-cn</id><content type="html" xml:base="https://blog.openacid.com/algo/raft-io-order-complete-cn/"><![CDATA[<p><img src="/post-res/raft-io-order-complete-cn/62b7bb390d222f2e-raft-io-order-fix-banner.webp" alt="" /></p>

<h2 id="前言">前言</h2>

<p>在 Raft 实现中，处理 appendEntries 请求时需要持久化两类数据：term 和 log entries。Raft 论文要求”在响应 RPC 之前必须更新持久化状态”，但并未明确说明这两类数据的持久化顺序。这个看似无关紧要的细节，却可能导致已提交数据的丢失。</p>

<p>问题的根源在于：Raft 论文描述的是一个简单的抽象模型（只有磁盘状态），而实际实现为了性能会分离内存状态和持久化状态。这种状态分离引入了论文中未定义的行为，当 IO 操作允许重排序时，就可能破坏 Raft 的安全性保证。</p>

<p>本文将深入分析这个问题是如何产生的，以及主流实现（TiKV、HashiCorp Raft、SOFAJRaft）如何避免这个陷阱。</p>

<h2 id="内存状态与持久化状态的陷阱">内存状态与持久化状态的陷阱</h2>

<p>在实际的 Raft 实现中，为了提升性能，通常会分离内存状态(<code class="language-plaintext highlighter-rouge">current_term</code>)和磁盘状态(<code class="language-plaintext highlighter-rouge">persisted_term</code>)。处理 appendEntries 请求的典型流程是：</p>

<ol>
  <li>收到 appendEntries，如果 <code class="language-plaintext highlighter-rouge">req.term &gt; current_term</code>，立即更新 <code class="language-plaintext highlighter-rouge">current_term</code></li>
  <li>异步提交 save-term IO</li>
  <li>IO 完成后更新 <code class="language-plaintext highlighter-rouge">persisted_term</code>（有些实现中可能没有显式的 <code class="language-plaintext highlighter-rouge">persisted_term</code>）</li>
</ol>

<p>这种状态分离引入了 Raft 论文中没有定义的行为（Raft 论文只关注磁盘状态）：</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">RaftState</span> <span class="p">{</span>
    <span class="c1">// In-memory term, updated immediately when receiving higher term</span>
    <span class="n">current_term</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>

    <span class="c1">// Persisted term on disk, updated only after IO completes</span>
    <span class="n">persisted_term</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>上面描述的流程是常见的 Raft 实现的流程, 在没有 IO-reorder 时, 它是正确的。但当 IO 操作可以重排序时，就会出现严重的安全问题。</p>

<h2 id="问题场景">问题场景</h2>

<p>用一个具体的时间线来展示 IO-reorder 如何导致数据丢失：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Legend:
Ni:   Node i
Vi:   RequestVote, term=i
Li:   Establish Leader, term=i
Ei-j: Log entry, term=i, index=j

N5 |          V5  L5     E5-1     E5-2
N4 |          V5         E5-1     E5-2
N3 |  V1              V5,E5-1  V5,E5-2  E1-1
N2 |  V1      V5                        E1-1
N1 |  V1  L1                            E1-1
------+---+---+---+------+--------+-----+------&gt; time
      t1  t2  t3  t4     t5       t6    t7
</code></pre></div></div>

<ul>
  <li>t1-t4: 两次选举，N1（term=1）和 N5（term=5）先后成为 leader</li>
  <li><strong>t5</strong>: L5 复制 E5-1 到 N3（N3 的 <code class="language-plaintext highlighter-rouge">current_term=1 &lt; req.term=5</code>）
    <ul>
      <li>N3 需要执行两个 IO：持久化 term=5 和 E5-1</li>
      <li>等待两个 IO 完成才返回成功</li>
    </ul>
  </li>
  <li><strong>t6</strong>: L5 复制 E5-2 到 N3（关键时刻）
    <ul>
      <li>N3 可能还在处理 t5 的 IO</li>
      <li>这时是否存在 IO-reorder 至关重要</li>
    </ul>
  </li>
  <li>t7: L1 尝试复制 E1-1（term=1, index=1）</li>
</ul>

<p><strong>关键在于 t6 时刻的第二个 AppendEntries 请求</strong>。让我们看看 N3 的内部状态变化。</p>

<h3 id="t5-时刻第一个-appendentries">t5 时刻：第一个 AppendEntries</h3>

<p>N3 收到 <code class="language-plaintext highlighter-rouge">appendEntries(term=5, entries=[E5-1])</code>：</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">handle_append_entries</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">req</span><span class="p">:</span> <span class="n">AppendEntries</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Check: RPC term &gt; in-memory term?</span>
    <span class="k">if</span> <span class="n">req</span><span class="py">.term</span> <span class="o">&gt;</span> <span class="k">self</span><span class="py">.current_term</span> <span class="p">{</span>
        <span class="k">self</span><span class="py">.current_term</span> <span class="o">=</span> <span class="n">req</span><span class="py">.term</span><span class="p">;</span>           <span class="c1">// Update memory immediately: 5</span>
        <span class="k">self</span><span class="nf">.submit_io</span><span class="p">(</span><span class="nf">save_term</span><span class="p">(</span><span class="n">req</span><span class="py">.term</span><span class="p">));</span>    <span class="c1">// Submit IO request</span>
    <span class="p">}</span>

    <span class="k">self</span><span class="nf">.submit_io</span><span class="p">(</span><span class="nf">save_entries</span><span class="p">(</span><span class="n">req</span><span class="py">.entries</span><span class="p">));</span>  <span class="c1">// Submit IO request</span>

    <span class="c1">// Wait for both IOs to complete</span>
    <span class="nf">wait_for_both_ios</span><span class="p">();</span>
    <span class="k">return</span> <span class="nf">success</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>N3 的状态：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">current_term = 5</code>（内存已更新）</li>
  <li><code class="language-plaintext highlighter-rouge">persisted_term = 1</code>（磁盘还未更新，IO 进行中）</li>
  <li>IO 队列：<code class="language-plaintext highlighter-rouge">save_term(5)</code>, <code class="language-plaintext highlighter-rouge">save_entries(E5-1)</code></li>
</ul>

<p>这个请求本身是正确的，问题出现在下一个时刻。</p>

<h3 id="t6-时刻第二个-appendentries">t6 时刻：第二个 AppendEntries</h3>

<p>N3 还没完成 t5 的 IO，就收到了 <code class="language-plaintext highlighter-rouge">appendEntries(term=5, entries=[E5-2])</code>。</p>

<p>如果代码只检查内存 <code class="language-plaintext highlighter-rouge">current_term</code>（大多数实现的做法）, 并提交 save-entries IO：</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">handle_append_entries</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">req</span><span class="p">:</span> <span class="n">AppendEntries</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Check: 5 &gt; 5? No</span>
    <span class="k">if</span> <span class="n">req</span><span class="py">.term</span> <span class="o">&gt;</span> <span class="k">self</span><span class="py">.current_term</span> <span class="p">{</span>
        <span class="c1">// Won't enter this branch</span>
    <span class="p">}</span>

    <span class="c1">// Only submit save_entries(E5-2)</span>
    <span class="k">self</span><span class="nf">.submit_io</span><span class="p">(</span><span class="nf">save_entries</span><span class="p">(</span><span class="n">req</span><span class="py">.entries</span><span class="p">));</span>

    <span class="c1">// Only wait for save_entries to complete</span>
    <span class="nf">wait_for_io</span><span class="p">(</span><span class="n">save_entries</span><span class="p">);</span>
    <span class="k">return</span> <span class="nf">success</span><span class="p">();</span>  <span class="c1">// Return success!</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>问题出现</strong>：在允许 IO-reorder 的时候,</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">save_entries(E5-2)</code> 完成</li>
  <li>但 <code class="language-plaintext highlighter-rouge">save_term(5)</code> 可能还没完成（如果存在 IO 重排序）</li>
  <li>N3 向 Leader 返回成功</li>
</ul>

<p>如果 N3 此时崩溃重启，磁盘状态可能是：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">persisted_term = 1</code>（save_term(5) 未完成）</li>
  <li><code class="language-plaintext highlighter-rouge">entries = [E5-1, E5-2]</code>（都完成了）</li>
  <li>Leader L5 认为 E5-2 已提交</li>
</ul>

<h3 id="t7-时刻数据丢失">t7 时刻：数据丢失</h3>

<p>重启后 N3 的磁盘状态：<code class="language-plaintext highlighter-rouge">term=1, entries=[E5-1, E5-2]</code></p>

<p>当 L1 发送 <code class="language-plaintext highlighter-rouge">appendEntries(term=1, entries=[E1-1])</code>：</p>

<ul>
  <li>N3 检查：RPC term (1) == 本地 term (1)，接受</li>
  <li>E1-1 覆盖 index=1</li>
  <li><strong>已向 L5 确认提交的 E5-1 和 E5-2 被覆盖</strong></li>
</ul>

<p>注意, 如果不允许 IO-reorder, 那么 t6 的 <code class="language-plaintext highlighter-rouge">save_entries(E5-2)</code> 的完成就暗示了
<code class="language-plaintext highlighter-rouge">save_term(5)</code> 的完成, 满足了 appendEntries 成功的条件, 不会出现问题.</p>

<h2 id="问题的本质">问题的本质</h2>

<p>如果允许 IO-reorder，必须检查 <code class="language-plaintext highlighter-rouge">persisted_term</code> 来判断是否下发 save-term IO；如果不允许 IO-reorder，检查 <code class="language-plaintext highlighter-rouge">current_term</code> 即可。</p>

<p>Raft 论文不区分内存状态和持久化状态，这是实现相关的陷阱。论文要求 “Before responding to RPCs, a server must update its persistent state”，在实现中需要更精确的表述： <strong>必须等待所有使 <code class="language-plaintext highlighter-rouge">persisted_term &gt;= req.term</code> 的 IO 完成后，才能返回成功</strong>。</p>

<h2 id="正确的做法">正确的做法</h2>

<p>检查持久化的 term 而不是内存 term：</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">handle_append_entries</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">req</span><span class="p">:</span> <span class="n">AppendEntries</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Check persisted term, not in-memory term!</span>
    <span class="k">let</span> <span class="n">need_save_term</span> <span class="o">=</span> <span class="n">req</span><span class="py">.term</span> <span class="o">&gt;</span> <span class="k">self</span><span class="py">.persisted_term</span><span class="p">;</span>

    <span class="k">if</span> <span class="n">need_save_term</span> <span class="p">{</span>
        <span class="k">self</span><span class="py">.current_term</span> <span class="o">=</span> <span class="n">req</span><span class="py">.term</span><span class="p">;</span>
        <span class="k">self</span><span class="nf">.submit_io</span><span class="p">(</span><span class="nf">save_term</span><span class="p">(</span><span class="n">req</span><span class="py">.term</span><span class="p">));</span>
    <span class="p">}</span>

    <span class="k">self</span><span class="nf">.submit_io</span><span class="p">(</span><span class="nf">save_entries</span><span class="p">(</span><span class="n">req</span><span class="py">.entries</span><span class="p">));</span>

    <span class="k">if</span> <span class="n">need_save_term</span> <span class="p">{</span>
        <span class="nf">wait_for_both_ios</span><span class="p">();</span>  <span class="c1">// Must wait for save_term to complete</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
        <span class="nf">wait_for_io</span><span class="p">(</span><span class="n">save_entries</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nf">success</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>注意：这种实现可能多次提交 save-term IO，需要在实现中谨慎优化。</p>

<h2 id="主流实现的方案">主流实现的方案</h2>

<p>主流实现（TiKV、HashiCorp Raft、SOFAJRaft）通过限制 save-term 和 save-entries 不能 reorder，因此只检查 <code class="language-plaintext highlighter-rouge">current_term</code> 也是安全的：</p>

<ol>
  <li>
    <p><strong>原子批处理（TiKV）</strong>：将 save-term 和 save-entries 放到一个 IO 请求里，一次性提交。这样根本不存在”第二个 appendEntries 只提交 save_entries”的情况。</p>
  </li>
  <li>
    <p><strong>有序分离（HashiCorp Raft）</strong>：save-term 和 save-entries 顺序执行，不会重排序。先完成 term 的 fsync（失败则 panic），再写 log。</p>
  </li>
  <li>
    <p><strong>混合顺序（SOFAJRaft）</strong>：term 同步写入（阻塞等待 fsync），log 异步批处理。保证了 save_term 完成后才会入队 save_entries。</p>
  </li>
</ol>

<h2 id="总结">总结</h2>

<p>Raft 论文的抽象模型（只关注持久化状态）和实际实现（内存状态 + 持久化状态）之间存在微妙的映射关系。</p>

<p><strong>关键不变式</strong>：log entry (term=T) 在磁盘 → persisted_term ≥ T 也必须在磁盘</p>

<p>维护此不变式的两种方式：</p>

<ol>
  <li><strong>消除 IO-reorder</strong>：原子批处理、有序执行或混合方式（主流实现）</li>
  <li><strong>处理 IO-reorder</strong>：检查持久化状态，等待必要的 IO 完成</li>
</ol>

<h2 id="相关资源">相关资源</h2>

<ul>
  <li><a href="https://github.com/databendlabs/openraft/blob/main/openraft/src/docs/protocol/io_ordering.md">OpenRaft docs: io-ordering</a></li>
  <li><a href="https://github.com/tikv/tikv">tikv/tikv</a></li>
  <li><a href="https://github.com/hashicorp/raft">hashicorp/raft</a></li>
  <li><a href="https://github.com/sofastack/sofa-jraft">sofastack/sofa-jraft</a></li>
</ul>

<p>Reference:</p>

<ul>
  <li>
    <p>OpenRaft docs: io-ordering : <a href="https://github.com/databendlabs/openraft/blob/main/openraft/src/docs/protocol/io_ordering.md">https://github.com/databendlabs/openraft/blob/main/openraft/src/docs/protocol/io_ordering.md</a></p>
  </li>
  <li>
    <p>hashicorp/raft : <a href="https://github.com/hashicorp/raft">https://github.com/hashicorp/raft</a></p>
  </li>
  <li>
    <p>sofastack/sofa-jraft : <a href="https://github.com/sofastack/sofa-jraft">https://github.com/sofastack/sofa-jraft</a></p>
  </li>
  <li>
    <p>tikv/tikv : <a href="https://github.com/tikv/tikv">https://github.com/tikv/tikv</a></p>
  </li>
</ul>]]></content><author><name>Zhang Yanpo (drdr.xp)</name></author><category term="algo" /><category term="distributed" /><category term="分布式" /><category term="raft" /><category term="cn" /><summary type="html"><![CDATA[深入分析 Raft 实现中 IO 重排序导致数据丢失的问题。问题不在 Raft 的设计，而在于实现中内存状态与持久化状态的区分导致的陷阱]]></summary></entry><entry><title type="html">Raft Configuration Change with Single Log Entry</title><link href="https://blog.openacid.com/algo/single-log-joint/" rel="alternate" type="text/html" title="Raft Configuration Change with Single Log Entry" /><published>2025-10-07T00:00:00+00:00</published><updated>2025-10-07T00:00:00+00:00</updated><id>https://blog.openacid.com/algo/single-log-joint</id><content type="html" xml:base="https://blog.openacid.com/algo/single-log-joint/"><![CDATA[<p><img src="/post-res/single-log-joint/c915c4fcc98591ed-single-log-joint-banner.webp" alt="" /></p>

<h1 id="preface">Preface</h1>

<p><strong>TL;DR</strong></p>

<p>Standard Raft configuration changes use two log entries with multi-phase commits and careful state management. Can we complete a configuration change with just one log entry? We’ll introduce <strong>effective-config</strong>, prove its correctness, then discover why the simple approach isn’t so simple after all. The standard Joint Consensus method wins for good reasons.</p>

<p><strong>What We’ll Cover</strong></p>

<ol>
  <li>How Raft’s Joint Consensus works (the two-phase approach)</li>
  <li>The single-log-entry idea and its mechanics</li>
  <li>Why it’s theoretically correct</li>
  <li>Why it’s practically problematic (and the patches we’d need)</li>
  <li>Why we should stick with Joint Consensus</li>
</ol>

<h1 id="introduction-to-raft-joint-consensus-2-config-log-entries">Introduction to Raft Joint Consensus: 2 Config Log Entries</h1>

<p>Changing cluster membership in Raft is tricky. Switching from the old configuration <code class="language-plaintext highlighter-rouge">{a,b,c}</code> to a new one <code class="language-plaintext highlighter-rouge">{x,y,z}</code> in one step is dangerous.</p>

<p>Nodes can’t all switch configurations at the exact same moment. During the transition, some nodes (say <code class="language-plaintext highlighter-rouge">a,b</code>) might still be using <code class="language-plaintext highlighter-rouge">C_old</code> while others (<code class="language-plaintext highlighter-rouge">x,y,z</code>) have moved to <code class="language-plaintext highlighter-rouge">C_new</code>. If these two groups don’t overlap—meaning a quorum from <code class="language-plaintext highlighter-rouge">C_old</code> (like <code class="language-plaintext highlighter-rouge">{a,b}</code>) and a quorum from <code class="language-plaintext highlighter-rouge">C_new</code> (like <code class="language-plaintext highlighter-rouge">{x,y}</code>) share no common nodes—we could elect two leaders in the same term, violating Raft’s fundamental safety guarantee.</p>

<p>The Raft paper solves this with a two-phase protocol called <strong>Joint Consensus</strong>:</p>

<p><img src="/post-res/single-log-joint/a7acea752fd84833-raft-joint.x.svg" alt="Figure 1: Joint Consensus Two-Phase Process" /></p>

<ol>
  <li>
    <p><strong>Phase 1: Enter the Joint phase (<code class="language-plaintext highlighter-rouge">C_old_new</code>)</strong>
When the leader receives a configuration change request, it writes a log entry containing <code class="language-plaintext highlighter-rouge">C_old_new</code>—a joint configuration that includes both old and new members. In this state, any decision (like committing a log entry) needs approval from a quorum of <code class="language-plaintext highlighter-rouge">C_old</code> <em>and</em> a quorum of <code class="language-plaintext highlighter-rouge">C_new</code>. The leader starts using <code class="language-plaintext highlighter-rouge">C_old_new</code> as soon as it writes this entry to its own log.</p>
  </li>
  <li>
    <p><strong>Phase 2: Move to the new configuration (<code class="language-plaintext highlighter-rouge">C_new</code>)</strong>
Once <code class="language-plaintext highlighter-rouge">C_old_new</code> commits, the leader writes a second log entry containing just <code class="language-plaintext highlighter-rouge">C_new</code>. From this point forward, the leader uses only <code class="language-plaintext highlighter-rouge">C_new</code>, and all subsequent log entries need only commit on a <code class="language-plaintext highlighter-rouge">C_new</code> quorum. When this second entry commits, the configuration change is complete.</p>
  </li>
</ol>

<p>The intermediate joint phase ensures that any two quorums—whether based on <code class="language-plaintext highlighter-rouge">C_old</code>, <code class="language-plaintext highlighter-rouge">C_new</code>, or <code class="language-plaintext highlighter-rouge">C_old_new</code>—must overlap, preventing split brain. This requires <strong>two</strong> log entries for each configuration change.</p>

<h1 id="can-we-do-it-with-just-one-log-entry">Can We Do It With Just One Log Entry?</h1>

<p>Can we do this safely with just <strong>one</strong> log entry?</p>

<p>We need a new concept: <strong>effective-config</strong>. This is the configuration the leader <em>actually uses</em> to determine if log entries are committed. It might not match any specific configuration stored in a log entry—it’s a runtime state that changes as the configuration change progresses.</p>

<h2 id="terminology">Terminology</h2>

<ul>
  <li><strong>effective-config</strong>: The runtime configuration the leader uses to determine if entries are committed</li>
  <li><strong>Joint config</strong>: A configuration containing both old and new members, like <code class="language-plaintext highlighter-rouge">C_old_new = [{a,b,c}, {x,y,z}]</code></li>
  <li><strong>Uniform config</strong>: A configuration with just one set of members, like <code class="language-plaintext highlighter-rouge">C_new = {x,y,z}</code></li>
  <li><strong>Barrier entry</strong>: A marker log entry that signals the joint phase has safely ended</li>
</ul>

<h2 id="how-it-works">How It Works</h2>

<ul>
  <li>
    <p><strong>Starting point</strong>: The cluster is running with <code class="language-plaintext highlighter-rouge">C_old = {a,b,c}</code>, and that configuration has been committed. The effective-config is <code class="language-plaintext highlighter-rouge">C_old</code>.</p>

    <p><img src="/post-res/single-log-joint/667db9f105260fea-single-1-start.x.svg" alt="Figure 2: Single Log Entry Change - Initial State" /></p>
  </li>
  <li>
    <p><strong>Propose the change</strong>: To change to <code class="language-plaintext highlighter-rouge">C_new = {x,y,z}</code>, the leader writes a single log entry <code class="language-plaintext highlighter-rouge">entry-i</code> containing just <code class="language-plaintext highlighter-rouge">C_new</code>.</p>
  </li>
  <li>
    <p><strong>Enter joint mode immediately</strong>: The moment the leader appends <code class="language-plaintext highlighter-rouge">entry-i</code> to its own log—before it commits, before it replicates—the leader switches its effective-config to the joint configuration <code class="language-plaintext highlighter-rouge">C_old_new = [{a,b,c}, {x,y,z}]</code>. Now <code class="language-plaintext highlighter-rouge">entry-i</code> and all subsequent entries must commit on a quorum from <em>both</em> <code class="language-plaintext highlighter-rouge">{a,b,c}</code> and <code class="language-plaintext highlighter-rouge">{x,y,z}</code>.</p>

    <p><img src="/post-res/single-log-joint/64bd23d7d6bd8fd7-single-2-joint.x.svg" alt="Figure 3: Single Log Entry Change - Entering Joint Phase" /></p>
  </li>
  <li>
    <p><strong>Normal operation continues</strong>: The cluster keeps processing requests. Every entry commits using the joint quorum rules.</p>
  </li>
  <li>
    <p><strong>Exit joint mode</strong>: Once <code class="language-plaintext highlighter-rouge">entry-i</code> commits under <code class="language-plaintext highlighter-rouge">C_old_new</code>, the leader switches effective-config to <code class="language-plaintext highlighter-rouge">C_new = {x,y,z}</code>. All subsequent entries need only a <code class="language-plaintext highlighter-rouge">C_new</code> quorum.</p>
  </li>
</ul>

<p>With one log entry, the system transitions through three states: <code class="language-plaintext highlighter-rouge">C_old → C_old_new → C_new</code>.</p>

<h3 id="correctness-proof">Correctness Proof</h3>

<p>We need to show that we can’t elect two leaders—neither during the configuration change nor afterward.</p>

<p>Assume leader <code class="language-plaintext highlighter-rouge">t</code> is doing the configuration change (writing <code class="language-plaintext highlighter-rouge">entry-i</code>). Later, some candidate <code class="language-plaintext highlighter-rouge">u</code> tries to get elected in term <code class="language-plaintext highlighter-rouge">u &gt; t</code>. We prove <code class="language-plaintext highlighter-rouge">t</code> and <code class="language-plaintext highlighter-rouge">u</code> can’t both be leaders.</p>

<p><strong>Analyzing candidate <code class="language-plaintext highlighter-rouge">u</code>’s election</strong></p>

<p>Candidate <code class="language-plaintext highlighter-rouge">u</code> either has <code class="language-plaintext highlighter-rouge">entry-i</code> in its log or it doesn’t.</p>

<ul>
  <li>
    <p><strong>Case 1: <code class="language-plaintext highlighter-rouge">u</code> has <code class="language-plaintext highlighter-rouge">entry-i</code></strong></p>

    <p>Then <code class="language-plaintext highlighter-rouge">u</code>’s effective-config includes <code class="language-plaintext highlighter-rouge">{x,y,z}</code>. Leader <code class="language-plaintext highlighter-rouge">t</code>’s effective-config is either <code class="language-plaintext highlighter-rouge">C_old_new = [{a,b,c}, {x,y,z}]</code> (still in joint mode) or <code class="language-plaintext highlighter-rouge">C_new = {x,y,z}</code> (finished). Either way, it includes <code class="language-plaintext highlighter-rouge">{x,y,z}</code>.</p>

    <p>Since <code class="language-plaintext highlighter-rouge">u</code> needs a quorum from <code class="language-plaintext highlighter-rouge">{x,y,z}</code> to get elected, and <code class="language-plaintext highlighter-rouge">t</code> needs a quorum from <code class="language-plaintext highlighter-rouge">{x,y,z}</code> to stay leader, these quorums must overlap. No split brain.</p>
  </li>
  <li>
    <p><strong>Case 2: <code class="language-plaintext highlighter-rouge">u</code> doesn’t have <code class="language-plaintext highlighter-rouge">entry-i</code></strong></p>

    <p>Then <code class="language-plaintext highlighter-rouge">u</code>’s effective-config is <code class="language-plaintext highlighter-rouge">C_old = {a,b,c}</code>. Now we consider where leader <code class="language-plaintext highlighter-rouge">t</code> is:</p>

    <ul>
      <li>
        <p>If <code class="language-plaintext highlighter-rouge">t</code>’s effective-config is <code class="language-plaintext highlighter-rouge">C_old_new</code>, then <code class="language-plaintext highlighter-rouge">t</code> needs a quorum from <code class="language-plaintext highlighter-rouge">{a,b,c}</code> and <code class="language-plaintext highlighter-rouge">u</code> needs a quorum from <code class="language-plaintext highlighter-rouge">{a,b,c}</code>. These must overlap. No split brain.</p>
      </li>
      <li>
        <p>If <code class="language-plaintext highlighter-rouge">t</code>’s effective-config is <code class="language-plaintext highlighter-rouge">C_new = {x,y,z}</code>, that means <code class="language-plaintext highlighter-rouge">entry-i</code> committed under <code class="language-plaintext highlighter-rouge">C_old_new</code>. So <code class="language-plaintext highlighter-rouge">entry-i</code> must exist on a quorum of <code class="language-plaintext highlighter-rouge">{a,b,c}</code>. Those nodes have logs at least as long as index <code class="language-plaintext highlighter-rouge">i</code>.</p>

        <p>But <code class="language-plaintext highlighter-rouge">u</code> doesn’t have <code class="language-plaintext highlighter-rouge">entry-i</code>, so its log is shorter than <code class="language-plaintext highlighter-rouge">i</code>. When <code class="language-plaintext highlighter-rouge">u</code> requests votes from nodes in <code class="language-plaintext highlighter-rouge">{a,b,c}</code>, they’ll reject it because their logs are more up-to-date. The election fails.</p>
      </li>
    </ul>
  </li>
</ul>

<p>In every case, we can’t have both <code class="language-plaintext highlighter-rouge">t</code> and <code class="language-plaintext highlighter-rouge">u</code> as leaders. The algorithm is safe.</p>

<p>However, although theoretically correct, it introduces problems in actual implementation:</p>

<h2 id="problem-1-the-memory-only-transition">Problem 1: The Memory-Only Transition</h2>

<p>When we move from <code class="language-plaintext highlighter-rouge">C_old_new</code> to <code class="language-plaintext highlighter-rouge">C_new</code>, we only change the in-memory effective-config. Nothing hits disk. This creates trouble.</p>

<p>Nodes from <code class="language-plaintext highlighter-rouge">C_old</code> can still initiate elections and compete with <code class="language-plaintext highlighter-rouge">C_new</code> nodes, because <code class="language-plaintext highlighter-rouge">C_old</code> logs are as long as <code class="language-plaintext highlighter-rouge">C_new</code> logs. Even after the configuration change completes, <code class="language-plaintext highlighter-rouge">C_old</code> nodes can steal leadership from <code class="language-plaintext highlighter-rouge">C_new</code> nodes. The root cause is that the state change is not recorded on the persistent layer. This is problematic because nodes intended for removal can still become leaders.</p>

<p>Compare this to standard Joint Consensus: it writes a second log entry containing <code class="language-plaintext highlighter-rouge">C_new</code>. That entry acts as a barrier. Nodes from <code class="language-plaintext highlighter-rouge">C_old</code> have shorter logs and lose elections. The single-entry approach has no such barrier—the transition from <code class="language-plaintext highlighter-rouge">C_old_new</code> to <code class="language-plaintext highlighter-rouge">C_new</code> is invisible on disk.</p>

<p>Look at the diagram below. The cluster transitions from <code class="language-plaintext highlighter-rouge">C_old_new</code> to <code class="language-plaintext highlighter-rouge">C_new</code>, but no logs change. Leadership moves to node <code class="language-plaintext highlighter-rouge">x</code> in <code class="language-plaintext highlighter-rouge">{x,y,z}</code>. But nodes from <code class="language-plaintext highlighter-rouge">C_old</code> can still start elections and steal leadership from <code class="language-plaintext highlighter-rouge">x</code>.</p>

<p><img src="/post-res/single-log-joint/3e6fb36ca8cf87de-single-3-elect.x.svg" alt="Figure 4: Patch-1 Persistent Layer Problem Example" /></p>

<p><strong>Patch-1</strong>: After entering <code class="language-plaintext highlighter-rouge">C_new</code>, immediately append a no-op entry. This lengthens the logs of <code class="language-plaintext highlighter-rouge">C_new</code> nodes, blocking elections from <code class="language-plaintext highlighter-rouge">C_old</code> nodes.</p>

<h2 id="problem-2-the-restart-ambiguity">Problem 2: The Restart Ambiguity</h2>

<p>When a node restarts, it can’t tell if the cluster is in joint mode or has finished the change.</p>

<ul>
  <li>
    <p>The restarting node reads its log. It sees <code class="language-plaintext highlighter-rouge">entry-i</code> containing <code class="language-plaintext highlighter-rouge">C_old</code> and <code class="language-plaintext highlighter-rouge">entry-j</code> containing <code class="language-plaintext highlighter-rouge">C_new</code>.</p>
  </li>
  <li>
    <p>We know <code class="language-plaintext highlighter-rouge">entry-i</code> is committed (Raft requires it before starting a new change).</p>
  </li>
  <li>
    <p>But what about <code class="language-plaintext highlighter-rouge">entry-j</code>? The node can’t tell just from its local log:</p>

    <ul>
      <li>If <code class="language-plaintext highlighter-rouge">entry-j</code> isn’t committed yet, the cluster is in joint mode with effective-config <code class="language-plaintext highlighter-rouge">C_old_new</code></li>
      <li>If <code class="language-plaintext highlighter-rouge">entry-j</code> is committed, the cluster is using <code class="language-plaintext highlighter-rouge">C_new</code></li>
    </ul>
  </li>
</ul>

<p>Without talking to other nodes, there’s no way to know.</p>

<p><img src="/post-res/single-log-joint/004bf3df9c4de529-restart.x.svg" alt="Figure 5: Patch-2 New Node Restart State Example" /></p>

<p>In the diagram above, even if <code class="language-plaintext highlighter-rouge">entry-3</code> has committed, the restarting nodes <code class="language-plaintext highlighter-rouge">b</code>, <code class="language-plaintext highlighter-rouge">c</code>, <code class="language-plaintext highlighter-rouge">x</code>, <code class="language-plaintext highlighter-rouge">y</code> can’t tell whether the cluster is in joint mode or using the new configuration. (Nodes <code class="language-plaintext highlighter-rouge">a</code> and <code class="language-plaintext highlighter-rouge">z</code> never received <code class="language-plaintext highlighter-rouge">entry-3</code> and are still using <code class="language-plaintext highlighter-rouge">{a,b,c}</code>.)</p>

<p><strong>Patch-2</strong>: Always start in joint mode after a restart.</p>

<ol>
  <li>When a node starts up, it sets effective-config to the joint configuration formed from the last two config entries in its log</li>
  <li>It uses this joint config for elections and normal operation</li>
  <li>Only after confirming that the latest config entry has committed under the joint configuration can it switch to the new configuration</li>
</ol>

<p><strong>Example</strong>: A node sees configs <code class="language-plaintext highlighter-rouge">{a,b,c}</code> and <code class="language-plaintext highlighter-rouge">{u,v,w}</code> in its log. It starts with effective-config <code class="language-plaintext highlighter-rouge">[{a,b,c}, {u,v,w}]</code>. To become leader, it needs quorums from both groups. Only after it confirms the new config committed under the joint rules can it switch to just <code class="language-plaintext highlighter-rouge">{u,v,w}</code>.</p>

<h2 id="problem-3-calling-home-to-dead-nodes">Problem 3: Calling Home to Dead Nodes</h2>

<p>Patch-2 solves the ambiguity problem but creates a worse one: <strong>nodes might try to contact old cluster members that no longer exist, making elections impossible</strong>.</p>

<p><strong>Example</strong>:</p>

<p><img src="/post-res/single-log-joint/0507172bcfea4f35-restart-after-uniform.x.svg" alt="Figure 6: Regression to C-old-new After Restart" /></p>

<ol>
  <li>
    <p>The cluster changes from <code class="language-plaintext highlighter-rouge">{a,b,c}</code> to <code class="language-plaintext highlighter-rouge">{x,y,z}</code></p>
  </li>
  <li>
    <p>The config entry commits under <code class="language-plaintext highlighter-rouge">C_old_new</code></p>
  </li>
  <li>
    <p>The cluster transitions to <code class="language-plaintext highlighter-rouge">C_new = {x,y,z}</code></p>
  </li>
  <li>
    <p>Nodes <code class="language-plaintext highlighter-rouge">a</code>, <code class="language-plaintext highlighter-rouge">b</code>, <code class="language-plaintext highlighter-rouge">c</code> are no longer members. They get shut down, their data gets wiped, and they’re gone</p>
  </li>
  <li>
    <p>Then something happens and all remaining nodes restart</p>
  </li>
  <li>
    <p>Node <code class="language-plaintext highlighter-rouge">x</code> restarts and follows Patch-2: it sees configs <code class="language-plaintext highlighter-rouge">{a,b,c}</code> and <code class="language-plaintext highlighter-rouge">{x,y,z}</code> in its log, so it sets effective-config to <code class="language-plaintext highlighter-rouge">[{a,b,c}, {x,y,z}]</code></p>
  </li>
  <li>
    <p>Node <code class="language-plaintext highlighter-rouge">x</code> tries to run an election, but <code class="language-plaintext highlighter-rouge">b</code> and <code class="language-plaintext highlighter-rouge">c</code> don’t exist anymore! It can’t get a quorum from both groups. The election fails. The cluster is stuck.</p>
  </li>
</ol>

<p>This is state regression. The transition from <code class="language-plaintext highlighter-rouge">C_old_new</code> to <code class="language-plaintext highlighter-rouge">C_new</code> wasn’t persisted, so after a restart, the system rolls back to needing <code class="language-plaintext highlighter-rouge">C_old</code>.</p>

<h2 id="adding-a-barrier-to-prevent-regression">Adding a Barrier to Prevent Regression</h2>

<p>Restarting nodes need to <strong>know for certain</strong> that the joint phase has ended—proof that it’s safe to use <code class="language-plaintext highlighter-rouge">C_new</code> without calling back to <code class="language-plaintext highlighter-rouge">C_old</code>.</p>

<p><strong>Patch-3: Add a barrier entry</strong></p>

<p>After <code class="language-plaintext highlighter-rouge">entry-j</code> (containing <code class="language-plaintext highlighter-rouge">C_new</code>) commits under <code class="language-plaintext highlighter-rouge">C_old_new</code>, append a special <strong>barrier entry</strong> to mark that <code class="language-plaintext highlighter-rouge">entry-j</code> has committed.</p>

<blockquote>
  <p><strong>Important</strong>: The barrier must come <em>after</em> <code class="language-plaintext highlighter-rouge">entry-j</code> commits. Otherwise it can’t serve as proof of the commit.</p>
</blockquote>

<p>When a restarting node sees this barrier, it knows the joint phase ended successfully. It can safely use <code class="language-plaintext highlighter-rouge">C_new</code> for elections without trying to contact old nodes that might not exist anymore.</p>

<p>In the diagram below, when <code class="language-plaintext highlighter-rouge">entry-3</code> commits under <code class="language-plaintext highlighter-rouge">C_old_new</code>, we add barrier <code class="language-plaintext highlighter-rouge">entry-4</code>:</p>

<p><img src="/post-res/single-log-joint/a1b3d0adfde6319d-barrier.x.svg" alt="Figure 7: Patch-3 Introducing Barrier Entry Process" /></p>

<p>Now when all nodes restart, there’s no regression. Nodes <code class="language-plaintext highlighter-rouge">x</code> and <code class="language-plaintext highlighter-rouge">y</code> see the barrier, so they use <code class="language-plaintext highlighter-rouge">C_new = {x,y,z}</code> directly. Even though <code class="language-plaintext highlighter-rouge">b</code> and <code class="language-plaintext highlighter-rouge">c</code> are gone, <code class="language-plaintext highlighter-rouge">x</code> or <code class="language-plaintext highlighter-rouge">y</code> can still get elected:</p>

<p><img src="/post-res/single-log-joint/431e950cac307092-barrier-restart.x.svg" alt="Figure 8: Barrier Entry After Restart" /></p>

<blockquote>
  <p><strong>Alternative: Persisting commit-index</strong></p>

  <p>Instead of a barrier entry, we could persist the commit-index—an idea from <a href="https://weibo.com/u/1516609505">Ma Jianjiang</a>.</p>

  <p>The rule: joint consensus ends when commit-index reaches a quorum of <code class="language-plaintext highlighter-rouge">C_new</code>. To make this work, we’d need to persist commit-index (standard Raft doesn’t require this).</p>

  <p>When a node restarts, it checks: if the persisted commit-index covers the config change entry, it knows <code class="language-plaintext highlighter-rouge">C_old_new</code> finished and can safely use <code class="language-plaintext highlighter-rouge">C_new</code>. No need to contact old nodes.</p>

  <p>But this still has Problem 1—<code class="language-plaintext highlighter-rouge">C_old</code> and <code class="language-plaintext highlighter-rouge">C_new</code> nodes competing for leadership. Here’s why: <code class="language-plaintext highlighter-rouge">C_new</code> nodes don’t have extra log entries, and committing commit-index to just <code class="language-plaintext highlighter-rouge">C_new</code> doesn’t guarantee <code class="language-plaintext highlighter-rouge">C_old</code> nodes see it. This is the classic distributed systems dilemma of at-least-once vs at-most-once delivery:</p>

  <ul>
    <li><strong>At-least-once</strong> (commit on <code class="language-plaintext highlighter-rouge">C_old_new</code>): commit-index might succeed, then <code class="language-plaintext highlighter-rouge">C_old</code> nodes get decommissioned, then we can’t commit it again to reach them. We’re stuck.</li>
    <li><strong>At-most-once</strong> (commit on <code class="language-plaintext highlighter-rouge">C_new</code> only): commit-index reaches <code class="language-plaintext highlighter-rouge">C_new</code> but might not reach <code class="language-plaintext highlighter-rouge">C_old</code>. Those nodes don’t know the cluster moved on, so they keep trying to run elections.</li>
  </ul>

  <p>Either way, we can still end up with <code class="language-plaintext highlighter-rouge">C_old</code> and <code class="language-plaintext highlighter-rouge">C_new</code> nodes competing for leadership.</p>
</blockquote>

<p>So here’s what the <strong>patched single-log approach</strong> looks like:</p>

<ol>
  <li>
    <p>Start with <code class="language-plaintext highlighter-rouge">effective-config = C_old = {a,b,c}</code></p>
  </li>
  <li>
    <p>Leader writes <code class="language-plaintext highlighter-rouge">entry-j</code> containing <code class="language-plaintext highlighter-rouge">C_new = {x,y,z}</code> and immediately switches <code class="language-plaintext highlighter-rouge">effective-config</code> to <code class="language-plaintext highlighter-rouge">C_old_new = [{a,b,c}, {x,y,z}]</code></p>
  </li>
  <li>
    <p>All entries from index <code class="language-plaintext highlighter-rouge">j</code> onward replicate and commit under <code class="language-plaintext highlighter-rouge">C_old_new</code></p>
  </li>
  <li>
    <p><strong>Critical step</strong>: Once <code class="language-plaintext highlighter-rouge">entry-j</code> commits under <code class="language-plaintext highlighter-rouge">C_old_new</code>, the leader writes a special <strong>barrier entry</strong>. This entry has no configuration data—it just marks “the joint phase is done.” The leader can switch to <code class="language-plaintext highlighter-rouge">effective-config = C_new</code> and use <code class="language-plaintext highlighter-rouge">C_new</code> to replicate the barrier.</p>
  </li>
  <li>
    <p>When the barrier entry commits, the configuration change is complete</p>
  </li>
</ol>

<p><strong>Restart behavior</strong>:</p>

<p>When a node restarts, it reads its log. It sees <code class="language-plaintext highlighter-rouge">entry-i</code> (<code class="language-plaintext highlighter-rouge">C_old</code>) and <code class="language-plaintext highlighter-rouge">entry-j</code> (<code class="language-plaintext highlighter-rouge">C_new</code>). It checks: is there a barrier after <code class="language-plaintext highlighter-rouge">entry-j</code>?</p>

<ul>
  <li>
    <p><strong>Barrier present</strong>: Joint phase ended. Set <code class="language-plaintext highlighter-rouge">effective-config = C_new</code>. No need to contact old nodes.</p>
  </li>
  <li>
    <p><strong>No barrier</strong>: Joint phase might still be active. Set <code class="language-plaintext highlighter-rouge">effective-config = C_old_new</code>.</p>
  </li>
</ul>

<p>Patch-3 adds a second log entry. We’re no longer doing “one log entry” configuration changes. We need “one config entry + one barrier entry.”</p>

<h1 id="conclusion">Conclusion</h1>

<p>Configuration changes must pass through three states—<code class="language-plaintext highlighter-rouge">C_old → C_old_new → C_new</code>. One log entry gives us one bit of persistent information: <code class="language-plaintext highlighter-rouge">C_old</code> or <code class="language-plaintext highlighter-rouge">C_new</code>. That’s only two states. We can’t represent three states with two values.</p>

<p>To safely handle all three states, we need at least two log entries. That gives us two bits of information and up to four possible states, which is enough to encode the three states we actually need.</p>

<p>The “single-log-entry” approach, after all the patches, ends up needing two entries anyway—one for the configuration and one for the barrier. And it’s more complex than standard Joint Consensus, with trickier edge cases around restarts and state transitions.</p>

<p>Stick with Joint Consensus. It’s cleaner, simpler, and solves the problem directly without patches.</p>

<h2 id="references">References</h2>

<ul>
  <li>Diego Ongaro &amp; John Ousterhout. In Search of an Understandable Consensus Algorithm (Raft paper): https://raft.github.io/raft.pdf</li>
  <li>OpenRaft(rust): https://github.com/databendlabs/openraft</li>
  <li>etcd/raft source code: https://github.com/etcd-io/raft</li>
  <li>Hashicorp Raft implementation: https://github.com/hashicorp/raft</li>
</ul>

<p>Reference:</p>

<ul>
  <li>马健将 : <a href="https://weibo.com/u/1516609505">https://weibo.com/u/1516609505</a></li>
</ul>]]></content><author><name>Zhang Yanpo (drdr.xp)</name></author><category term="algo" /><category term="distributed" /><category term="raft" /><category term="config-change" /><category term="joint" /><summary type="html"><![CDATA[Is the single-log-entry approach to Raft configuration change simpler than the standard Joint Consensus?]]></summary></entry><entry><title type="html">Raft IO Execution Order (Revised)</title><link href="https://blog.openacid.com/algo/raft-io-order-fix/" rel="alternate" type="text/html" title="Raft IO Execution Order (Revised)" /><published>2025-10-04T00:00:00+00:00</published><updated>2025-10-04T00:00:00+00:00</updated><id>https://blog.openacid.com/algo/raft-io-order-fix</id><content type="html" xml:base="https://blog.openacid.com/algo/raft-io-order-fix/"><![CDATA[<p><img src="/post-res/raft-io-order-fix/62b7bb390d222f2e-raft-io-order-fix-banner.webp" alt="" /></p>

<h2 id="preface">Preface</h2>

<p>I need to come clean about something. In my <a href="https://blog.openacid.com/algo/raft-io-order/">previous article on IO ordering in Raft</a>, I tried to demonstrate the dangers of “writing log entries before term” using a committed data loss scenario. The problem? That example was fundamentally flawed—it didn’t actually capture the real issue with IO reordering at all.</p>

<p>So let’s fix that. This article walks through what I got wrong and, more importantly, presents a correct understanding of when and why IO reordering becomes dangerous in Raft implementations.</p>

<h2 id="what-went-wrong-in-my-original-analysis">What Went Wrong in My Original Analysis</h2>

<p>Let me show you the timeline I used in the previous article:</p>

<blockquote>
  <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Legend:
Ni:   Node i
Vi:   RequestVote, term=i
Li:   Establish Leader, term=i
Ei-j: Log entry, term=i, index=j

N5 |          V5  L5       E5-1
N4 |          V5           E5-1
N3 |  V1                V5,E5-1  E1-1
N2 |  V1      V5                 E1-1
N1 |  V1  L1                     E1-1
------+---+---+---+--------+-----+---------&gt; time
      t1  t2  t3  t4       t5    t6
</code></pre></div>  </div>

  <p>Here’s what I claimed would happen:</p>

  <ul>
    <li>At t5: N3 receives entry E5-1 from leader L5 (term=5) and needs to persist both term=5 and E5-1</li>
    <li>I argued: “If N3 writes E5-1 first but crashes before writing term=5, it could restart with <code class="language-plaintext highlighter-rouge">term=1, entries=[E5-1]</code>”</li>
    <li>At t6: The old leader L1 (term=1) could then overwrite E5-1, causing data loss</li>
  </ul>

  <p><strong>Here’s the flaw in my reasoning</strong>: Raft’s protocol explicitly requires that <em>both</em> the term update and log entries must be successfully persisted before a follower responds with success. If either IO fails or is incomplete, the leader never receives confirmation and therefore never considers the entry committed. The Raft paper’s design is actually bulletproof here.</p>
</blockquote>

<p>So if Raft’s design is correct, where does the IO ordering problem actually come from? The answer lies in a subtle gap between theory and implementation—specifically, how real Raft systems separate in-memory state from on-disk state.</p>

<h2 id="the-real-culprit-in-memory-vs-persisted-state">The Real Culprit: In-Memory vs Persisted State</h2>

<p>Here’s where things get interesting. The Raft paper describes a beautifully simple world where a server has just one state: what’s on disk. But real implementations need to be fast, so they introduce an optimization—they split their state into two layers:</p>

<p><strong>In-memory state</strong>: The “optimistic” view that updates immediately when receiving RPCs
<strong>Persisted state</strong>: The “durable” view that updates only after IO completes</p>

<p>Here’s how a typical implementation handles an appendEntries request:</p>

<ol>
  <li>Receive appendEntries RPC with <code class="language-plaintext highlighter-rouge">req.term</code></li>
  <li>If <code class="language-plaintext highlighter-rouge">req.term &gt; current_term</code>, immediately update <code class="language-plaintext highlighter-rouge">current_term</code> to <code class="language-plaintext highlighter-rouge">req.term</code></li>
  <li>Asynchronously submit a save-term IO operation</li>
  <li>Eventually update <code class="language-plaintext highlighter-rouge">persisted_term</code> when the IO completes</li>
</ol>

<p>In code, this looks like:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">RaftState</span> <span class="p">{</span>
    <span class="c1">// In-memory term - may be ahead of what's on disk</span>
    <span class="n">current_term</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>

    <span class="c1">// Persisted term on disk - the durable truth</span>
    <span class="n">persisted_term</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This separation is where the danger lurks. The Raft paper assumes only one “term” variable—what’s persisted on disk. But implementations now have <em>two</em> term values, and this introduces a behavior the paper never defined or analyzed.</p>

<p>The pattern above is ubiquitous in Raft implementations. And here’s the kicker: <em>without IO reordering, it works perfectly fine</em>. The bug only surfaces when IOs can complete out of order.</p>

<h2 id="a-concrete-example-where-io-reordering-breaks-raft">A Concrete Example: Where IO Reordering Breaks Raft</h2>

<p>Let’s build a scenario that actually exposes the bug. I’ll walk you through it step by step:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Legend:
Ni:   Node i
Vi:   RequestVote, term=i
Li:   Establish Leader, term=i
Ei-j: Log entry, term=i, index=j

N5 |          V5  L5     E5-1     E5-2
N4 |          V5         E5-1     E5-2
N3 |  V1              V5,E5-1  V5,E5-2  E1-1
N2 |  V1      V5                        E1-1
N1 |  V1  L1                            E1-1
------+---+---+---+------+--------+-----+------&gt; time
      t1  t2  t3  t4     t5       t6    t7
</code></pre></div></div>

<p>Here’s the sequence of events:</p>

<ul>
  <li><strong>t1-t4</strong>: Two elections occur. First N1 becomes leader (term=1), then N5 becomes leader (term=5)</li>
  <li><strong>t5</strong>: Leader L5 sends its first entry E5-1 to follower N3
    <ul>
      <li>N3’s current state: <code class="language-plaintext highlighter-rouge">current_term=1</code>, <code class="language-plaintext highlighter-rouge">persisted_term=1</code></li>
      <li>N3 receives <code class="language-plaintext highlighter-rouge">appendEntries(term=5, entries=[E5-1])</code></li>
      <li>N3 must persist both term=5 and entry E5-1</li>
      <li>N3 responds “success” only after both IOs complete</li>
    </ul>
  </li>
  <li><strong>t6</strong>: Leader L5 sends a second entry E5-2 to N3 ← <em>This is the critical moment</em>
    <ul>
      <li>N3 might still be waiting for t5’s IOs to complete</li>
      <li>Whether IO reordering can occur makes all the difference</li>
    </ul>
  </li>
  <li><strong>t7</strong>: The old leader L1 (term=1) attempts to replicate E1-1 to N3</li>
</ul>

<p>The bug manifests in what happens at <strong>t6</strong>—when the second AppendEntries arrives while the first one’s IOs are still in flight. Let’s zoom into N3’s internal state at each step.</p>

<h3 id="at-t5-the-first-appendentries">At t5: The First AppendEntries</h3>

<p>When N3 receives <code class="language-plaintext highlighter-rouge">appendEntries(term=5, entries=[E5-1])</code>, here’s what happens inside:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">handle_append_entries</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">req</span><span class="p">:</span> <span class="n">AppendEntries</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Check: Is the RPC term newer than our in-memory term?</span>
    <span class="k">if</span> <span class="n">req</span><span class="py">.term</span> <span class="o">&gt;</span> <span class="k">self</span><span class="py">.current_term</span> <span class="p">{</span>
        <span class="k">self</span><span class="py">.current_term</span> <span class="o">=</span> <span class="n">req</span><span class="py">.term</span><span class="p">;</span>           <span class="c1">// Update memory immediately: 1 → 5</span>
        <span class="k">self</span><span class="nf">.submit_io</span><span class="p">(</span><span class="nf">save_term</span><span class="p">(</span><span class="n">req</span><span class="py">.term</span><span class="p">));</span>    <span class="c1">// Queue IO to persist term=5</span>
    <span class="p">}</span>

    <span class="k">self</span><span class="nf">.submit_io</span><span class="p">(</span><span class="nf">save_entries</span><span class="p">(</span><span class="n">req</span><span class="py">.entries</span><span class="p">));</span>  <span class="c1">// Queue IO to persist E5-1</span>

    <span class="c1">// Wait for both IOs to complete before responding</span>
    <span class="nf">wait_for_both_ios</span><span class="p">();</span>
    <span class="k">return</span> <span class="nf">success</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>After this call executes, N3’s state looks like:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">current_term = 5</code> (memory updated immediately)</li>
  <li><code class="language-plaintext highlighter-rouge">persisted_term = 1</code> (disk not yet updated—IO still in flight)</li>
  <li>IO queue: <code class="language-plaintext highlighter-rouge">[save_term(5), save_entries(E5-1)]</code> waiting to complete</li>
</ul>

<p>So far, so good. This request is handled correctly—N3 won’t respond until both IOs finish. The trouble starts at the next moment.</p>

<h3 id="at-t6-the-second-appendentrieswhere-everything-goes-wrong">At t6: The Second AppendEntries—Where Everything Goes Wrong</h3>

<p>Now here’s the critical moment. Before t5’s IOs have completed, N3 receives a second request: <code class="language-plaintext highlighter-rouge">appendEntries(term=5, entries=[E5-2])</code>.</p>

<p>Most implementations check only the in-memory <code class="language-plaintext highlighter-rouge">current_term</code> to decide whether to persist the term. Watch what happens:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">handle_append_entries</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">req</span><span class="p">:</span> <span class="n">AppendEntries</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Check: Is 5 &gt; 5? Nope!</span>
    <span class="k">if</span> <span class="n">req</span><span class="py">.term</span> <span class="o">&gt;</span> <span class="k">self</span><span class="py">.current_term</span> <span class="p">{</span>
        <span class="c1">// We skip this branch entirely</span>
    <span class="p">}</span>

    <span class="c1">// We only queue the entries IO</span>
    <span class="k">self</span><span class="nf">.submit_io</span><span class="p">(</span><span class="nf">save_entries</span><span class="p">(</span><span class="n">req</span><span class="py">.entries</span><span class="p">));</span>

    <span class="c1">// We only wait for the entries IO to complete</span>
    <span class="nf">wait_for_io</span><span class="p">(</span><span class="n">save_entries</span><span class="p">);</span>
    <span class="k">return</span> <span class="nf">success</span><span class="p">();</span>  <span class="c1">// We're done!</span>
<span class="p">}</span>
</code></pre></div></div>

<p>See the problem? N3 returns success as soon as <code class="language-plaintext highlighter-rouge">save_entries(E5-2)</code> completes. But here’s the dangerous part: <strong>if IO reordering is allowed</strong>, the system might have:</p>

<ul>
  <li>✅ Completed <code class="language-plaintext highlighter-rouge">save_entries(E5-2)</code></li>
  <li>✅ Completed <code class="language-plaintext highlighter-rouge">save_entries(E5-1)</code></li>
  <li>❌ NOT completed <code class="language-plaintext highlighter-rouge">save_term(5)</code> (still in flight from t5)</li>
</ul>

<p>N3 happily returns success to Leader L5, which then considers E5-2 replicated and potentially committed.</p>

<p>Now imagine N3 crashes. When it restarts, its disk state is:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">persisted_term = 1</code> (the save_term(5) never finished)</li>
  <li><code class="language-plaintext highlighter-rouge">entries = [E5-1, E5-2]</code> (both successfully written)</li>
</ul>

<p>This is an inconsistent state that Raft’s protocol assumes can never exist. And it’s about to cause data loss.</p>

<h3 id="at-t7-the-data-loss-materializes">At t7: The Data Loss Materializes</h3>

<p>After N3 restarts with <code class="language-plaintext highlighter-rouge">term=1, entries=[E5-1, E5-2]</code>, the old leader L1 (from term=1) sends an appendEntries request: <code class="language-plaintext highlighter-rouge">appendEntries(term=1, entries=[E1-1])</code>.</p>

<p>N3’s logic:</p>

<ol>
  <li>Check: RPC term (1) == my local term (1) ✅</li>
  <li>Accept the request</li>
  <li>Write E1-1 at index=1, overwriting E5-1</li>
</ol>

<p><strong>The disaster</strong>: Entries E5-1 and E5-2, which Leader L5 believed were successfully replicated and possibly committed, have just been silently destroyed. We’ve lost committed data.</p>

<hr />

<p><strong>Important note</strong>: If IO reordering were <em>not</em> allowed, this bug wouldn’t occur. Here’s why: when <code class="language-plaintext highlighter-rouge">save_entries(E5-2)</code> completes at t6, it would guarantee that <code class="language-plaintext highlighter-rouge">save_term(5)</code> (queued earlier) has also completed. The sequential ordering ensures that N3’s disk state remains consistent, and the AppendEntries success response would be legitimate.</p>

<h2 id="the-root-cause-a-mismatch-between-theory-and-practice">The Root Cause: A Mismatch Between Theory and Practice</h2>

<p>Let’s crystallize what we’ve learned:</p>

<p><strong>The core issue</strong>: When deciding whether to persist the term, should we check <code class="language-plaintext highlighter-rouge">current_term</code> or <code class="language-plaintext highlighter-rouge">persisted_term</code>?</p>

<ul>
  <li>If IO reordering is <strong>not allowed</strong> → checking <code class="language-plaintext highlighter-rouge">current_term</code> is safe</li>
  <li>If IO reordering <strong>is allowed</strong> → we must check <code class="language-plaintext highlighter-rouge">persisted_term</code></li>
</ul>

<p>This isn’t obvious because the Raft paper never talks about in-memory vs persisted state—it only knows about one kind of state: what’s on disk. The paper says: <em>“Before responding to RPCs, a server must update its persistent state.”</em></p>

<p>But in real implementations with in-memory and persisted state split, this requirement needs to be more precise:</p>

<p><strong>Before returning success, we must ensure all IOs that make <code class="language-plaintext highlighter-rouge">persisted_term &gt;= req.term</code> have completed.</strong></p>

<p>Checking only <code class="language-plaintext highlighter-rouge">current_term</code> creates a window where we might respond successfully while the required disk updates are still in flight. If those updates can complete out of order, we’ve violated Raft’s safety guarantees.</p>

<h2 id="how-to-fix-it-check-persisted-state-not-in-memory-state">How to Fix It: Check Persisted State, Not In-Memory State</h2>

<p>If you need to support IO reordering, the fix is conceptually simple—check the on-disk term, not the in-memory term:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">handle_append_entries</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">req</span><span class="p">:</span> <span class="n">AppendEntries</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Check against disk state, not memory!</span>
    <span class="k">let</span> <span class="n">need_save_term</span> <span class="o">=</span> <span class="n">req</span><span class="py">.term</span> <span class="o">&gt;</span> <span class="k">self</span><span class="py">.persisted_term</span><span class="p">;</span>

    <span class="k">if</span> <span class="n">need_save_term</span> <span class="p">{</span>
        <span class="k">self</span><span class="py">.current_term</span> <span class="o">=</span> <span class="n">req</span><span class="py">.term</span><span class="p">;</span>
        <span class="k">self</span><span class="nf">.submit_io</span><span class="p">(</span><span class="nf">save_term</span><span class="p">(</span><span class="n">req</span><span class="py">.term</span><span class="p">));</span>
    <span class="p">}</span>

    <span class="k">self</span><span class="nf">.submit_io</span><span class="p">(</span><span class="nf">save_entries</span><span class="p">(</span><span class="n">req</span><span class="py">.entries</span><span class="p">));</span>

    <span class="c1">// Wait for the right IOs based on what we actually need</span>
    <span class="k">if</span> <span class="n">need_save_term</span> <span class="p">{</span>
        <span class="nf">wait_for_both_ios</span><span class="p">();</span>  <span class="c1">// Must wait for term update to complete</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
        <span class="nf">wait_for_io</span><span class="p">(</span><span class="n">save_entries</span><span class="p">);</span>  <span class="c1">// Only need to wait for entries</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nf">success</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>By checking <code class="language-plaintext highlighter-rouge">persisted_term</code> instead of <code class="language-plaintext highlighter-rouge">current_term</code>, we correctly detect when the term IO is still in flight and wait for it to complete.</p>

<p><strong>Caveat</strong>: This approach might submit multiple <code class="language-plaintext highlighter-rouge">save_term(T)</code> IOs for the same term T (if multiple AppendEntries arrive in quick succession). You’ll need to handle this carefully—either make the IO layer idempotent or add deduplication logic.</p>

<h2 id="how-production-systems-solve-this">How Production Systems Solve This</h2>

<p>Here’s the interesting part: most mature Raft implementations don’t actually support IO reordering. Instead, they eliminate the problem entirely by ensuring save-term and save-entries execute in order. This lets them safely check <code class="language-plaintext highlighter-rouge">current_term</code> without the bug we just analyzed.</p>

<p>Let’s look at three different approaches from production systems:</p>

<h3 id="1-atomic-batching-tikv">1. Atomic Batching (TiKV)</h3>

<p><strong>Strategy</strong>: Bundle save-term and save-entries into a single atomic IO operation.</p>

<p>When an AppendEntries requires both a term update and log writes, TiKV combines them into one batch and submits it as a single IO request. This makes it impossible for the entries to persist without the term—they’re literally the same operation.</p>

<p>This elegantly sidesteps the entire reordering problem. There’s no “second AppendEntries that only submits save_entries” scenario because term and entries are always written together.</p>

<h3 id="2-ordered-separation-hashicorp-raft">2. Ordered Separation (HashiCorp Raft)</h3>

<p><strong>Strategy</strong>: Persist term and entries separately, but enforce strict ordering.</p>

<p>HashiCorp’s Raft implementation writes the term first (with fsync, panicking on failure), then writes the log entries. The key is that these operations execute sequentially—save_entries can’t start until save_term completes.</p>

<p>This guarantees that if entries reach disk, the term has definitely reached disk first. Sequential ordering prevents the reordering bug.</p>

<h3 id="3-hybrid-ordering-sofajraft">3. Hybrid Ordering (SOFAJRaft)</h3>

<p><strong>Strategy</strong>: Synchronous term writes, asynchronous batched log writes.</p>

<p>SOFAJRaft writes the term synchronously (blocking the current thread for fsync) but batches log entries for asynchronous writing. The crucial property: save_term always completes before save_entries is even enqueued.</p>

<p>This hybrid approach gets you most of the performance benefits of async IO while maintaining the ordering guarantee that prevents the bug.</p>

<h2 id="summary-bridging-theory-and-practice">Summary: Bridging Theory and Practice</h2>

<p>The IO ordering bug in Raft implementations stems from a subtle gap between the paper’s abstract model and real-world code. The Raft paper assumes a single state: what’s on disk. Real implementations optimize with in-memory and persisted state split, introducing behaviors the paper never analyzed.</p>

<p><strong>The invariant we must maintain</strong>:</p>

<blockquote>
  <p>If a log entry with term=T is on disk, then persisted_term ≥ T must also be on disk.</p>
</blockquote>

<p>Violating this invariant—having entries from term T on disk while <code class="language-plaintext highlighter-rouge">persisted_term &lt; T</code>—breaks Raft’s safety guarantees and can cause committed data loss.</p>

<p><strong>Two ways to maintain the invariant</strong>:</p>

<ol>
  <li>
    <p><strong>Eliminate IO reordering</strong> (mainstream approach)</p>

    <ul>
      <li>Atomic batching: Write term and entries together</li>
      <li>Ordered execution: Guarantee term persists before entries</li>
      <li>Hybrid ordering: Synchronous term, async entries</li>
    </ul>
  </li>
  <li>
    <p><strong>Handle IO reordering explicitly</strong></p>

    <ul>
      <li>Check <code class="language-plaintext highlighter-rouge">persisted_term</code> instead of <code class="language-plaintext highlighter-rouge">current_term</code> when deciding whether to persist the term</li>
      <li>Wait for all required IOs to complete before responding</li>
    </ul>
  </li>
</ol>

<p>Most production systems choose option 1—it’s simpler to reason about and avoids the complexity of tracking multiple in-flight term updates. But if you do need to support IO reordering, now you know where the dragons are hiding.</p>

<h2 id="related-resources">Related Resources</h2>

<ul>
  <li><a href="https://blog.openacid.com/algo/raft-io-order/">The Hidden Danger in Raft: Why IO Ordering Matters</a></li>
  <li><a href="https://github.com/databendlabs/openraft/blob/main/openraft/src/docs/protocol/io_ordering.md">OpenRaft docs: io-ordering</a></li>
  <li><a href="https://github.com/tikv/tikv">tikv/tikv</a></li>
  <li><a href="https://github.com/hashicorp/raft">hashicorp/raft</a></li>
  <li><a href="https://github.com/sofastack/sofa-jraft">sofastack/sofa-jraft</a></li>
</ul>

<p>Reference:</p>

<ul>
  <li>
    <p>OpenRaft docs: io-ordering : <a href="https://github.com/databendlabs/openraft/blob/main/openraft/src/docs/protocol/io_ordering.md">https://github.com/databendlabs/openraft/blob/main/openraft/src/docs/protocol/io_ordering.md</a></p>
  </li>
  <li>
    <p>hashicorp/raft : <a href="https://github.com/hashicorp/raft">https://github.com/hashicorp/raft</a></p>
  </li>
  <li>
    <p>The Hidden Danger in Raft Why IO Ordering Matters : <a href="https://blog.openacid.com/algo/raft-io-order/">https://blog.openacid.com/algo/raft-io-order/</a></p>
  </li>
  <li>
    <p>sofastack/sofa-jraft : <a href="https://github.com/sofastack/sofa-jraft">https://github.com/sofastack/sofa-jraft</a></p>
  </li>
  <li>
    <p>tikv/tikv : <a href="https://github.com/tikv/tikv">https://github.com/tikv/tikv</a></p>
  </li>
</ul>]]></content><author><name>Zhang Yanpo (drdr.xp)</name></author><category term="algo" /><category term="distributed" /><category term="raft" /><category term="cn" /><summary type="html"><![CDATA[I got it wrong in my previous article. The IO ordering bug in Raft isn't about the protocol design—it's about the subtle trap that emerges when implementations split state into in-memory and persisted state. Here's what actually happens.]]></summary></entry></feed>