<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: SACHIN INDWAR</title>
    <description>The latest articles on DEV Community by SACHIN INDWAR (@sachin-680).</description>
    <link>https://dev.to/sachin-680</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2225598%2F8cdbbcd6-8dfd-46e0-b399-2c6bd14bded9.jpg</url>
      <title>DEV Community: SACHIN INDWAR</title>
      <link>https://dev.to/sachin-680</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sachin-680"/>
    <language>en</language>
    <item>
      <title>Three Raft bugs that only existed once I added log compaction</title>
      <dc:creator>SACHIN INDWAR</dc:creator>
      <pubDate>Tue, 13 Oct 2026 17:47:07 +0000</pubDate>
      <link>https://dev.to/sachin-680/three-raft-bugs-that-only-existed-once-i-added-log-compaction-3ge8</link>
      <guid>https://dev.to/sachin-680/three-raft-bugs-that-only-existed-once-i-added-log-compaction-3ge8</guid>
      <description>&lt;p&gt;I implemented Raft from the paper in Go — leader election, log replication, snapshotting, joint-consensus membership changes — with a linearizable key/value store on top.&lt;/p&gt;

&lt;p&gt;The difficult part wasn't implementing the happy path. It was finding the defects that only became reachable once log compaction removed the redundancy that had been hiding them.&lt;/p&gt;

&lt;p&gt;Adding snapshotting exposed three real bugs. A deterministic simulator I had built beforehand caught the subtlest one and pinned it to a single seed — &lt;strong&gt;seed 188&lt;/strong&gt; — which then reproduced it exactly throughout the diagnosis. The other two were caught by a storage conformance suite and a restart test. This post walks through each one, from trigger to regression test, along with the false positive I refused to count.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing" rel="noopener noreferrer"&gt;sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why ordinary tests miss this class of bug
&lt;/h2&gt;

&lt;p&gt;Raft's correctness depends on the &lt;em&gt;interleaving&lt;/em&gt; — which message arrives first, which is lost, whose timer fires first. An integration test samples whatever interleaving the OS scheduler produces on a fast, healthy machine. The orderings where consensus breaks — say, a message from long ago arriving &lt;em&gt;after&lt;/em&gt; the entries it refers to have been compacted away — almost never come up that way. And when one does, it's gone before you can look at it.&lt;/p&gt;

&lt;p&gt;Deterministic simulation testing, the approach FoundationDB and TigerBeetle are known for, attacks both problems: run the whole cluster on one thread, draw every source of nondeterminism from one seeded random source, and a run becomes repeatable from its seed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Determinism has to be designed in
&lt;/h2&gt;

&lt;p&gt;In Go, that only works if you control time, network, disk — and the goroutine scheduler. The scheduler forces the architecture: if consensus logic is spread across goroutines talking over channels, no virtual clock will make it replayable.&lt;/p&gt;

&lt;p&gt;So the Raft core is a pure, synchronous state machine — no clock, no goroutines, no channels, no I/O:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;RawNode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Tick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;RawNode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;RawNode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Propose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;RawNode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Messages&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;RawNode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Applied&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;LogEntry&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Everything nondeterministic sits behind an interface, and the simulator swaps it out:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F43h4g8yy3h8vnz4g6ho2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F43h4g8yy3h8vnz4g6ho2.png" alt="The same Raft core runs against bbolt, gRPC and time.Now in production, and against an in-memory map, an event queue and a virtual clock in the simulator" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Production&lt;/th&gt;
&lt;th&gt;Simulation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;time.Now&lt;/code&gt; / timers&lt;/td&gt;
&lt;td&gt;virtual clock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network&lt;/td&gt;
&lt;td&gt;gRPC&lt;/td&gt;
&lt;td&gt;in-memory queue with fault injection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk&lt;/td&gt;
&lt;td&gt;bbolt&lt;/td&gt;
&lt;td&gt;in-memory map&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduling&lt;/td&gt;
&lt;td&gt;one goroutine per node&lt;/td&gt;
&lt;td&gt;one single-threaded event loop&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A CI script fails the build if a clock, goroutine, channel, mutex or package-level &lt;code&gt;math/rand&lt;/code&gt; call appears inside the core, because this property degrades silently.&lt;/p&gt;

&lt;p&gt;Note the trade-off in that table, because it matters later: &lt;strong&gt;the simulator runs the real consensus core, but not bbolt and not the node driver.&lt;/strong&gt; It can only find bugs in the code it actually executes.&lt;/p&gt;
&lt;h3&gt;
  
  
  The four lines determinism depended on
&lt;/h3&gt;

&lt;p&gt;The event loop is a priority queue ordered by virtual time, and simultaneous events are routine — every node's first tick is at t=0. Go's &lt;code&gt;container/heap&lt;/code&gt; makes no promise about the order of equal elements, so without a tiebreaker the same seed could execute differently on different runs. Every event gets an insertion sequence number:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;eventQueue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Less&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seq&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;(The other trap is Go's randomized map iteration, which can't be caught statically. Every peer list in the core is a sorted slice, and a test runs each seed twice and compares a hash of the full event trace.)&lt;/p&gt;
&lt;h2&gt;
  
  
  What the simulator checks
&lt;/h2&gt;

&lt;p&gt;Faults drawn from the seed: dropped, duplicated, delayed and reordered messages; partitions and heals; crashes and restarts. After &lt;strong&gt;every event&lt;/strong&gt;: at most one leader per term, no committed entry ever changes, the commit index never decreases, applied never exceeds committed. At the end of a run: logs match pairwise, and no two nodes applied different commands at the same index.&lt;/p&gt;

&lt;p&gt;A seed reproduces its run. Here's the same seed three times, recorded on a Linux box, not my laptop:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb0f064zxigen16tmqb3d.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb0f064zxigen16tmqb3d.gif" alt="Terminal recording: seed 2 run three times produces the same trace hash, then a disk-loss run fails with the same safety violation" width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;simctl run &lt;span class="nt"&gt;--seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="go"&gt;seed 2: ok — 169359 events in 246.926ms (30000ms virtual) ... trace 26c664211d16bfc0
seed 2: ok — 169359 events in 254.286ms (30000ms virtual) ... trace 26c664211d16bfc0
seed 2: ok — 169359 events in 247.318ms (30000ms virtual) ... trace 26c664211d16bfc0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Only the wall-clock time changes. To be precise about how far that's been verified: the same trace hash appears in the repo's docs, recorded on my development machine with a different Go minor version. That's two machines and two toolchains for this seed, plus CI replaying failures — not a proof that every build on every platform agrees.&lt;/p&gt;

&lt;p&gt;The full campaign, from a fresh run whose raw output is &lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing/blob/main/docs/campaigns/fuzz-10k.txt" rel="noopener noreferrer"&gt;committed to the repo&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;{{CAMPAIGN_OUTPUT}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  The three bugs
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Bug 1: a late message mistaken for an attack on the committed log
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it was found:&lt;/strong&gt; the simulator, seed 188.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trigger.&lt;/strong&gt; The leader sends an &lt;code&gt;AppendEntries&lt;/code&gt; starting near the beginning of the log. The network delays it. Meanwhile the follower receives newer appends, commits past them, takes a snapshot and compacts the old entries away. Then the stale message finally arrives.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuvmpor2egklnf8jjl3yb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuvmpor2egklnf8jjl3yb.png" alt="Timeline: the leader sends an append starting at index 1, it is delayed, the follower commits to 57, snapshots and compacts old entries, then the stale append arrives and is wrongly treated as a conflict" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause.&lt;/strong&gt; The follower ran its normal conflict scan on the stale message. The scan compares each incoming entry's term with the term the follower holds at that index — but those indices had been compacted. An entry the follower simply &lt;em&gt;no longer had&lt;/em&gt; was reported as &lt;em&gt;conflicting&lt;/em&gt;, and the node refused the append as an attempt to overwrite its committed prefix.&lt;/p&gt;

&lt;p&gt;Before compaction this code was correct: the old entries were still there, the scan verified them, and found no conflict.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consequence.&lt;/strong&gt; The follower raised a safety error on a perfectly legitimate, merely late, message. To check how rare the trigger is, I removed the fix and re-ran the campaign:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;simctl fuzz &lt;span class="nt"&gt;--count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;300 &lt;span class="nt"&gt;--max-failures&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0      &lt;span class="c"&gt;# fix removed&lt;/span&gt;
&lt;span class="go"&gt;ran 300 seeds in 1m27.659s (3 seeds/sec, 364034 entries committed, 48795440 events)
1 failing seed(s):
  seed 188: sim: step append-req on node 1: raft: leader entry at index 1
            conflicts with committed index 57: raft: safety violation
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;One seed out of 300.&lt;/strong&gt; It needs a log long enough to compact &lt;em&gt;and&lt;/em&gt; a message delayed long enough to span the compaction. That seed reproduced it identically on every run, including after I fixed the wrong thing first and had to go back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; A guard before the scan (&lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing/blob/aca8699ad4e5061ddf05e9edc6ee72d949d699cf/raft/replication.go#L153-L174" rel="noopener noreferrer"&gt;&lt;code&gt;raft/replication.go&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PrevLogIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;committed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MsgAppendResp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;To&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Success&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MatchIndex&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;committed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReadID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Why ignoring the entries is safe.&lt;/strong&gt; It looks alarming to return without processing the message, so here's the invariant:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Messages from older terms are rejected before this handler runs, so this one comes from the current leader.&lt;/li&gt;
&lt;li&gt;Everything at or below the follower's commit index is committed. By Raft's Leader Completeness and Log Matching properties, the current leader's log agrees with ours at every one of those indices. There is nothing to verify and nothing to change.&lt;/li&gt;
&lt;li&gt;The message may also carry entries &lt;em&gt;above&lt;/em&gt; our commit index. We drop them — but the reply says &lt;code&gt;MatchIndex: committed&lt;/code&gt;, not "everything you sent". We never acknowledge an entry we don't hold, so the leader can't count us toward committing it.&lt;/li&gt;
&lt;li&gt;The leader only ever moves its record of our progress &lt;em&gt;forward&lt;/em&gt; (&lt;code&gt;if m.MatchIndex &amp;gt; pr.Match&lt;/code&gt;), so a low-but-true answer can't rewind it. It resends the later entries on the next round.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the cost of the early return is at most one extra round trip, and it can't affect safety.&lt;/p&gt;

&lt;p&gt;The same change also floors the leader's rejection backoff at the follower's confirmed match index. A delayed &lt;em&gt;rejection&lt;/em&gt; could otherwise make the leader resend entries the follower had already committed. That wasn't the cause of seed 188, but it's the same family: a late message acting on a world that has moved on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regression tests.&lt;/strong&gt; &lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing/blob/aca8699ad4e5061ddf05e9edc6ee72d949d699cf/raft/replication_test.go#L560" rel="noopener noreferrer"&gt;&lt;code&gt;TestStaleAppendBelowCommitIndexIsAnswered&lt;/code&gt;&lt;/a&gt; builds the exact sequence — append 60 entries, commit 58, snapshot and compact, then deliver a stale append from index 1 — and asserts it's answered with success, &lt;code&gt;MatchIndex&lt;/code&gt; 58, and an unchanged commit index. &lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing/blob/aca8699ad4e5061ddf05e9edc6ee72d949d699cf/raft/replication_test.go#L504" rel="noopener noreferrer"&gt;&lt;code&gt;TestStaleRejectionCannotRewindBelowMatch&lt;/code&gt;&lt;/a&gt; covers the delayed rejection. Seed 188 runs in the campaign above.&lt;/p&gt;
&lt;h3&gt;
  
  
  Bug 2: the log grew forever behind a snapshot that claimed to shorten it
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it was found:&lt;/strong&gt; the storage conformance suite, which runs the same tests against the in-memory store and bbolt. The simulator couldn't have found this one — it never runs bbolt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trigger.&lt;/strong&gt; A leader takes a snapshot but deliberately keeps a tail of entries past the snapshot point, so a follower that's one entry behind can be repaired with entries instead of the whole state machine. Later it compacts up to an index &lt;em&gt;below&lt;/em&gt; the snapshot boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause.&lt;/strong&gt; The bbolt store used one field for two different things: the &lt;strong&gt;log floor&lt;/strong&gt; (the oldest entry still answerable) and the &lt;strong&gt;snapshot boundary&lt;/strong&gt;. They're equal right up until a leader keeps a tail. Once they diverged, &lt;code&gt;Compact&lt;/code&gt; saw an index at or below its "floor", concluded it had already run, and returned.&lt;/p&gt;

&lt;p&gt;The fix was to give them separate fields (&lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing/blob/aca8699ad4e5061ddf05e9edc6ee72d949d699cf/storage/bolt.go#L75-L93" rel="noopener noreferrer"&gt;&lt;code&gt;storage/bolt.go&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// snapIndex and snapTerm describe the newest snapshot.&lt;/span&gt;
&lt;span class="n"&gt;snapIndex&lt;/span&gt; &lt;span class="n"&gt;raft&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Index&lt;/span&gt;
&lt;span class="n"&gt;snapTerm&lt;/span&gt;  &lt;span class="n"&gt;raft&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Term&lt;/span&gt;

&lt;span class="c"&gt;// floorIndex and floorTerm describe the oldest entry still answerable:&lt;/span&gt;
&lt;span class="c"&gt;// the last one discarded by compaction. Separate from the snapshot —&lt;/span&gt;
&lt;span class="c"&gt;// the floor sits *below* the snapshot boundary when a tail is kept.&lt;/span&gt;
&lt;span class="n"&gt;floorIndex&lt;/span&gt; &lt;span class="n"&gt;raft&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Index&lt;/span&gt;
&lt;span class="n"&gt;floorTerm&lt;/span&gt;  &lt;span class="n"&gt;raft&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Term&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;Compact&lt;/code&gt; now short-circuits only when &lt;code&gt;upto &amp;lt;= s.floorIndex&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consequence.&lt;/strong&gt; No error, no failed assertion — just a log that never got shorter, behind a snapshot that said it had been compacted. In production, a disk-space leak you notice days later. Any test that doesn't specifically check "did the log actually get shorter?" passes straight through it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regression test.&lt;/strong&gt; &lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing/blob/aca8699ad4e5061ddf05e9edc6ee72d949d699cf/storage/conformance_test.go#L407" rel="noopener noreferrer"&gt;&lt;code&gt;TestCompactBelowTheSnapshotBoundaryStillWorks&lt;/code&gt;&lt;/a&gt;: append 60 entries, save a snapshot at 60, compact to a lower index, and assert the entries below it are actually gone — against both storage implementations.&lt;/p&gt;
&lt;h3&gt;
  
  
  Bug 3: a restarted node came back with a fraction of its data — and reported healthy
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it was found:&lt;/strong&gt; while making the &lt;em&gt;simulator's&lt;/em&gt; model of a restarting node correct (more on that below). The real defect lives in the node driver, which the simulator doesn't run, so it's covered by a cluster test rather than a seed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trigger.&lt;/strong&gt; A node that has taken a snapshot and compacted its log restarts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause.&lt;/strong&gt; On startup, the node resumed applying entries from its persisted applied index — but never rebuilt its state machine from its own snapshot first. Without compaction this was invisible: the whole log was still there, so replay produced the right state anyway. Wrong code path, right outcome, for exactly as long as nothing was ever discarded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Restore from the node's own snapshot before the run loop starts, so nothing can observe a half-built state machine (&lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing/blob/aca8699ad4e5061ddf05e9edc6ee72d949d699cf/node/node.go#L442-L467" rel="noopener noreferrer"&gt;&lt;code&gt;node/node.go&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;restoreFromDisk&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InitialState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"node: reading initial state: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;snap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsEmpty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// ... the state machine must support snapshots, or the node cannot recover&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;snapshotter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Restore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;snap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastIncludedIndex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"node: restoring snapshot at index %d: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;snap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastIncludedIndex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Consequence.&lt;/strong&gt; Once compaction existed, the discarded entries were gone. The node came back, answered requests, voted in elections — and held the wrong data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regression test.&lt;/strong&gt; &lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing/blob/aca8699ad4e5061ddf05e9edc6ee72d949d699cf/node/cluster_test.go#L795" rel="noopener noreferrer"&gt;&lt;code&gt;TestNodeRestoresFromItsOwnSnapshot&lt;/code&gt;&lt;/a&gt; runs a real cluster with a low snapshot threshold, writes 60 keys, restarts the leader, and asserts all 60 keys are still there.&lt;/p&gt;
&lt;h2&gt;
  
  
  The finding I didn't count
&lt;/h2&gt;

&lt;p&gt;The first snapshot-enabled fuzz run reported eleven seeds with state machine divergence. That looked like eleven more bugs.&lt;/p&gt;

&lt;p&gt;It wasn't. The simulator's own model restarted a node with an empty state machine — the same mistake as bug 3, but in the test harness — so two correct nodes legitimately had histories starting at different points. The checker compared them by list position and called it divergence.&lt;/p&gt;

&lt;p&gt;I fixed it twice over: the simulated node now restores from its snapshot, and the checker compares by &lt;strong&gt;log index&lt;/strong&gt; instead of position, which states the property that actually matters — no two nodes disagree about what's at index &lt;em&gt;i&lt;/em&gt; — without caring where each history begins.&lt;/p&gt;

&lt;p&gt;A failing run means &lt;em&gt;something disagrees&lt;/em&gt;, not &lt;em&gt;the code is wrong&lt;/em&gt;. Counting your tool's bugs as findings poisons every future result.&lt;/p&gt;
&lt;h2&gt;
  
  
  How I know the checker isn't just quiet
&lt;/h2&gt;

&lt;p&gt;A campaign with zero violations looks exactly like a broken checker. So there's a negative control.&lt;/p&gt;

&lt;p&gt;Raft assumes a node's term, vote and log survive a crash. &lt;code&gt;--disk-loss=0.5&lt;/code&gt; deliberately breaks that: a restarting node loses its persisted state half the time. That &lt;strong&gt;must&lt;/strong&gt; break safety somewhere:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;simctl fuzz &lt;span class="nt"&gt;--count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;41 &lt;span class="nt"&gt;--disk-loss&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.5 &lt;span class="nt"&gt;--max-failures&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="go"&gt;ran 41 seeds in 10.686s (4 seeds/sec, 31781 entries committed, 5206831 events)
23 failing seed(s):
  seed 1: safety violation [committed-entries-never-change] ...
  seed 6: safety violation [state-machines-agree] ...
&lt;/span&gt;&lt;span class="c"&gt;  ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;23 of 41 seeds fail: 15 because a committed entry was later replaced, 8 because two nodes applied different entries at the same index. Each run stops at its first violation, so these are the &lt;em&gt;first&lt;/em&gt; properties broken. Any one of them reproduces exactly from its seed:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;simctl run &lt;span class="nt"&gt;--seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;27 &lt;span class="nt"&gt;--disk-loss&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.5
&lt;span class="go"&gt;safety violation [committed-entries-never-change] at t=26106ms (seed 27):
  committed index 119 changed: node 1 committed term 1 (8488d043) at t=2520ms,
  node 3 now has term 3 (c38200e5)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The unit tests get the same treatment: 22 seeded mutations, each a plausible mistake paired with the test that should catch it. That check found &lt;strong&gt;three tests that could not fail&lt;/strong&gt; — one measured nothing at all, and two pre-vote tests passed whether or not the bug they were named after was present.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the evidence does and doesn't establish
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Evidence&lt;/th&gt;
&lt;th&gt;Establishes&lt;/th&gt;
&lt;th&gt;Does not establish&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10,000 seeded runs, no violations&lt;/td&gt;
&lt;td&gt;No checked property was violated in those executions, under the simulator's fault model&lt;/td&gt;
&lt;td&gt;That this Raft is correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identical trace hashes&lt;/td&gt;
&lt;td&gt;Repeatability for the tested seeds on the machines and toolchains tried&lt;/td&gt;
&lt;td&gt;Agreement across every build and platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22/22 seeded mutations caught&lt;/td&gt;
&lt;td&gt;Those specific mistakes are detected by their tests&lt;/td&gt;
&lt;td&gt;Complete mutation coverage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk-loss negative control&lt;/td&gt;
&lt;td&gt;The checker detects at least this class of injected failure&lt;/td&gt;
&lt;td&gt;Detection of every possible safety defect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-cluster chaos + linearizability checks&lt;/td&gt;
&lt;td&gt;The recorded client histories satisfy the model&lt;/td&gt;
&lt;td&gt;Correctness under all workloads and faults&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row covers the simulator's blind spot. Because it replaces gRPC, bbolt and the Go scheduler, a separate rig runs a real cluster in containers with &lt;code&gt;tc&lt;/code&gt;/&lt;code&gt;netem&lt;/code&gt; packet loss and &lt;code&gt;iptables&lt;/code&gt; partitions, records every client operation, and checks the history is linearizable with &lt;a href="https://github.com/anishathalye/porcupine" rel="noopener noreferrer"&gt;Porcupine&lt;/a&gt;. Those runs can't be replayed, but they exercise exactly what the simulator stubs out — which, as bugs 2 and 3 show, is where real defects also live.&lt;/p&gt;
&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;The biggest lesson wasn't that a simulator found a bug. It was that all three bugs had been hidden by the absence of a feature.&lt;/p&gt;

&lt;p&gt;Before compaction, replaying the log masked a broken restart path. Before the log floor diverged from the snapshot boundary, a storage bug stayed dormant. Before a delayed message could cross a compaction boundary, the conflict check behaved correctly.&lt;/p&gt;

&lt;p&gt;Adding the feature removed those safety nets. Because the core was deterministic, the subtlest failure became a reproducible sequence of events — seed 188 — rather than an intermittent symptom. And because the simulator can't see everything, the conformance suite and restart test caught what it couldn't.&lt;/p&gt;

&lt;p&gt;That's why I now treat the testing architecture as part of the system design, not something added after implementation.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make fuzz &lt;span class="nv"&gt;FUZZ_SEEDS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10000                     &lt;span class="c"&gt;# the campaign&lt;/span&gt;
make replay &lt;span class="nv"&gt;SEED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2                             &lt;span class="c"&gt;# one execution, with its trace&lt;/span&gt;
make mutation                                  &lt;span class="c"&gt;# the 22 seeded mutations&lt;/span&gt;
bin/simctl fuzz &lt;span class="nt"&gt;--count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;41 &lt;span class="nt"&gt;--disk-loss&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.5 &lt;span class="nt"&gt;--max-failures&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0   &lt;span class="c"&gt;# the negative control&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/sAchin-680" rel="noopener noreferrer"&gt;
        sAchin-680
      &lt;/a&gt; / &lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing" rel="noopener noreferrer"&gt;
        Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A Raft consensus implementation in Go built from scratch using real HTTP communication between processes to demonstrate leader election, heartbeats, and distributed systems fundamentals.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Distributed KV Store with Raft + Deterministic Simulation Testing&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;A linearizable key/value store on hand-written Raft — verified by a deterministic simulator that replays any failure from a single integer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing/actions/workflows/ci.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing/actions/workflows/ci.yml/badge.svg" alt="CI"&gt;&lt;/a&gt;
&lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing/actions/workflows/cd.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing/actions/workflows/cd.yml/badge.svg" alt="CD"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/71f580befacf98a81ad18c2a2001fecdf62391306f0ded2c94d6692d6f99d977/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f676f2d312e32352d3030414444383f6c6f676f3d676f266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/71f580befacf98a81ad18c2a2001fecdf62391306f0ded2c94d6692d6f99d977/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f676f2d312e32352d3030414444383f6c6f676f3d676f266c6f676f436f6c6f723d7768697465" alt="Go"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/2925ce87d0e78df23b9c5320b55682ecf3e28915296721b1dc0db988cdbf3503/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6b756265726e657465732d537461746566756c5365742d3332364345353f6c6f676f3d6b756265726e65746573266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/2925ce87d0e78df23b9c5320b55682ecf3e28915296721b1dc0db988cdbf3503/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6b756265726e657465732d537461746566756c5365742d3332364345353f6c6f676f3d6b756265726e65746573266c6f676f436f6c6f723d7768697465" alt="Kubernetes"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/731f659361d567e7b2169b0e4340c389eacc59a091f2400fd66b5fc494eceba5/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7465727261666f726d2d312e352d3742343242433f6c6f676f3d7465727261666f726d266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/731f659361d567e7b2169b0e4340c389eacc59a091f2400fd66b5fc494eceba5/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7465727261666f726d2d312e352d3742343242433f6c6f676f3d7465727261666f726d266c6f676f436f6c6f723d7768697465" alt="Terraform"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/b8cadaa967891081f8f165695470689986c028821dd8a040132f6e661795dc0d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c7565"&gt;&lt;img src="https://camo.githubusercontent.com/b8cadaa967891081f8f165695470689986c028821dd8a040132f6e661795dc0d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c7565" alt="License"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing#overview" rel="noopener noreferrer"&gt;Overview&lt;/a&gt; · &lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing#system-design" rel="noopener noreferrer"&gt;System design&lt;/a&gt; · &lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing#verification" rel="noopener noreferrer"&gt;Verification&lt;/a&gt; · &lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing#performance" rel="noopener noreferrer"&gt;Performance&lt;/a&gt; · &lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing#production-operations" rel="noopener noreferrer"&gt;Operations&lt;/a&gt; · &lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing#getting-started" rel="noopener noreferrer"&gt;Getting started&lt;/a&gt; · &lt;a href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing#documentation" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Overview&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Raft implemented from the paper — leader election, log replication, snapshotting and joint-consensus membership changes — with a linearizable key/value store on top.&lt;/p&gt;
&lt;p&gt;The part that matters is what surrounds it. A &lt;strong&gt;deterministic simulation framework&lt;/strong&gt; controls time and the network, injects partitions and crashes from a seeded random source, and reproduces any execution exactly. When a randomized run finds a safety violation it prints one number, and that number is enough for anyone to replay the identical failure, byte for byte, indefinitely.&lt;/p&gt;
&lt;p&gt;Everything below was measured, not estimated.&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Simulation campaign&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;10,000 seeds&lt;/strong&gt;, 1.7 billion events, 83 simulated hours in under 4 minutes, &lt;strong&gt;0&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/sAchin-680/Distributed-KV-Store-with-Raft-Deterministic-Simulation-Testing" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Next in this series: why my alert for leader elections uses a threshold &lt;em&gt;derived from cluster size&lt;/em&gt;, and how a rolling upgrade showed the old constant was in the worst possible place.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Sachin, an SRE / platform engineer working on observability, Kubernetes and distributed systems — currently open to new roles. &lt;a href="https://www.linkedin.com/in/sachinindwar" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/sAchin-680" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>distributedsystems</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Tracing a 3x Memory Blow-Up in Grafana's Time Comparison</title>
      <dc:creator>SACHIN INDWAR</dc:creator>
      <pubDate>Fri, 07 Aug 2026 15:15:46 +0000</pubDate>
      <link>https://dev.to/sachin-680/tracing-a-3x-memory-blow-up-in-grafanas-time-comparison-2jpn</link>
      <guid>https://dev.to/sachin-680/tracing-a-3x-memory-blow-up-in-grafanas-time-comparison-2jpn</guid>
      <description>&lt;p&gt;While contributing to Grafana, I picked up a memory issue in the &lt;strong&gt;Time Comparison&lt;/strong&gt; feature — a follow-up to &lt;a href="https://github.com/grafana/grafana/pull/129681" rel="noopener noreferrer"&gt;earlier performance work&lt;/a&gt; I had done in the same area.&lt;/p&gt;

&lt;p&gt;A comparison panel was consuming significantly more memory than expected. The interesting part: the extra memory wasn't coming from real data.&lt;/p&gt;

&lt;p&gt;This post covers how I traced it to the root cause and fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;Time Comparison overlays an earlier period onto the current one — for example, this week vs. last week. The comparison data is fetched from the earlier window and &lt;strong&gt;shifted forward&lt;/strong&gt; before rendering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Query → DataFrame → Prepare frame → Shift → Render
                         │
                         └─ Gap filling
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The important detail: &lt;strong&gt;gap filling ran before the comparison frame was shifted.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I reproduced the issue with:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Series&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Window&lt;/td&gt;
&lt;td&gt;6h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interval&lt;/td&gt;
&lt;td&gt;20s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compare offset&lt;/td&gt;
&lt;td&gt;24h&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A single-period panel contained roughly &lt;strong&gt;540,000 points&lt;/strong&gt;, so a comparison panel should be about &lt;strong&gt;2× the baseline&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Instead, the compare frame contained &lt;strong&gt;3,240,500 points — ~6× the baseline&lt;/strong&gt; — and consumed &lt;strong&gt;76.4 MB&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The question was: &lt;strong&gt;where did the extra points come from?&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Investigation
&lt;/h2&gt;

&lt;p&gt;I first verified the baseline to rule out the query returning unexpected data. It was correct.&lt;/p&gt;

&lt;p&gt;Then I used a reproducible browser harness and a &lt;strong&gt;heap snapshot&lt;/strong&gt; to inspect the extra memory. Most of it was &lt;strong&gt;null rows introduced during gap filling&lt;/strong&gt; — not real samples, not copies.&lt;/p&gt;

&lt;p&gt;Following the frame through the preparation pipeline revealed why. When gap filling ran, the compare frame still represented data &lt;strong&gt;24 hours in the past&lt;/strong&gt;, but the gap-filler was using the &lt;strong&gt;current time range&lt;/strong&gt; as its reference:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   Compare frame                         Current range
   [===== 6h =====]                      [===== 6h =====]
          └───────────────  24h  ───────────────┘
                gap-filler reads this offset as one gap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;At a 20-second interval, 24 hours is:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;24 × 60 × 60 / 20 = 4,320 intervals
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;So up to &lt;strong&gt;4,320 null positions per series&lt;/strong&gt; were introduced purely because the frame hadn't been shifted yet. The frame was then shifted forward, leaving most of that padding outside the visible range — where nothing draws it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Root Cause
&lt;/h2&gt;

&lt;p&gt;The issue was &lt;strong&gt;the reference range gap filling used&lt;/strong&gt;. It evaluated the compare frame against the &lt;em&gt;current&lt;/em&gt; range instead of the range the frame actually represented:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before                         After
──────                         ─────
Compare frame                  Compare frame
     ↓                              ↓
Gap-fill vs current range      Use frame's effective range
     ↓                              ↓
Shift by 24h                   Gap-fill
                                    ↓
                               Shift by 24h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;I changed the gap-filling logic to account for the compare frame's own offset when determining its effective time range. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;genuine gaps are still filled&lt;/li&gt;
&lt;li&gt;non-comparison frames are unchanged&lt;/li&gt;
&lt;li&gt;the unnecessary null padding is eliminated&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Compare points&lt;/td&gt;
&lt;td&gt;3,240,500&lt;/td&gt;
&lt;td&gt;1,081,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compare heap&lt;/td&gt;
&lt;td&gt;76.4 MB&lt;/td&gt;
&lt;td&gt;22.8 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12× dashboard heap&lt;/td&gt;
&lt;td&gt;822.5 MB&lt;/td&gt;
&lt;td&gt;225.0 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The compare frame went from roughly &lt;strong&gt;6× to 2× the baseline&lt;/strong&gt; — consistent with the actual amount of data being rendered. I also verified it in the browser: the reproduced panel dropped from &lt;strong&gt;144.2 MB to 84.4 MB&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Performance bugs can be data-flow bugs.&lt;/strong&gt; The problem wasn't an expensive algorithm — it was a correct operation running against the wrong state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Profile before optimizing.&lt;/strong&gt; The heap snapshot showed the extra memory was synthetic null data, which immediately narrowed the search.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Large-codebase bugs often live between components.&lt;/strong&gt; This one emerged from the interaction between time comparison, frame preparation, gap filling, and time shifting — not from a single isolated function.&lt;/p&gt;
&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;This reinforced a lesson I keep coming back to while contributing to large open source projects:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When debugging performance, follow the data — not just the allocations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix is up as a pull request against Grafana:&lt;/p&gt;


&lt;div class="ltag_github-liquid-tag"&gt;
  &lt;h1&gt;
    &lt;a href="https://github.com/grafana/grafana/pull/129934" rel="noopener noreferrer"&gt;
      &lt;img class="github-logo" alt="GitHub logo" src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg"&gt;
      &lt;span class="issue-title"&gt;
        TimeComparison: Stop padding compare frames across the compare offset
      &lt;/span&gt;
      &lt;span class="issue-number"&gt;#129934&lt;/span&gt;
    &lt;/a&gt;
  &lt;/h1&gt;
  &lt;div class="github-thread"&gt;
    &lt;div class="timeline-comment-header"&gt;
      &lt;a href="https://github.com/sAchin-680" rel="noopener noreferrer"&gt;
        &lt;img class="github-liquid-tag-img" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F118827645%3Fv%3D4" alt="sAchin-680 avatar"&gt;
      &lt;/a&gt;
      &lt;div class="timeline-comment-header-text"&gt;
        &lt;strong&gt;
          &lt;a href="https://github.com/sAchin-680" rel="noopener noreferrer"&gt;sAchin-680&lt;/a&gt;
        &lt;/strong&gt; posted on &lt;a href="https://github.com/grafana/grafana/pull/129934" rel="noopener noreferrer"&gt;&lt;time&gt;Aug 03, 2026&lt;/time&gt;&lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;div class="ltag-github-body"&gt;
      &lt;p&gt;Follow-up to the memory investigation in #129681.&lt;/p&gt;
&lt;p&gt;Compare frames are gap-filled while still sitting in their own earlier window - the panel only shifts them onto the current range afterwards. Measuring them against the unshifted current range reads the whole compare offset as a gap, so each frame gets padded with a null row per interval across it. Those rows then travel forward with the shift and land outside the visible range, where nothing can see them.&lt;/p&gt;
&lt;p&gt;The fix measures a compare frame against the window it actually covers. Genuine gaps inside a frame still fill, and non-compare frames are untouched.&lt;/p&gt;
&lt;p&gt;Measured with @drew08t's benchmark from #129806 (500 series, 6h window, 20s interval, 24h offset):&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Prepared points&lt;/th&gt;
&lt;th&gt;Heap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Compare — before&lt;/td&gt;
&lt;td&gt;3,240,500&lt;/td&gt;
&lt;td&gt;76.4 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compare — after&lt;/td&gt;
&lt;td&gt;1,081,000&lt;/td&gt;
&lt;td&gt;22.8 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard 12x compare — before&lt;/td&gt;
&lt;td&gt;38,886,000&lt;/td&gt;
&lt;td&gt;822.5 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard 12x compare — after&lt;/td&gt;
&lt;td&gt;12,972,000&lt;/td&gt;
&lt;td&gt;225.0 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Compare now reports 2.00x the points of a single-period baseline instead of 6.00x — two periods of 500 series should cost exactly twice one period, and it now does.&lt;/p&gt;
&lt;p&gt;Also confirmed in a browser on the four-panel harness, toggling the fix in one session: the compare panel goes from 144.2 MB to 84.4 MB, with compare frames back to their true 1080 points. It now sits alongside an equivalent 1000-series non-compare panel (81.7 MB), the remaining ~3 MB being genuine compare overhead.&lt;/p&gt;
&lt;p&gt;Note the benchmark in #129806 is still a draft, so those numbers aren't reproducible from main until it lands.&lt;/p&gt;
&lt;p&gt;Ref #125104&lt;/p&gt;

    &lt;/div&gt;
    &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/grafana/grafana/pull/129934" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>opensource</category>
      <category>debugging</category>
      <category>performance</category>
      <category>grafana</category>
    </item>
  </channel>
</rss>
