<?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%2F307ffa66-8ee6-447e-89e1-650bb93d944f.png</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>Four Kubernetes node bugs I found by reading CI failures to the end</title>
      <dc:creator>SACHIN INDWAR</dc:creator>
      <pubDate>Mon, 26 Oct 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/sachin-680/four-kubernetes-node-bugs-i-found-by-reading-ci-failures-to-the-end-2e52</link>
      <guid>https://dev.to/sachin-680/four-kubernetes-node-bugs-i-found-by-reading-ci-failures-to-the-end-2e52</guid>
      <description>&lt;p&gt;Kubernetes' node tests fail a lot. Most of the time someone re-runs them, they go green, and everyone moves on.&lt;/p&gt;

&lt;p&gt;Over the past week I picked four failures in SIG Node territory and refused to move on — I kept reading the logs until the timeline explained the failure. Three were CI flakes. The fourth was a second, separate bug hiding inside a reported kubelet 1.37 memory regression. In every case, the bug wasn't where the error message pointed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status, to be upfront:&lt;/strong&gt; as of October 10, 2026, all four of my fixes are open pull requests, triaged or awaiting review, not merged. This post is about the investigations — which is the part I'd want to read anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I read a failing CI run
&lt;/h2&gt;

&lt;p&gt;Every Kubernetes CI run on &lt;a href="https://prow.k8s.io" rel="noopener noreferrer"&gt;Prow&lt;/a&gt; keeps its artifacts: the build log, the JUnit results, the kubelet and containerd logs from the node, and for node tests, the VM's serial console. &lt;a href="https://testgrid.k8s.io" rel="noopener noreferrer"&gt;TestGrid&lt;/a&gt; shows the history, so you can tell a one-off from a pattern.&lt;/p&gt;

&lt;p&gt;My loop for each flake:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find the pattern in TestGrid.&lt;/strong&gt; Which job, how often, and — most useful — which &lt;em&gt;similar&lt;/em&gt; jobs &lt;em&gt;don't&lt;/em&gt; fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a timeline across logs.&lt;/strong&gt; Test log, kubelet log and serial console on one clock, to the second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explain the difference.&lt;/strong&gt; If only one job fails, something about that job's environment is the clue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproduce before fixing.&lt;/strong&gt; Either in a unit test that fails on master, or on a machine set up like the CI node.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Bug 1: the image record that kept disappearing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;PR:&lt;/strong&gt; &lt;a href="https://github.com/kubernetes/kubernetes/pull/142801" rel="noopener noreferrer"&gt;kubernetes/kubernetes#142801&lt;/a&gt; · fixes &lt;a href="https://github.com/kubernetes/kubernetes/issues/140942" rel="noopener noreferrer"&gt;#140942&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The symptom
&lt;/h3&gt;

&lt;p&gt;Tests for &lt;code&gt;KubeletEnsureSecretPulledImages&lt;/code&gt; — the feature that makes the kubelet re-check credentials before reusing a private image — kept failing in one job, &lt;code&gt;ci-kubernetes-node-swap-ubuntu-serial&lt;/code&gt;: 3 of the last 10 runs, 6 of 20 over a longer window. A test helper waits up to five seconds for the kubelet to write an &lt;code&gt;ImagePulledRecord&lt;/code&gt; for an image, and the record never appeared.&lt;/p&gt;

&lt;p&gt;This had already been through two issues. One was closed by a PR that only added debug logging. The working theory in the open issue was that the kubelet doesn't write a fresh record when an image is already cached from an earlier test.&lt;/p&gt;

&lt;h3&gt;
  
  
  The timeline
&lt;/h3&gt;

&lt;p&gt;From the failing run (prow job &lt;code&gt;2080994746846679040&lt;/code&gt;):&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%2Frc2jv5yc06xszhkd5bm7.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%2Frc2jv5yc06xszhkd5bm7.png" alt="Timeline of the failing run: at 14:49:41 the test pod pulls the pause image and the record is written; at 14:49:42 the test restarts the kubelet and image GC runs at 85% disk usage, exactly the high threshold, and the post-GC hook deletes the record; at 14:49:47 the test times out waiting for a record that no longer exists" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The record &lt;em&gt;was&lt;/em&gt; written. Then it was deleted one second later. The cached-image theory was wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why only this job
&lt;/h3&gt;

&lt;p&gt;That job's image filesystem sat at about 85% for the whole run — exactly the kubelet's default high threshold for image garbage collection. Every kubelet restart in that job triggered a GC pass. Other jobs never got there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The root cause
&lt;/h3&gt;

&lt;p&gt;After a GC pass, the kubelet calls post-GC hooks with the list of images that "remain". The &lt;code&gt;ImagePullManager&lt;/code&gt;'s hook, &lt;code&gt;PruneUnknownRecords&lt;/code&gt;, deletes every pulled-record for an image that isn't in that list.&lt;/p&gt;

&lt;p&gt;The list was built inside &lt;code&gt;freeSpace()&lt;/code&gt;, from the images it &lt;em&gt;evaluated&lt;/em&gt;. But images in use by a running container, and pinned images, are filtered out before &lt;code&gt;freeSpace()&lt;/code&gt; ever sees them. And &lt;code&gt;freeSpace()&lt;/code&gt; stops early once it has freed enough bytes, so images after that point were never added either:&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;// imagesInEvictionOrder, simplified&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;imageRecords&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;isImageUsed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;imagesInUse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;          &lt;span class="c"&gt;// in-use images never reach freeSpace()...&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;record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pinned&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;          &lt;span class="c"&gt;// ...and neither do pinned ones&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;images&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;evictionInfo&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;imageRecord&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the list of "remaining" images was really "the eviction candidates GC looked at and kept". The image the test pod was &lt;em&gt;running&lt;/em&gt; wasn't on it, and its record got pruned.&lt;/p&gt;

&lt;p&gt;It fails safe — a later pod just has to re-verify credentials — but it's wrong, and it's invisible: the deletions weren't logged.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Pass the hooks every image the GC manager still tracks, not just the candidates it evaluated:&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;// remainingImages returns the IDs of all images still known after a GC pass.&lt;/span&gt;
&lt;span class="c"&gt;// This must include in-use and pinned images, not just the eviction candidates&lt;/span&gt;
&lt;span class="c"&gt;// that survived: post-GC hooks treat anything missing from the list as gone.&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;im&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;realImageGCManager&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;remainingImages&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;imageRecordsLock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;imageRecordsLock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c"&gt;// The same image can be tracked once per runtime handler.&lt;/span&gt;
    &lt;span class="n"&gt;imageIDs&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;imageKey&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;imageRecords&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;imageIDs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getImageIDFromTuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;imageKey&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="n"&gt;sets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;imageIDs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both &lt;code&gt;GarbageCollect&lt;/code&gt; and &lt;code&gt;DeleteUnusedImages&lt;/code&gt; now call &lt;code&gt;im.runPostGCHooks(ctx, im.remainingImages(), freeTime)&lt;/code&gt;, and pruned records are logged at &lt;code&gt;V(4)&lt;/code&gt;. The new &lt;code&gt;TestPostGCHooksReceiveAllRemainingImages&lt;/code&gt; fails on master — the hook receives an empty list — and passes with the change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status (Oct 10, 2026):&lt;/strong&gt; open, awaiting triage and review from SIG Node.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: the swap test that hung nodes for hours
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;PR:&lt;/strong&gt; &lt;a href="https://github.com/kubernetes/kubernetes/pull/142850" rel="noopener noreferrer"&gt;kubernetes/kubernetes#142850&lt;/a&gt; · related to &lt;a href="https://github.com/kubernetes/kubernetes/issues/138226" rel="noopener noreferrer"&gt;#138226&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The symptom
&lt;/h3&gt;

&lt;p&gt;A swap e2e test, "should be able to use more than the node memory capacity", sometimes passed, sometimes failed, and sometimes produced a run that ended in &lt;code&gt;exit status 255&lt;/code&gt; with no JUnit results at all — the node had stopped responding.&lt;/p&gt;

&lt;h3&gt;
  
  
  The numbers didn't add up
&lt;/h3&gt;

&lt;p&gt;The test allocates memory equal to the node's &lt;strong&gt;full capacity&lt;/strong&gt; — 8 GiB on the CI node, which also has 1 GiB of swap. The system needs some of that RAM too, so part of the pod's allocation can't fit in memory and has to go to swap. In my reproduction below, when the pod was allowed to finish, about &lt;strong&gt;761 MiB&lt;/strong&gt; of it ended up in swap.&lt;/p&gt;

&lt;p&gt;Under &lt;code&gt;LimitedSwap&lt;/code&gt;, a container's swap limit is proportional to its memory request:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;swap limit = (memory request ÷ node memory) × node swap&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Swap limit&lt;/th&gt;
&lt;th&gt;Against ~761 MiB of overflow&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;30% (before)&lt;/td&gt;
&lt;td&gt;~307 MiB&lt;/td&gt;
&lt;td&gt;too small&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90% (after)&lt;/td&gt;
&lt;td&gt;~921 MiB&lt;/td&gt;
&lt;td&gt;fits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So in every run, the pod hit its swap limit with memory still left to place. What happened &lt;em&gt;next&lt;/em&gt; varied — which is why the test flaked instead of failing every time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The OOM killer stepped in quickly.&lt;/strong&gt; The run recovered, and sometimes the test passed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It didn't.&lt;/strong&gt; The kernel tried to reclaim memory elsewhere. On this image &lt;code&gt;system.slice&lt;/code&gt; and &lt;code&gt;user.slice&lt;/code&gt; can't swap, so it thrashed the page cache instead — and the node hung for hours. Those are the &lt;code&gt;exit status 255&lt;/code&gt; runs with no JUnit results.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The serial console of one failed run showed exactly this: the pod pinned at its swap limit, with roughly 700 MB of swap still free on the node.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproducing it
&lt;/h3&gt;

&lt;p&gt;On a VM set up like the CI node (8 GiB RAM, 1 GiB swap, Ubuntu 24.04, the same slice swap settings): at 30% the pod hit its swap limit and was OOM-killed. At 90% it finished allocating with about 761 MiB in swap, with no OOM and no stalls.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Three changes, all test-only:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request 90% of memory, so the pod's swap limit can hold the overflow.&lt;/li&gt;
&lt;li&gt;Lower the &lt;code&gt;memory.available&lt;/code&gt; hard-eviction threshold to 100 Mi for these tests. Under node-level pressure, swap only starts a few seconds before the default 250 Mi threshold would evict the pod — before the test can see any swap usage.&lt;/li&gt;
&lt;li&gt;Delete the pod with &lt;code&gt;DeleteSync&lt;/code&gt;, so it's gone before the kubelet restarts in &lt;code&gt;AfterEach&lt;/code&gt;. Nothing kills the pod while the kubelet is down.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Status (Oct 10, 2026):&lt;/strong&gt; open, triaged as &lt;code&gt;important-longterm&lt;/code&gt;, with the swap tests' original author asked to review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: the 3.9 GB image that never finished pulling
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;PR:&lt;/strong&gt; &lt;a href="https://github.com/kubernetes/kubernetes/pull/142802" rel="noopener noreferrer"&gt;kubernetes/kubernetes#142802&lt;/a&gt; · related to &lt;a href="https://github.com/kubernetes/kubernetes/issues/141200" rel="noopener noreferrer"&gt;#141200&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The symptom
&lt;/h3&gt;

&lt;p&gt;The GPU sanity test (&lt;code&gt;nvidia-smi&lt;/code&gt; plus a CUDA demo) timed out after 30 minutes with the pod stuck in &lt;code&gt;ErrImagePull&lt;/code&gt;. Even passing runs spent up to 24 minutes pulling the image.&lt;/p&gt;

&lt;h3&gt;
  
  
  The containerd log
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cancel pulling image docker.io/nvidia/cuda:12.5.0-devel-ubuntu22.04 because of no progress in 5m0s
stop pulling image ...: active requests=1, bytes read=3918316892
failed to extract layer (... sha256:496b16dd...) to overlayfs as "extract-...": context canceled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image is about 3.9 GB, and its largest layer is about 2.5 GB. The log shows 3.9 GB had been read before the pull was cancelled during layer extraction. So the expensive part wasn't the network — it was unpacking the big layer, which reported no "progress" to containerd's five-minute watchdog. The cancelled pull didn't reuse what it had already fetched, so each kubelet retry started the download again. That's &lt;a href="https://github.com/containerd/containerd/issues/13909" rel="noopener noreferrer"&gt;containerd/containerd#13909&lt;/a&gt;, with a proposed fix upstream.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Use a smaller image. On amd64, the test only needs &lt;code&gt;nvidia-smi&lt;/code&gt; (which comes from the driver) and the &lt;code&gt;cuda-demo-suite-12-5&lt;/code&gt; apt package, whose binaries link the CUDA runtime statically. The ~90 MB &lt;code&gt;nvidia/cuda:12.5.0-base-ubuntu22.04&lt;/code&gt; image has the same CUDA apt repository, keyring and environment. Other architectures build samples with &lt;code&gt;nvcc&lt;/code&gt;, so they keep the devel image, and the pod is pinned to the GPU node's architecture so the image always matches the script's &lt;code&gt;uname -m&lt;/code&gt; branch.&lt;/p&gt;

&lt;p&gt;I left arm64 alone because I couldn't test it on arm64 GPU nodes. It can still hit the containerd behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status (Oct 10, 2026):&lt;/strong&gt; open, triaged as &lt;code&gt;important-longterm&lt;/code&gt;, awaiting &lt;code&gt;/ok-to-test&lt;/code&gt; and review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: a second bug inside the 1.37 kubelet memory regression
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;PR:&lt;/strong&gt; &lt;a href="https://github.com/google/cadvisor/pull/3946" rel="noopener noreferrer"&gt;google/cadvisor#3946&lt;/a&gt; · issue &lt;a href="https://github.com/kubernetes/kubernetes/issues/142874" rel="noopener noreferrer"&gt;kubernetes/kubernetes#142874&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This one isn't a flake. A SIG Node contributor reported 20–30% higher kubelet memory in Kubernetes 1.37, traced to cAdvisor — the library the kubelet uses to collect container stats — and wrote the main fix. My part is a separate, smaller bug I found while reproducing it. To keep the credit straight:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;What it fixes&lt;/th&gt;
&lt;th&gt;Author&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/google/cadvisor/pull/3944" rel="noopener noreferrer"&gt;google/cadvisor#3944&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The reported cause: changing counters that stop housekeeping from ever backing off&lt;/td&gt;
&lt;td&gt;the reporter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/google/cadvisor/pull/3946" rel="noopener noreferrer"&gt;google/cadvisor#3946&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A back-off reset bug that makes the interval oscillate even when stats are unchanged&lt;/td&gt;
&lt;td&gt;me&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The reported cause
&lt;/h3&gt;

&lt;p&gt;cAdvisor polls each container's stats on a "housekeeping" interval. When stats haven't changed, it backs off — doubling the interval up to a maximum. When they change, it drops back to the baseline.&lt;/p&gt;

&lt;p&gt;MemoryQoS, default-on in 1.37, sets &lt;code&gt;memory.high&lt;/code&gt; on containers. The kernel then reclaims memory continuously, which keeps advancing counters in &lt;code&gt;memory.stat&lt;/code&gt;. cAdvisor compared whole stats structs, so they never looked unchanged, and housekeeping never backed off. The reporter's fix, &lt;a href="https://github.com/google/cadvisor/pull/3944" rel="noopener noreferrer"&gt;google/cadvisor#3944&lt;/a&gt;, excludes those counters from the comparison.&lt;/p&gt;

&lt;h3&gt;
  
  
  The second bug underneath
&lt;/h3&gt;

&lt;p&gt;I reproduced the regression and confirmed #3944 fixes the counters. But while simulating it, I found the back-off had a bug of its own:&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;stats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatsEq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;housekeepingInterval&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxHousekeepingInterval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;housekeepingInterval&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
    &lt;span class="c"&gt;// ... cap at max ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;housekeepingInterval&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;HousekeepingInterval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// Lower interval back to the baseline.&lt;/span&gt;
    &lt;span class="n"&gt;cd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;housekeepingInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;HousekeepingInterval&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the interval reaches the maximum, &lt;code&gt;interval &amp;lt; max&lt;/code&gt; is false — so even with &lt;em&gt;unchanged&lt;/em&gt; stats, execution falls into the &lt;code&gt;else&lt;/code&gt; branch and resets to the baseline. Instead of staying backed off, the interval cycles: with cAdvisor's defaults, &lt;code&gt;1s → 2s → 4s → … → 1m → 1s&lt;/code&gt;; with the kubelet's settings, &lt;code&gt;10s → 15s → 10s → 15s&lt;/code&gt;.&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%2Fvwupylvwg4g0ro3m4zkc.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%2Fvwupylvwg4g0ro3m4zkc.png" alt="Chart of the housekeeping interval over successive ticks with unchanged stats: the original code climbs to the maximum and then drops back to the baseline in a repeating sawtooth, while the fixed code climbs to the maximum and stays there" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix separates the two conditions, so only &lt;em&gt;changed&lt;/em&gt; stats reset the interval:&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;stats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatsEq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&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;cd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;housekeepingInterval&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxHousekeepingInterval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;cd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;housekeepingInterval&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
        &lt;span class="c"&gt;// ... cap at max ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;housekeepingInterval&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;HousekeepingInterval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;housekeepingInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;HousekeepingInterval&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The new &lt;code&gt;TestNextHousekeepingIntervalDynamic&lt;/code&gt; steps through unchanged and changed samples. Run against the original code, it fails exactly where the sawtooth starts:&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%2F8eiwlv0kix84kpsakghp.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%2F8eiwlv0kix84kpsakghp.gif" alt="Terminal recording: the new test fails on the original cAdvisor code, expecting 4s but getting 1s at step 3 and 2s at step 4, then passes with the fix" width="800" height="550"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In my simulation of a 10-minute run with the kubelet's settings and MemoryQoS-style advancing counters, a container got &lt;strong&gt;60&lt;/strong&gt; housekeeping ticks on the current code, &lt;strong&gt;48&lt;/strong&gt; with #3944, and &lt;strong&gt;41&lt;/strong&gt; with both fixes. Most of the improvement comes from #3944; mine removes the oscillation that remains. Neither patch alone gets the kubelet to back off as intended, and the simulation measures housekeeping ticks, not the production memory figure in the report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status (Oct 10, 2026):&lt;/strong&gt; #3946 is open; the issue is assigned to me, and the reporter called the patch a good one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the four had in common
&lt;/h2&gt;

&lt;p&gt;Each bug lived at an edge that normal runs never reach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A list that silently meant "some of everything".&lt;/strong&gt; "Remaining images" excluded in-use and pinned images, and nothing downstream knew.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A number that couldn't work.&lt;/strong&gt; A swap limit smaller than the overflow it had to hold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A timeout that measured the wrong thing.&lt;/strong&gt; A "no progress" watchdog applied to an unpack that reports no progress — and the fetched data not reused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An &lt;code&gt;else&lt;/code&gt; branch that fired at the boundary.&lt;/strong&gt; Correct below the maximum, wrong exactly at it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And each one was explained by a difference: the one job at 85% disk, the one test whose request didn't match its allocation, the one image that was 40 times bigger than it needed to be, the one comparison that never settled.&lt;/p&gt;

&lt;p&gt;Flakes aren't noise. They're bugs that only show up under one environment's conditions. The job that fails is telling you which conditions.&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;Prow timeline for bug 1&lt;/td&gt;
&lt;td&gt;The record was written, then pruned by GC one second later, in that run&lt;/td&gt;
&lt;td&gt;That every historical failure of these tests had this cause&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TestPostGCHooksReceiveAllRemainingImages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The hook now receives in-use and pinned images&lt;/td&gt;
&lt;td&gt;That the fix has been reviewed or accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VM reproduction for bug 2&lt;/td&gt;
&lt;td&gt;30% fails and 90% holds on a CI-like machine&lt;/td&gt;
&lt;td&gt;Behavior on every node image the swap tests run on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;containerd log for bug 3&lt;/td&gt;
&lt;td&gt;3.9 GB had been read before the pull was cancelled during layer extraction&lt;/td&gt;
&lt;td&gt;That every layer had fully downloaded, or that the smaller image never hits the same timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cAdvisor test, failing on original and passing with the fix&lt;/td&gt;
&lt;td&gt;The back-off reset bug exists and the change removes it&lt;/td&gt;
&lt;td&gt;The production memory savings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60 / 48 / 41 tick simulation&lt;/td&gt;
&lt;td&gt;The relative effect of #3944 and #3946 in my model&lt;/td&gt;
&lt;td&gt;The 20–30% memory figure, which comes from the reporter's environment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Check each one yourself
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bug 1 — kubelet image GC.&lt;/strong&gt; Check out the PR branch and run the new unit test. Without the fix, the hook receives an empty list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;test&lt;/span&gt; ./pkg/kubelet/images/ &lt;span class="nt"&gt;-run&lt;/span&gt; TestPostGCHooksReceiveAllRemainingImages &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bug 2 — swap test.&lt;/strong&gt; This is a node e2e test, so it needs a Linux node with swap enabled. The PR describes the VM I used (8 GiB RAM, 1 GiB swap, Ubuntu 24.04). From a Kubernetes checkout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make test-e2e-node &lt;span class="nv"&gt;FOCUS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"should be able to use more than the node memory capacity"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bug 3 — GPU image.&lt;/strong&gt; The full test needs a GPU node; in CI it's the &lt;code&gt;pull-kubernetes-e2e-gce-device-plugin-gpu&lt;/code&gt; job. The size difference behind the fix is easy to see anywhere &lt;code&gt;crictl&lt;/code&gt; runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;crictl pull nvidia/cuda:12.5.0-devel-ubuntu22.04   &lt;span class="c"&gt;# ~3.9 GB, ~2.5 GB largest layer&lt;/span&gt;
crictl pull nvidia/cuda:12.5.0-base-ubuntu22.04    &lt;span class="c"&gt;# ~90 MB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cancellation itself is containerd's &lt;code&gt;image_pull_progress_timeout&lt;/code&gt; (five minutes by default) firing during extraction; &lt;a href="https://github.com/containerd/containerd/issues/13909" rel="noopener noreferrer"&gt;containerd/containerd#13909&lt;/a&gt; has the details.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 4 — cAdvisor back-off.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/google/cadvisor &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;cadvisor/lib
&lt;span class="c"&gt;# add TestNextHousekeepingIntervalDynamic from the PR, then:&lt;/span&gt;
go &lt;span class="nb"&gt;test&lt;/span&gt; ./manager/ &lt;span class="nt"&gt;-run&lt;/span&gt; TestNextHousekeepingIntervalDynamic &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the original code it fails at step 3; with the fix applied, it passes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Sachin, an SRE / platform engineer working on Kubernetes, observability 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>kubernetes</category>
      <category>go</category>
      <category>sre</category>
      <category>opensource</category>
    </item>
    <item>
      <title>32 bytes verified, 40 didn't: parsing HTTP inside the eBPF verifier's budget</title>
      <dc:creator>SACHIN INDWAR</dc:creator>
      <pubDate>Mon, 19 Oct 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/sachin-680/32-bytes-verified-40-didnt-parsing-http-inside-the-ebpf-verifiers-budget-1hkg</link>
      <guid>https://dev.to/sachin-680/32-bytes-verified-40-didnt-parsing-http-inside-the-ebpf-verifiers-budget-1hkg</guid>
      <description>&lt;p&gt;My eBPF agent traces HTTPS traffic without touching the application: uprobes on OpenSSL's &lt;code&gt;SSL_write&lt;/code&gt;/&lt;code&gt;SSL_read&lt;/code&gt; and on Go's &lt;code&gt;crypto/tls&lt;/code&gt; copy the plaintext out of the process before encryption, and the agent turns it into OpenTelemetry spans.&lt;/p&gt;

&lt;p&gt;The kernel side copies a bounded prefix of each payload — 256 bytes — into a ring buffer, and &lt;strong&gt;all the HTTP parsing happens in userspace&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The obvious question is why. Parsing in the kernel would mean shipping less data and filtering earlier. "The verifier won't allow it" is the usual answer, but I wanted a number, not folklore. So I wrote the in-kernel parser five different ways and recorded exactly what the verifier said about each.&lt;/p&gt;

&lt;p&gt;The answer turned out to be more interesting than "no".&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/sAchin-680/eBPF-observability-agent" rel="noopener noreferrer"&gt;sAchin-680/eBPF-observability-agent&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Every attempt is a real uprobe program that reads up to 256 bytes of the buffer passed to &lt;code&gt;SSL_write&lt;/code&gt; and tries to find &lt;code&gt;Host:&lt;/code&gt; headers. None is ever attached — they exist to be loaded, and the interesting ones are the loads that fail.&lt;/p&gt;

&lt;p&gt;Each program is loaded &lt;strong&gt;on its own&lt;/strong&gt; with &lt;a href="https://github.com/cilium/ebpf" rel="noopener noreferrer"&gt;cilium/ebpf&lt;/a&gt;, because loading them together would stop at the first rejection and hide the rest. The test records the outcome, the time, and — new for this post — the verifier's own count of instructions it processed.&lt;/p&gt;

&lt;p&gt;I originally ran this on kernel 6.8. For this post I re-ran everything on &lt;strong&gt;6.18&lt;/strong&gt;. Every accept/reject outcome was identical.&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%2Fm0xqj4wv9tnug2lhe1ad.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%2Fm0xqj4wv9tnug2lhe1ad.gif" alt="Terminal recording on kernel 6.18: the classic per-header parse is accepted at 32 bytes and rejected from 40 bytes up, while the bpf_loop and bpf_for versions are accepted at 256 bytes, then both pass every correctness check" width="800" height="509"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 1: scan until the data says stop
&lt;/h2&gt;

&lt;p&gt;The natural way to find the end of a header block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&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;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'\0'&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="p"&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;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'\n'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Rejected:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;invalid read from stack R3 off=0 size=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;What's interesting is &lt;em&gt;how&lt;/em&gt; it fails. Since kernel 5.3 the verifier accepts bounded loops by simulating every iteration. It did exactly that here: it followed &lt;code&gt;i&lt;/code&gt; all the way to the end of the 256-byte buffer and refused the read that walks off it. It isn't objecting to the loop. It found the overrun.&lt;/p&gt;
&lt;h2&gt;
  
  
  Attempt 2: bound it by the caller's length
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;num&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Rejected the same way. The verifier's register state shows why:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;R6_r=Pscalar(smin=umin=250, smax=umax=0x7fffffff, ...)
invalid read from stack R2 off=0 size=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;umax=0x7fffffff&lt;/code&gt;. The length comes from userspace, so the verifier knows nothing about it and has to assume two billion. A bound the verifier can't see is no bound at all.&lt;/p&gt;
&lt;h2&gt;
  
  
  Attempt 3: bound it by the buffer
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;BUF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&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="p"&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;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'\n'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&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;Accepted.&lt;/strong&gt; 22 instructions, and the verifier processed &lt;strong&gt;6,389&lt;/strong&gt; instructions to prove it safe — walking all 256 iterations.&lt;/p&gt;

&lt;p&gt;But it only counts line breaks. Real parsing needs per-line work.&lt;/p&gt;
&lt;h2&gt;
  
  
  Attempt 4: actually parse — and hit the wall
&lt;/h2&gt;

&lt;p&gt;The smallest thing that resembles parsing headers: at each &lt;code&gt;\r\n&lt;/code&gt;, check whether the line that just ended starts with &lt;code&gt;Host:&lt;/code&gt;, then remember where the next line begins.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&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="p"&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;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'\r'&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;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'\n'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line_start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;line_start&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'H'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;line_start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'o'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;line_start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'s'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;line_start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'t'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;line_start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;':'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;line_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;I compiled the identical code at different buffer sizes. Every version is &lt;strong&gt;76 instructions&lt;/strong&gt; — the compiler doesn't unroll it. The difference is entirely in how much work the verifier does:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Buffer&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;Instructions processed by the verifier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;32 bytes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;accepted&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;158,806&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;40 bytes&lt;/td&gt;
&lt;td&gt;rejected, &lt;code&gt;E2BIG&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;over 1,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;48 bytes&lt;/td&gt;
&lt;td&gt;rejected, &lt;code&gt;E2BIG&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;over 1,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;56 bytes&lt;/td&gt;
&lt;td&gt;rejected, &lt;code&gt;E2BIG&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;over 1,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64 bytes&lt;/td&gt;
&lt;td&gt;rejected, &lt;code&gt;E2BIG&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;over 1,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256 bytes&lt;/td&gt;
&lt;td&gt;rejected, &lt;code&gt;E2BIG&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;over 1,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;E2BIG&lt;/code&gt; ("argument list too long") is what the verifier returns when it exhausts its budget of one million processed instructions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;32 bytes uses 16% of the budget. 40 bytes exceeds it.&lt;/strong&gt; A 25% bigger buffer costs more than six times the verification work. A &lt;code&gt;Host&lt;/code&gt; header alone is often longer than 32 bytes, so this isn't a tuning problem — this shape of parser simply can't cover a real request.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why it explodes
&lt;/h3&gt;

&lt;p&gt;The verifier walks every iteration and every branch. Normally it keeps the cost down by &lt;em&gt;pruning&lt;/em&gt;: when it reaches an instruction in a state equivalent to one it has already proven safe, it stops exploring.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;line_start&lt;/code&gt; defeats that. It's used as a memory offset, so the verifier must track its exact value — and every path through the data sets it differently, depending on where the &lt;code&gt;\r\n&lt;/code&gt; pairs fell. States that differ only in &lt;code&gt;line_start&lt;/code&gt; can't be merged, so the number of live paths multiplies with each byte. Counting line breaks in attempt 3 has no such variable, which is why it stays cheap over 256 bytes.&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%2F23ba5fbjh8fwvfas6isk.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%2F23ba5fbjh8fwvfas6isk.png" alt="Diagram: in the classic loop the verifier explores a tree of paths where each line break sets line_start to a different value, so paths multiply; with bpf_loop the callback is verified once for any index, costing 149 instructions for 256 bytes" width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  An aside: asking for the log changed the answer
&lt;/h3&gt;

&lt;p&gt;On the 6.8 machine, requesting the verifier's log for the 64-byte program changed the reported error from &lt;code&gt;E2BIG&lt;/code&gt; to &lt;code&gt;EINVAL&lt;/code&gt;, and verification went from 0.3 s to 5.7 s. Worse: the loader retries with a progressively bigger log buffer while the log is truncated, and no buffer was ever big enough. The test process reached 5.3 GB resident on a 5.9 GB machine and was OOM-killed.&lt;/p&gt;

&lt;p&gt;Verification itself took 280 ms. Only the log was unbounded. The experiment now loads the size sweep with logging disabled and asks only for the verifier's statistics, which are a few lines long.&lt;/p&gt;
&lt;h2&gt;
  
  
  Attempt 5: the two features built for exactly this
&lt;/h2&gt;

&lt;p&gt;My original conclusion was "in-kernel parsing is ruled out." That conclusion was only about the classic loop. Newer kernels have two features designed for loops the verifier can't afford to walk:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;bpf_loop()&lt;/code&gt;&lt;/strong&gt; (kernel 5.17): a helper that calls a callback N times. The verifier checks the callback &lt;strong&gt;once&lt;/strong&gt;, with the index known only to be in &lt;code&gt;[0, N)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open-coded iterators&lt;/strong&gt; (&lt;code&gt;bpf_for&lt;/code&gt;, kernel 6.4): an ordinary-looking loop that the verifier checks by converging on a stable state, rather than by walking every iteration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the same parse with &lt;code&gt;bpf_loop()&lt;/code&gt;, over the full 256 bytes:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="nf"&gt;parse_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__u64&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;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;parse_ctx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;__u64&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;__u64&lt;/span&gt; &lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;BUF&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'\r'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'\n'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;ls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;line_start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;BUF&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;ls&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'H'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;ls&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'o'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;ls&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'s'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;ls&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'t'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;ls&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;':'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;line_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/* in the uprobe: */&lt;/span&gt;
&lt;span class="n"&gt;bpf_loop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BUF&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parse_step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  First, the verifier and the compiler disagreed
&lt;/h3&gt;

&lt;p&gt;My first versions of both were rejected instantly. Not because of the budget — because of how I'd written the bounds checks:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;invalid unbounded variable-offset read from stack R5
invalid variable-offset read from stack R2 var_off=(0x0; 0x1ff) off=-256 size=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two classic mistakes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;if (i + 1 &amp;gt;= BUF)&lt;/code&gt;&lt;/strong&gt; let the compiler test a &lt;em&gt;copy&lt;/em&gt; of the index, so the register actually used as the offset was never bounded in the verifier's eyes. Testing &lt;code&gt;i &amp;gt;= BUF - 1&lt;/code&gt; directly bounds the register that's used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;if (line_start + 5 &amp;lt; BUF)&lt;/code&gt;&lt;/strong&gt; looks like it proves &lt;code&gt;line_start &amp;lt; 251&lt;/code&gt;. In 32-bit arithmetic it doesn't: the addition could wrap, so the verifier learns nothing about &lt;code&gt;line_start&lt;/code&gt; from it. &lt;code&gt;line_start &amp;lt; BUF - 5&lt;/code&gt; says the same thing in a form it can use.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The agent's production capture code already follows the same rule — it masks the copy length with &lt;code&gt;n &amp;amp;= (MAX_DATA - 1)&lt;/code&gt; rather than relying on an earlier &lt;code&gt;if&lt;/code&gt;, because the mask states the bound in a form the verifier accepts on every path. Bounds have to be written for the verifier, not just for the reader.&lt;/p&gt;
&lt;h3&gt;
  
  
  Then: 256 bytes, a few hundred instructions
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Buffer&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;Instructions processed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;classic bounded loop&lt;/td&gt;
&lt;td&gt;32 bytes&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;158,806&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;classic bounded loop&lt;/td&gt;
&lt;td&gt;40 bytes&lt;/td&gt;
&lt;td&gt;rejected&lt;/td&gt;
&lt;td&gt;over 1,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;bpf_loop()&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;256 bytes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;accepted&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;149&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;bpf_for&lt;/code&gt; iterator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;256 bytes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;accepted&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;351&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2F69v3l9bczzki1w53z6z5.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%2F69v3l9bczzki1w53z6z5.png" alt="Log-scale bar chart of verifier instructions processed: classic loop 32 bytes 158,806 accepted; classic loop 40 and 256 bytes over the 1,000,000 budget; bpf_loop 256 bytes 149; bpf_for 256 bytes 351" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Eight times the buffer of the largest classic loop that verifies, for 0.1–0.2% of the verification work.&lt;/p&gt;

&lt;p&gt;Loading isn't the same as being right, so I also checked they compute the correct answer. Uprobe programs can't be test-run directly, so the identical parse is wrapped in a &lt;code&gt;BPF_PROG_TYPE_SYSCALL&lt;/code&gt; program and run on real requests with &lt;code&gt;BPF_PROG_TEST_RUN&lt;/code&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;run_bpf_loop  one Host header      len= 50  matches=1 want=1  ok
run_bpf_loop  no Host header       len= 51  matches=0 want=0  ok
run_bpf_loop  two Host headers     len= 42  matches=2 want=2  ok
run_bpf_loop  Host past byte 200   len=224  matches=1 want=1  ok
run_bpf_for   one Host header      len= 50  matches=1 want=1  ok
run_bpf_for   no Host header       len= 51  matches=0 want=0  ok
run_bpf_for   two Host headers     len= 42  matches=2 want=2  ok
run_bpf_for   Host past byte 200   len=224  matches=1 want=1  ok
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That last case — a &lt;code&gt;Host&lt;/code&gt; header past byte 200 — is one the classic loop couldn't even be verified over.&lt;/p&gt;
&lt;h2&gt;
  
  
  So why does the agent still parse in userspace?
&lt;/h2&gt;

&lt;p&gt;The verifier is no longer the deciding reason on a modern kernel. These are:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The kernel floor.&lt;/strong&gt; The agent ships as one CO-RE binary, verified by checksum on kernels 5.15 and 6.8. &lt;code&gt;bpf_loop()&lt;/code&gt; needs 5.17 and iterators need 6.4. Ubuntu 22.04 runs 5.15. Using either would mean dropping supported kernels or maintaining two kernel-side parsers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where the work runs.&lt;/strong&gt; A uprobe handler runs synchronously in the traced application's own thread, before &lt;code&gt;SSL_write&lt;/code&gt; continues. Copying 256 bytes and returning keeps the cost on the application's request path as small as possible. Parsing in userspace moves that work onto the agent's own threads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the kernel can see.&lt;/strong&gt; A header block can be split across several &lt;code&gt;SSL_write&lt;/code&gt; calls, and the kernel program sees one call at a time. Userspace sees the whole stream and can correlate requests with responses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I can test.&lt;/strong&gt; The userspace parser is ordinary Go with ordinary unit tests. The kernel parser needs root, a specific kernel, and a test harness just to run.&lt;/p&gt;

&lt;p&gt;The cost of this design is explicit: the captured prefix is 256 bytes, so headers beyond it are truncated — and truncation is reported on every event rather than hidden.&lt;/p&gt;

&lt;p&gt;If the agent ever drops 5.15, an in-kernel pre-filter with &lt;code&gt;bpf_loop()&lt;/code&gt; — say, only capture requests to certain hosts — becomes a reasonable option rather than an impossible one. That's a different conclusion from the one I started with.&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;Classic loop: 32 bytes accepted, 40+ rejected&lt;/td&gt;
&lt;td&gt;This parser shape exceeds the 1M-instruction budget above 32 bytes, on 6.8 and 6.18&lt;/td&gt;
&lt;td&gt;That every classic-loop parser hits the same wall at the same size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;bpf_loop&lt;/code&gt; / &lt;code&gt;bpf_for&lt;/code&gt; accepted at 256 bytes&lt;/td&gt;
&lt;td&gt;These two loop forms verify cheaply for this parse on 6.18&lt;/td&gt;
&lt;td&gt;Behavior on kernels I haven't tested between 5.17/6.4 and 6.18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test-run results&lt;/td&gt;
&lt;td&gt;The parse returns the right count for these inputs&lt;/td&gt;
&lt;td&gt;That it handles every valid HTTP/1.1 request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verifier statistics&lt;/td&gt;
&lt;td&gt;How much work the verifier did for these programs on this kernel&lt;/td&gt;
&lt;td&gt;Run-time cost of the programs once attached&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  What I'd take from this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure the constraint before designing around it.&lt;/strong&gt; "The verifier won't allow it" was true for one loop shape on one kind of kernel, and false for another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the verifier's instruction count, not just pass/fail.&lt;/strong&gt; 158,806 of 1,000,000 at 32 bytes was the warning that 40 bytes would fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write bounds for the verifier.&lt;/strong&gt; Check the register you index with, avoid sums that can wrap, and mask when you can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-test old conclusions when the platform moves.&lt;/strong&gt; I'd written "in-kernel parsing is ruled out" into the project's docs. On a 6.x kernel, that's no longer true.&lt;/li&gt;
&lt;/ol&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;go &lt;span class="nb"&gt;test&lt;/span&gt; ./test/verifier/ &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The classic-loop experiment is in &lt;a href="https://github.com/sAchin-680/eBPF-observability-agent/blob/6aab36dbeb700e201d68445c38c533e57c3141fb/test/verifier/headers.bpf.c" rel="noopener noreferrer"&gt;&lt;code&gt;test/verifier/headers.bpf.c&lt;/code&gt;&lt;/a&gt;, and the write-up with the raw verifier output is in &lt;a href="https://github.com/sAchin-680/eBPF-observability-agent/tree/6aab36dbeb700e201d68445c38c533e57c3141fb/docs/verifier" rel="noopener noreferrer"&gt;&lt;code&gt;docs/verifier/&lt;/code&gt;&lt;/a&gt;. The &lt;code&gt;bpf_loop&lt;/code&gt;/&lt;code&gt;bpf_for&lt;/code&gt; versions, the statistics test and the test-run checks are in &lt;a href="https://github.com/sAchin-680/eBPF-observability-agent/blob/main/test/verifier/extra.bpf.c" rel="noopener noreferrer"&gt;&lt;code&gt;test/verifier/extra.bpf.c&lt;/code&gt;&lt;/a&gt;.&lt;/p&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/eBPF-observability-agent" rel="noopener noreferrer"&gt;
        eBPF-observability-agent
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Production-grade eBPF observability agent for zero-instrumentation HTTP/HTTPS tracing.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Zero-Instrumentation eBPF Observability Agent&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Request-level tracing for HTTP and HTTPS services, with zero changes to the traced application.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This agent attaches to the kernel and to TLS library entry points using eBPF
reconstructs HTTP request/response pairs from raw syscalls and
decrypted-in-place buffers, and exports them as OpenTelemetry traces and
metrics.&lt;/p&gt;
&lt;p&gt;No SDK. No sidecar proxy. No configuration injected into the traced process
One binary, once per node.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Architecture&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;        ┌──────────────┐  ┌──────────────┐  ┌──────────────┐
        │  Go service  │  │    Python    │  │   Node.js    │
        │ (crypto/tls) │  │  (OpenSSL)   │  │  (OpenSSL)   │
        └───────┬──────┘  └───────┬──────┘  └───────┬──────┘
                │                 │                 │
                └─────────────────┼─────────────────┘
                                  │  uprobes + tracepoints
                                  ▼
        ┌──────────────────────────────────────────────────┐
        │              eBPF programs (kernel)              │
        │  SSL_write/read · Go TLS write/read · socket tp  │
        └─────────────────────────┬────────────────────────┘
                                  │  BPF ring buffer
                                  ▼
        ┌──────────────────────────────────────────────────┐
        │               Userspace agent (Go)               │
        │       parse · correlate · build OTel spans       │
        └─────────────────────────┬────────────────────────┘
                                  │  OTLP
                                  ▼
               ┌──────────────────┴──────────────────┐
               ▼                                     ▼
        Tempo (traces)                     Prometheus (metrics)&lt;/code&gt;&lt;/pre&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/eBPF-observability-agent" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Next in this series: why benchmarking the agent against an idle machine made it look &lt;em&gt;faster&lt;/em&gt; than running no agent at all — and the control arm that fixed it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Sachin, an SRE / platform engineer working on observability, eBPF and Kubernetes — 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>ebpf</category>
      <category>linux</category>
      <category>go</category>
      <category>observability</category>
    </item>
    <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>
