<?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: Sahan</title>
    <description>The latest articles on DEV Community by Sahan (@sahan).</description>
    <link>https://dev.to/sahan</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%2F222556%2F1c778b22-09b2-4722-bde2-596cea77a01a.jpg</url>
      <title>DEV Community: Sahan</title>
      <link>https://dev.to/sahan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sahan"/>
    <language>en</language>
    <item>
      <title>Replacing Sharp with WASM: How a Hidden SVG Dependency Broke Our Rendering Pipeline</title>
      <dc:creator>Sahan</dc:creator>
      <pubDate>Fri, 21 Aug 2026 01:03:00 +0000</pubDate>
      <link>https://dev.to/sahan/replacing-sharp-with-wasm-how-a-hidden-svg-dependency-broke-our-rendering-pipeline-fga</link>
      <guid>https://dev.to/sahan/replacing-sharp-with-wasm-how-a-hidden-svg-dependency-broke-our-rendering-pipeline-fga</guid>
      <description>&lt;h2&gt;
  
  
  The failure, the cause, and the fix
&lt;/h2&gt;

&lt;p&gt;Every Open Graph social card on the site was returning a &lt;code&gt;404&lt;/code&gt; in production, even though the same code worked locally and in CI.&lt;/p&gt;

&lt;p&gt;The production rendering path depended on &lt;code&gt;sharp&lt;/code&gt;, libvips, and librsvg to turn Satori's SVG output into PNG bytes.&lt;/p&gt;

&lt;p&gt;A runtime security setting made libvips's SVG loader unavailable, so &lt;code&gt;sharp&lt;/code&gt; failed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Input buffer contains unsupported image format
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The existing fallback failed too.&lt;/p&gt;

&lt;p&gt;It simplified the card content, but still used the same rendering machinery. So when the renderer failed, the fallback immediately hit the same failure.&lt;/p&gt;

&lt;p&gt;The fix had two parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Replace the &lt;code&gt;sharp&lt;/code&gt; rasterisation path with &lt;code&gt;resvg&lt;/code&gt; WASM.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Replace the dynamic fallback with a static image read from disk.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The primary path became deterministic across environments, and the fallback stopped sharing the primary renderer's failure mode.&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%2Flt0g5c9z4lhumc0qdjdi.webp" 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%2Flt0g5c9z4lhumc0qdjdi.webp" alt="Open Graph rendering pipeline before and after, replacing the native Sharp and libvips SVG path with an explicit resvg WASM rasteriser" width="799" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The important bit:&lt;/strong&gt; Make the primary path deterministic. Make the fallback independent of the capability that just failed.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Want the full debugging story?
&lt;/h2&gt;

&lt;p&gt;The fix is fairly small once you know the cause.&lt;/p&gt;

&lt;p&gt;Getting there wasn't.&lt;/p&gt;

&lt;p&gt;I didn't start this investigation because someone reported broken social cards. I noticed unexplained errors repeatedly appearing on a Datadog dashboard, with no obvious user-facing symptom attached to them.&lt;/p&gt;

&lt;p&gt;I started working backwards from the timestamps.&lt;/p&gt;

&lt;p&gt;Datadog gave me the error clusters. Splunk gave me the request-level context. Correlating the two eventually led me to the Open Graph image requests and the production-only &lt;code&gt;404&lt;/code&gt;s.&lt;/p&gt;

&lt;p&gt;From there, I had to work out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;why the route was failing&lt;/li&gt;
&lt;li&gt;why a clean reproduction worked perfectly&lt;/li&gt;
&lt;li&gt;what was different about the production runtime&lt;/li&gt;
&lt;li&gt;why the fallback was dying milliseconds after the primary renderer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the rabbit hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 404 was coming from inside the route
&lt;/h2&gt;

&lt;p&gt;The application used Next.js's file-based Open Graph support with an &lt;code&gt;opengraph-image.tsx&lt;/code&gt; route.&lt;/p&gt;

&lt;p&gt;A failing request looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /profiles/&amp;lt;user&amp;gt;/opengraph-image-&amp;lt;hash&amp;gt;?&amp;lt;build&amp;gt;
-&amp;gt; 404 text/plain 9 bytes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine bytes mattered.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Not Found&lt;/code&gt; is nine bytes.&lt;/p&gt;

&lt;p&gt;A genuinely missing route returned the much larger Next.js HTML 404 page.&lt;/p&gt;

&lt;p&gt;This response was ours.&lt;/p&gt;

&lt;p&gt;That meant the route was running and something inside it was throwing.&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%2F37ubelw36pxx43kq94yd.webp" 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%2F37ubelw36pxx43kq94yd.webp" alt="Open Graph request reaching the route, rendering throwing inside it, and the application returning a nine-byte text 404" width="800" height="287"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That gave me the first useful boundary in the investigation.&lt;/p&gt;

&lt;p&gt;Now I needed to find what was throwing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The renderer hid an SVG stage
&lt;/h2&gt;

&lt;p&gt;My mental model going into this was basically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;JSX -&amp;gt; PNG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was too simple.&lt;/p&gt;

&lt;p&gt;In that production version, the actual path looked more like this:&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%2Frzojtfasddstcdxuw3v3.webp" 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%2Frzojtfasddstcdxuw3v3.webp" alt="Satori producing SVG before the renderer chooses Sharp when available or resvg WASM otherwise, with production taking the Sharp path" width="799" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Satori first turns JSX into SVG.&lt;/p&gt;

&lt;p&gt;A second stage then has to rasterise that SVG into PNG bytes.&lt;/p&gt;

&lt;p&gt;That intermediate format ended up being the most important detail in the incident.&lt;/p&gt;

&lt;p&gt;The renderer also selected its rasteriser at runtime. Conceptually, it did something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getSharp&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="nx"&gt;_sharp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;_sharp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;_sharp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sharp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&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="nx"&gt;_sharp&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;Then later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;svg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;satori&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&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="nx"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;pngBuffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;png&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBuffer&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;renderer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Resvg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&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;So the useful mental model wasn't "Next.js renders JSX into an image".&lt;/p&gt;

&lt;p&gt;It was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;JSX -&amp;gt; SVG -&amp;gt; PNG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those stages have different dependencies and different failure modes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Hidden stages matter:&lt;/strong&gt; When a library appears to do A to C, find out whether production actually depends on an intermediate B.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  I ruled out the obvious explanations first
&lt;/h2&gt;

&lt;p&gt;Before I had the useful stack trace, there were plenty of plausible explanations.&lt;/p&gt;

&lt;p&gt;I worked through the obvious ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bad JSX or CSS:&lt;/strong&gt; rendered fine locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specific profile data:&lt;/strong&gt; real production-like data rendered correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing fonts:&lt;/strong&gt; present in the standalone build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing WASM binaries:&lt;/strong&gt; present.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incorrect generated URL:&lt;/strong&gt; the URL was correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Middleware intercepting the request:&lt;/strong&gt; the request reached the image route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node version differences:&lt;/strong&gt; the major version matched.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also managed to manufacture one completely unrelated failure myself.&lt;/p&gt;

&lt;p&gt;The generated Open Graph URL contained a build-hash suffix. At one point I requested the bare path instead, and Next.js quite correctly returned its normal HTML 404.&lt;/p&gt;

&lt;p&gt;For about twenty minutes I thought I'd reproduced production.&lt;/p&gt;

&lt;p&gt;I'd reproduced my typo.&lt;/p&gt;

&lt;p&gt;Excellent progress.&lt;/p&gt;

&lt;p&gt;The investigation only started moving when I stopped theorising and followed the errors through the logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correlating Datadog and Splunk exposed the pattern
&lt;/h2&gt;

&lt;p&gt;Datadog was where I first noticed the failure, but Splunk had the request context I needed.&lt;/p&gt;

&lt;p&gt;For each cluster of errors in Datadog, I searched the same time window in Splunk and looked at what the application was doing around the failure.&lt;/p&gt;

&lt;p&gt;That's how I connected what initially looked like generic rendering errors to the Open Graph image requests.&lt;/p&gt;

&lt;p&gt;It also exposed a pattern I couldn't explain yet.&lt;/p&gt;

&lt;p&gt;Primary renders and fallback renders were failing in pairs, milliseconds apart, on the same runtime instance.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;primary: 32 failures
fallback: 32 failures
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;primary: 93 failures
fallback: 93 failures
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Failures were also spread across different avatar sources, which made an avatar-specific input problem much less likely.&lt;/p&gt;

&lt;p&gt;The paired failures would matter later.&lt;/p&gt;

&lt;p&gt;First, I needed to know where the renderer was dying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack trace moved the failure below Next.js
&lt;/h2&gt;

&lt;p&gt;The line that changed the investigation was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Input buffer contains unsupported image format
    at Sharp.toBuffer (.../sharp/dist/output.mjs:159:17)
    at render (.../@vercel/og/index.node.js:...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That immediately narrowed the search space.&lt;/p&gt;

&lt;p&gt;The request was going through &lt;code&gt;sharp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;More importantly, the buffer &lt;code&gt;sharp&lt;/code&gt; couldn't identify wasn't an uploaded avatar.&lt;/p&gt;

&lt;p&gt;It was the SVG Satori had just generated.&lt;/p&gt;

&lt;p&gt;The failing path was now much clearer.&lt;/p&gt;

&lt;p&gt;Satori successfully produced SVG, and the native rasterisation branch died inside &lt;code&gt;sharp&lt;/code&gt; before PNG output existed.&lt;/p&gt;

&lt;p&gt;That changed the question from:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What's wrong with the profile image?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why can't sharp decode a valid SVG in production?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In this path, &lt;code&gt;sharp&lt;/code&gt; hands the work to libvips. SVG support depends on the SVG loader being available there.&lt;/p&gt;

&lt;p&gt;If no loader claims the input, the error you get is the wonderfully specific:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Input buffer contains unsupported image format
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now I had something concrete to reproduce.&lt;/p&gt;

&lt;h2&gt;
  
  
  The working reproduction narrowed the problem
&lt;/h2&gt;

&lt;p&gt;I built a minimal reproduction with the same important pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;framework version&lt;/li&gt;
&lt;li&gt;package manager&lt;/li&gt;
&lt;li&gt;install flags&lt;/li&gt;
&lt;li&gt;standalone output&lt;/li&gt;
&lt;li&gt;Node version&lt;/li&gt;
&lt;li&gt;base architecture&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It rendered perfectly, including on two architectures.&lt;/p&gt;

&lt;p&gt;That ruled out most of the build.&lt;/p&gt;

&lt;p&gt;The framework version wasn't inherently broken. The standalone output wasn't inherently broken. The healthy container was taking the same &lt;code&gt;sharp&lt;/code&gt; path successfully.&lt;/p&gt;

&lt;p&gt;The remaining difference was much narrower:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;the environment the container was running in.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Negative evidence is still evidence:&lt;/strong&gt; A reproduction that doesn't reproduce can remove an entire class of possible causes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A runtime security setting reproduced the exact failure
&lt;/h2&gt;

&lt;p&gt;libvips can block operations it considers untrusted.&lt;/p&gt;

&lt;p&gt;In my reproduction, enabling that restriction made the SVG path fail exactly like production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker run &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;VIPS_BLOCK_UNTRUSTED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &amp;lt;image&amp;gt; node probe.js

sharp SVG-&amp;gt;PNG: FAILED
Input buffer contains unsupported image format
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the same image without the setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker run &amp;lt;image&amp;gt; node probe.js

sharp SVG-&amp;gt;PNG: OK
31270 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same image. Same application.&lt;/p&gt;

&lt;p&gt;One runtime setting changed the result.&lt;/p&gt;

&lt;p&gt;I could confirm the mechanism directly too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;sharp&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sharp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unblock&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;VipsForeignLoadSvg&lt;/span&gt;&lt;span class="dl"&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;Once the SVG loader was allowed again, the render succeeded.&lt;/p&gt;

&lt;p&gt;That proved the failure mechanism.&lt;/p&gt;

&lt;p&gt;The Open Graph path produced SVG, then passed it into a native image stack where SVG parsing was unavailable at runtime.&lt;/p&gt;

&lt;p&gt;There's one caveat worth calling out.&lt;/p&gt;

&lt;p&gt;I never established what introduced that restriction into the production environment. I couldn't find it in the application source, container image, or deployment configuration I investigated.&lt;/p&gt;

&lt;p&gt;I could prove the mechanism.&lt;/p&gt;

&lt;p&gt;I couldn't prove its provenance.&lt;/p&gt;

&lt;p&gt;That uncertainty influenced the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fallback used the same broken renderer
&lt;/h2&gt;

&lt;p&gt;There was already a fallback renderer.&lt;/p&gt;

&lt;p&gt;If the main card failed, the fallback removed most of the complicated inputs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no avatar&lt;/li&gt;
&lt;li&gt;no custom fonts&lt;/li&gt;
&lt;li&gt;no profile text&lt;/li&gt;
&lt;li&gt;no dynamic content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The assumption was reasonable.&lt;/p&gt;

&lt;p&gt;If some unexpected input broke the renderer, render something simpler.&lt;/p&gt;

&lt;p&gt;Except the input wasn't broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The renderer was.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fallback still crossed the same rendering boundary as the primary:&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%2F5woqihey2xw3444h3roh.webp" 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%2F5woqihey2xw3444h3roh.webp" alt="Primary and fallback Open Graph cards both passing through Satori and Sharp, causing both paths to fail when the SVG loader is blocked" width="800" height="507"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Suddenly the paired error counts made sense.&lt;/p&gt;

&lt;p&gt;Every time the primary failed because SVG parsing wasn't available, the fallback generated another SVG and sent it through the same broken machinery a few milliseconds later.&lt;/p&gt;

&lt;p&gt;The fallback had removed complexity from the input.&lt;/p&gt;

&lt;p&gt;It hadn't removed the failing dependency.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The fallback rule:&lt;/strong&gt; A fallback that shares the primary's failure mode isn't independent. It's the same system with fewer features.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  I made the fallback deliberately boring
&lt;/h2&gt;

&lt;p&gt;The replacement fallback doesn't render anything.&lt;/p&gt;

&lt;p&gt;It reads a pre-generated image from disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK_CARD_CONTENT_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderFallbackSocialCard&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public/card.jpg&lt;/span&gt;&lt;span class="dl"&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;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK_CARD_CONTENT_TYPE&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;That's it.&lt;/p&gt;

&lt;p&gt;No Satori. No SVG. No &lt;code&gt;sharp&lt;/code&gt;. No libvips.&lt;/p&gt;

&lt;p&gt;It's boring on purpose.&lt;/p&gt;

&lt;p&gt;If the dynamic rendering stack catches fire, the fallback shouldn't politely walk back into the fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I replaced sharp with resvg WASM
&lt;/h2&gt;

&lt;p&gt;For the main renderer, I had two practical options.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 1: allow SVG parsing again
&lt;/h3&gt;

&lt;p&gt;Mechanically, this was the quickest path.&lt;/p&gt;

&lt;p&gt;But I still didn't know why the production environment had restricted that capability in the first place.&lt;/p&gt;

&lt;p&gt;I didn't want an Open Graph feature to override an environment-level security control just to preserve the existing implementation, especially when I could remove that dependency from the path entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: remove libvips from this path
&lt;/h3&gt;

&lt;p&gt;The other option was to remove libvips from this rendering path entirely and make &lt;code&gt;resvg&lt;/code&gt; WASM the explicit rasteriser.&lt;/p&gt;

&lt;p&gt;That's the route I took.&lt;/p&gt;

&lt;p&gt;The implementation was relatively small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;join&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:path&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;initWasm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Resvg&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@resvg/resvg-wasm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;satori&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;satori&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WASM_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node_modules/@resvg/resvg-wasm/index_bg.wasm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;wasmReady&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;initResvg&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;wasmReady&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;WASM_PATH&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initWasm&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;wasmReady&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderElementToPng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fonts&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;RenderOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;svg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;satori&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;fonts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;initResvg&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resvg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Resvg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;fitTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;width&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;width&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rendered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;resvg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rendered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asPng&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;rendered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;free&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="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;resvg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;free&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;The important change wasn't the syntax.&lt;/p&gt;

&lt;p&gt;It was the runtime behaviour.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;resvg&lt;/code&gt; became the one explicit rasteriser.&lt;/p&gt;

&lt;p&gt;No native image loader in this path. No environment-dependent SVG decoder. No silent rasteriser selection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The WASM fix created a packaging problem
&lt;/h2&gt;

&lt;p&gt;Of course, replacing the renderer wasn't the end of it.&lt;/p&gt;

&lt;p&gt;Reading the WASM file via &lt;code&gt;require.resolve()&lt;/code&gt; made the bundler try to process it as a module, which failed.&lt;/p&gt;

&lt;p&gt;Reading the binary directly by path avoided that problem, but then Next.js's dependency tracer couldn't infer that the file was required at runtime.&lt;/p&gt;

&lt;p&gt;The standalone output didn't contain it.&lt;/p&gt;

&lt;p&gt;I had to make that dependency explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;serverExternalPackages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@resvg/resvg-wasm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;

&lt;span class="nx"&gt;outputFileTracingIncludes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/**&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./node_modules/@resvg/resvg-wasm/index_bg.wasm&lt;/span&gt;&lt;span class="dl"&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;Configuration like this is exactly the sort of thing that gets removed six months later because nobody remembers why it's there.&lt;/p&gt;

&lt;p&gt;So I added a build assertion too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; .next/standalone/node_modules/@resvg/resvg-wasm/index_bg.wasm &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"resvg wasm missing from standalone output"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I verified the assertion in both directions.&lt;/p&gt;

&lt;p&gt;Remove the tracing configuration and the build fails.&lt;/p&gt;

&lt;p&gt;That turns a hidden runtime dependency into something the build can enforce.&lt;/p&gt;

&lt;h2&gt;
  
  
  One green test had never rendered an image
&lt;/h2&gt;

&lt;p&gt;The migration also exposed a test problem.&lt;/p&gt;

&lt;p&gt;There was already a test that appeared to cover image rendering. It created an &lt;code&gt;ImageResponse&lt;/code&gt; and asserted against it.&lt;/p&gt;

&lt;p&gt;The problem was that &lt;code&gt;ImageResponse&lt;/code&gt; rendered lazily.&lt;/p&gt;

&lt;p&gt;Constructing the response didn't force Satori to execute.&lt;/p&gt;

&lt;p&gt;The test was green because the failing operation had never happened.&lt;/p&gt;

&lt;p&gt;Once I changed the path to render eagerly, the test failed immediately.&lt;/p&gt;

&lt;p&gt;A test called "render image" that never renders the image is a particularly optimistic form of testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  I verified the real renderer, not just a successful response
&lt;/h2&gt;

&lt;p&gt;The static fallback introduced one final trap.&lt;/p&gt;

&lt;p&gt;Once it shipped, every Open Graph request returned a valid-looking image again.&lt;/p&gt;

&lt;p&gt;From the outside, the feature looked healthy.&lt;/p&gt;

&lt;p&gt;That still didn't prove the dynamic renderer was working.&lt;/p&gt;

&lt;p&gt;It could remain completely broken while every request quietly returned the fallback.&lt;/p&gt;

&lt;p&gt;So I verified the responses themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  Before the renderer fix
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Content type&lt;/td&gt;
&lt;td&gt;&lt;code&gt;image/jpeg&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;425,081&lt;/code&gt; bytes every time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;45/45&lt;/code&gt; fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  After the renderer fix
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Content type&lt;/td&gt;
&lt;td&gt;&lt;code&gt;image/png&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;roughly &lt;code&gt;143KB&lt;/code&gt; to &lt;code&gt;245KB&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;45/45&lt;/code&gt; dynamically rendered&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fallback responses were byte-for-byte identical.&lt;/p&gt;

&lt;p&gt;The dynamic cards varied with each profile.&lt;/p&gt;

&lt;p&gt;That variation was evidence that I was exercising the real renderer again.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Verification matters:&lt;/strong&gt; A valid response only proves that &lt;em&gt;some&lt;/em&gt; path worked. Verify the behavioural property that distinguishes the path you actually fixed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I'd do earlier next time
&lt;/h2&gt;

&lt;p&gt;A few things from this investigation are going straight into my debugging playbook.&lt;/p&gt;

&lt;h3&gt;
  
  
  Correlate telemetry before guessing
&lt;/h3&gt;

&lt;p&gt;The Datadog errors weren't very useful by themselves.&lt;/p&gt;

&lt;p&gt;Correlating their timestamps with Splunk turned an unexplained counter into an actual request path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Look for hidden stages
&lt;/h3&gt;

&lt;p&gt;"JSX to PNG" hid an SVG conversion with its own runtime dependencies.&lt;/p&gt;

&lt;p&gt;The intermediate representation was where the incident lived.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make implementation selection explicit
&lt;/h3&gt;

&lt;p&gt;A library silently choosing between implementations is convenient until production takes a different path from your laptop.&lt;/p&gt;

&lt;p&gt;For an important path, the chosen implementation should be observable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Design fallbacks around failure boundaries
&lt;/h3&gt;

&lt;p&gt;Removing input complexity helps when the input is the problem.&lt;/p&gt;

&lt;p&gt;It doesn't help when the capability underneath it has disappeared.&lt;/p&gt;

&lt;p&gt;A useful fallback should avoid the failed step wherever practical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson I kept
&lt;/h2&gt;

&lt;p&gt;Technically, this incident came down to an SVG loader being unavailable inside the production runtime.&lt;/p&gt;

&lt;p&gt;That's not the part I expect to remember.&lt;/p&gt;

&lt;p&gt;I started with unexplained errors on a Datadog dashboard, correlated them against Splunk, connected them to broken Open Graph images, followed the failure through Satori and &lt;code&gt;sharp&lt;/code&gt;, reproduced the runtime behaviour in isolation, and replaced the native rasterisation path with WASM.&lt;/p&gt;

&lt;p&gt;But the most useful discovery was the fallback.&lt;/p&gt;

&lt;p&gt;I'd assumed it gave the feature resilience because it rendered something simpler.&lt;/p&gt;

&lt;p&gt;It didn't.&lt;/p&gt;

&lt;p&gt;It depended on the exact capability that had just failed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fallback wasn't independent. It was a smaller version of the same pipeline.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Thanks for reading ✌️&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://nextjs.org/docs/app/getting-started/metadata-and-og-images" rel="noopener noreferrer"&gt;Metadata and OG images - Next.js&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/vercel/satori" rel="noopener noreferrer"&gt;Satori - JSX and CSS to SVG&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sharp.pixelplumbing.com/api-utility/#unblock" rel="noopener noreferrer"&gt;Sharp global properties - block and unblock&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.libvips.org/API/8.18/func.block_untrusted_set.html" rel="noopener noreferrer"&gt;libvips &lt;code&gt;VIPS_BLOCK_UNTRUSTED&lt;/code&gt; behaviour&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/thx/resvg-js#webassembly" rel="noopener noreferrer"&gt;resvg-js and the WebAssembly renderer&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>nextjs</category>
      <category>debugging</category>
      <category>webassembly</category>
      <category>webdev</category>
    </item>
    <item>
      <title>When Kubernetes Pods Try to Run One Database Migration</title>
      <dc:creator>Sahan</dc:creator>
      <pubDate>Fri, 14 Aug 2026 22:23:00 +0000</pubDate>
      <link>https://dev.to/sahan/when-kubernetes-pods-try-to-run-one-database-migration-361d</link>
      <guid>https://dev.to/sahan/when-kubernetes-pods-try-to-run-one-database-migration-361d</guid>
      <description>&lt;p&gt;This startup command looked fine while our application had one replica:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;prisma migrate deploy &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; node server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we scaled the Deployment to three pods.&lt;/p&gt;

&lt;p&gt;All three pods started together. All three ran the migration command. Prisma used a PostgreSQL advisory lock to protect the migration, so the database was not corrupted, but the losing pods waited for the lock and eventually timed out. Kubernetes saw failed containers, restarted them, and filled the rollout logs with errors.&lt;/p&gt;

&lt;p&gt;One pod was doing useful work. The other two were turning expected contention into application failures.&lt;/p&gt;

&lt;p&gt;The clean solution is a migration Job that runs before the Deployment. Our release platform could only start one application workload, so we used a small PostgreSQL-backed coordinator instead.&lt;/p&gt;

&lt;p&gt;This post shows the complete pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem in one picture
&lt;/h2&gt;

&lt;p&gt;A Deployment creates copies of a long-running process. A database migration is one-shot work.&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%2Fd7xmws1dfv6w3w646mvn.webp" 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%2Fd7xmws1dfv6w3w646mvn.webp" alt="Three Kubernetes pods use one PostgreSQL advisory lock so only one pod runs Prisma migrations while the others wait, reconcile, and then start the application" width="799" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The coordinator gives every pod the same startup protocol:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Try to acquire one application-specific advisory lock.&lt;/li&gt;
&lt;li&gt;If the lock is busy, close the connection, wait with jitter, and try again.&lt;/li&gt;
&lt;li&gt;If the lock is acquired, run &lt;code&gt;prisma migrate deploy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Release the lock and start the application.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every pod runs the migration command after acquiring the lock. For the first pod it applies pending migrations. For later pods it is a no-op. This matters because a released lock only proves that the previous database session ended. It does not prove the previous migration succeeded.&lt;/p&gt;


&lt;p&gt;Reconcile after waiting&lt;/p&gt;
&lt;br&gt;
  &lt;p&gt;A released lock does not mean the previous migration worked. Every pod that acquires the lock should reconcile the database to the expected migration state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use a Job when your platform supports one
&lt;/h2&gt;

&lt;p&gt;Before adding coordination code, check whether the delivery system can run an ordered pre-deployment Job.&lt;/p&gt;

&lt;p&gt;A Job is the better default because it gives the migration its own status, logs, image and database credentials. The application Deployment does not need schema-changing permissions, and a failed migration stops the rollout at the correct boundary.&lt;/p&gt;

&lt;p&gt;The coordinator below is for the awkward case where the platform can only deploy the application workload. That was our constraint.&lt;/p&gt;

&lt;p&gt;Stay in the loop&lt;/p&gt;

&lt;p&gt;Enjoying this article?&lt;/p&gt;

&lt;p&gt;Get practical engineering notes in your inbox, usually once or twice a month.&lt;/p&gt;

&lt;p&gt;Get new posts →&lt;/p&gt;
&lt;h2&gt;
  
  
  Add the coordinator
&lt;/h2&gt;

&lt;p&gt;The application already contained Prisma. The coordinator only needed the PostgreSQL client:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;pg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Create &lt;code&gt;prisma/migration-coordinator.mjs&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;spawn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:child_process&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fileURLToPath&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connectionString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DIRECT_DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lockName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MIGRATION_LOCK_NAME&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;web:public:prisma-migrations&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lockId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lockName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readBigInt64BE&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;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;waitTimeoutMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MIGRATION_WAIT_TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;300000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;retryDelayMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MIGRATION_RETRY_DELAY_MS&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prismaCli&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fileURLToPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../node_modules/.bin/prisma&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DIRECT_DATABASE_URL is required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runPrismaMigrateDeploy&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;child&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prismaCli&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;migrate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deploy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;stdio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inherit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;env&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="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;connectionString&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="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;exit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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="nx"&gt;code&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="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`prisma migrate deploy exited with code &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;waitTimeoutMs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;migrationComplete&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;migrationComplete&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;connectionTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;acquired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT pg_try_advisory_lock($1::bigint) AS acquired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lockId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;acquired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&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;span class="nx"&gt;acquired&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&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="nx"&gt;acquired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;migration lock acquired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runPrismaMigrateDeploy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;database migrations are current&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;migrationComplete&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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="k"&gt;finally&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="nx"&gt;acquired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT pg_advisory_unlock($1::bigint)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lockId&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed to release migration lock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;migrationComplete&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;break&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="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timed out waiting for the migration lock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jitterMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;migration lock busy; waiting before retry&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;retryDelayMs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;jitterMs&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;There are four details worth keeping:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pg_try_advisory_lock&lt;/code&gt; returns immediately instead of keeping a waiting connection open.&lt;/li&gt;
&lt;li&gt;The same database connection stays open while the Prisma subprocess runs. Session-level advisory locks belong to the connection that acquired them.&lt;/li&gt;
&lt;li&gt;Every lock owner runs &lt;code&gt;migrate deploy&lt;/code&gt;. Already-applied migrations make it a no-op.&lt;/li&gt;
&lt;li&gt;A failed migration rejects the script, so the application does not start against an unknown schema.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use a direct PostgreSQL connection for &lt;code&gt;DIRECT_DATABASE_URL&lt;/code&gt;. Transaction-mode poolers do not preserve session state, so they are the wrong boundary for a session-level advisory lock.&lt;/p&gt;

&lt;p&gt;The lock name is not a secret, but it must be stable. The script hashes the readable name into the signed 64-bit integer PostgreSQL expects. Every revision of this application should use the same name for the same database and schema. Different applications sharing a database should use different names.&lt;/p&gt;

&lt;p&gt;After adding the coordinator, expected contention becomes an ordinary waiting path:&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%2F63pox7ruy05rsllk1ej3.webp" 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%2F63pox7ruy05rsllk1ej3.webp" alt="Pod A runs the migration while Pods B and C close their database connections, wait with jitter, retry, reconcile the schema and then start" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Run it before the server
&lt;/h2&gt;

&lt;p&gt;Update the container command so the server starts only after coordination succeeds:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.example.com/web:abc123&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
              &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;node prisma/migration-coordinator.mjs &amp;amp;amp;&amp;amp;amp; exec node server.js&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DIRECT_DATABASE_URL&lt;/span&gt;
              &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web-database&lt;/span&gt;
                  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;direct-url&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MIGRATION_LOCK_NAME&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;web:public:prisma-migrations"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MIGRATION_WAIT_TIMEOUT_MS&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;300000"&lt;/span&gt;
          &lt;span class="na"&gt;startupProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/health/startup&lt;/span&gt;
              &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;
            &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
            &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;72&lt;/span&gt;
          &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/health/ready&lt;/span&gt;
              &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;
            &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;code&gt;exec&lt;/code&gt; replaces the shell with the Node process after the migration finishes. That lets the application receive termination signals directly as PID 1.&lt;/p&gt;

&lt;p&gt;The coordinator calls the Prisma binary already installed in the image. Keep the Prisma CLI in the production image rather than downloading it during startup. If your image uses a different project layout, adjust &lt;code&gt;prismaCli&lt;/code&gt; to match it.&lt;/p&gt;

&lt;p&gt;The startup probe allows six minutes for lock waiting, migration and application startup. Set that budget from your slowest expected migration rather than copying this number. Readiness still decides when the running pod can receive traffic.&lt;/p&gt;
&lt;h2&gt;
  
  
  What happens during a rollout
&lt;/h2&gt;

&lt;p&gt;With three new pods, the rollout now looks like this:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pod-a migration lock acquired
pod-b migration lock busy; waiting before retry
pod-c migration lock busy; waiting before retry

pod-a database migrations are current
pod-a application starts

pod-b migration lock acquired
pod-b no pending migrations to apply
pod-b application starts

pod-c migration lock acquired
pod-c no pending migrations to apply
pod-c application starts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Expected contention is logged as waiting, not as a migration failure. If the winning pod disappears, PostgreSQL releases its session lock when the connection closes. Another pod acquires the lock and runs &lt;code&gt;migrate deploy&lt;/code&gt; again.&lt;/p&gt;

&lt;p&gt;This has the same shape as the shared-state problem in &lt;a href="https://www.sahansera.dev/scaling-an-agentic-coding-sdk-what-concurrency-actually-costs/" rel="noopener noreferrer"&gt;Scaling an Agentic Coding SDK: What Concurrency Actually Costs&lt;/a&gt;. Sequential execution had hidden an ownership problem. Adding replicas made it visible.&lt;/p&gt;
&lt;h2&gt;
  
  
  The lock does not make breaking migrations safe
&lt;/h2&gt;

&lt;p&gt;The advisory lock serialises migration commands. It does not stop old pods from serving traffic while a new pod changes the schema.&lt;/p&gt;

&lt;p&gt;Rolling deployments still need expand-and-contract migrations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the new schema in a backward-compatible form.&lt;/li&gt;
&lt;li&gt;Deploy code that works with both representations.&lt;/li&gt;
&lt;li&gt;Backfill data if required.&lt;/li&gt;
&lt;li&gt;Switch reads and writes.&lt;/li&gt;
&lt;li&gt;Remove the old schema after old pods are gone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I covered the same compatibility problem in more detail in &lt;a href="https://www.sahansera.dev/replacing-critical-data-path-without-flag-day/" rel="noopener noreferrer"&gt;Replacing a Critical Data Path Without a Flag Day&lt;/a&gt;.&lt;/p&gt;


&lt;p&gt;Coordination is not compatibility&lt;/p&gt;
&lt;br&gt;
  &lt;p&gt;The lock keeps migration commands from colliding. Expand-and-contract changes keep old and new application versions compatible during the rollout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before shipping it
&lt;/h2&gt;

&lt;p&gt;Check these six things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A pre-deployment Job really is unavailable.&lt;/li&gt;
&lt;li&gt;The lock uses a direct PostgreSQL session.&lt;/li&gt;
&lt;li&gt;The lock name is stable and unique to the application and schema.&lt;/li&gt;
&lt;li&gt;Waiting has jitter and an overall deadline.&lt;/li&gt;
&lt;li&gt;A failed migration prevents the application from starting.&lt;/li&gt;
&lt;li&gt;Schema changes remain compatible with old pods during the rollout.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the whole pattern. One pod applies the migration. The others wait, check the same desired state, and then start normally.&lt;/p&gt;

&lt;p&gt;The code is small because PostgreSQL already owns the hard guarantee: only one session can hold the advisory lock at a time.&lt;/p&gt;

&lt;p&gt;Thanks for reading ✌️&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.prisma.io/docs/orm/prisma-migrate/workflows/development-and-production" rel="noopener noreferrer"&gt;Prisma Migrate in development and production&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.prisma.io/docs/cli/migrate/deploy" rel="noopener noreferrer"&gt;Prisma &lt;code&gt;migrate deploy&lt;/code&gt; reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS" rel="noopener noreferrer"&gt;PostgreSQL advisory lock functions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/workloads/controllers/job/" rel="noopener noreferrer"&gt;Kubernetes Jobs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/configuration/liveness-readiness-startup-probes/" rel="noopener noreferrer"&gt;Kubernetes liveness, readiness and startup probes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>architecture</category>
      <category>tutorial</category>
      <category>database</category>
    </item>
    <item>
      <title>Scaling an Agentic Coding SDK: What Concurrency Actually Costs</title>
      <dc:creator>Sahan</dc:creator>
      <pubDate>Wed, 12 Aug 2026 11:51:00 +0000</pubDate>
      <link>https://dev.to/sahan/scaling-an-agentic-coding-sdk-what-concurrency-actually-costs-199</link>
      <guid>https://dev.to/sahan/scaling-an-agentic-coding-sdk-what-concurrency-actually-costs-199</guid>
      <description>&lt;p&gt;I previously wrote about &lt;a href="https://www.sahansera.dev/most-dependency-vulnerability-remediation-is-a-packaging-problem/" rel="noopener noreferrer"&gt;building an AI-assisted dependency vulnerability fixer&lt;/a&gt;. It used the &lt;a href="https://github.com/github/copilot-sdk" rel="noopener noreferrer"&gt;GitHub Copilot SDK&lt;/a&gt; to start a repository-scoped coding agent for the fixes that could not be handled reliably by a deterministic packaging change. Each agent took a known finding, made the smallest safe change it could, validated the result, and opened a pull request for the repository owner.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.sahansera.dev/safe-repository-maintenance-agent-dotnet/" rel="noopener noreferrer"&gt;.NET&lt;/a&gt; and &lt;a href="https://www.sahansera.dev/safe-repository-maintenance-agent-python/" rel="noopener noreferrer"&gt;Python&lt;/a&gt; tutorials isolate the permission boundary behind this kind of work: one agent, one repository, and explicit approval for side effects. This article looks at the next operational problem from the production fixer - what changes when many such workloads run concurrently.&lt;/p&gt;

&lt;p&gt;The first version processed one repository at a time. A representative batch of 30 repositories took roughly 46 minutes. With a worker pool of five, it took about 12.&lt;/p&gt;

&lt;p&gt;Replacing the sequential loop was the easy part. Before those five agents could run safely, each needed an isolated workspace, reliable cleanup, resource limits, rate limiting, and enough telemetry to show whether more concurrency helped. Multiple service replicas also needed durable job ownership and safe retries.&lt;/p&gt;

&lt;p&gt;That work changed my mental model:&lt;/p&gt;


&lt;p&gt;The mental model&lt;/p&gt;
&lt;br&gt;
  &lt;p&gt;Agent concurrency is not a larger number of requests. It is a larger number of partially trusted computers operating at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  An agent session is a workload, not a request
&lt;/h2&gt;

&lt;p&gt;When I say “agent” in this article, I mean one live Copilot SDK client and session operating against one repository. In this setup, the client started the Copilot runtime and the session drove its model and repository tools. It was not a name for one model API request.&lt;/p&gt;

&lt;p&gt;An HTTP request usually borrows a connection, does bounded work, and returns. A Copilot session can live for minutes and contain many model and tool turns. It keeps a transcript in memory, drives a shell, mutates a checkout, creates subprocesses, uses upstream capacity, and may hold credentials that can create branches and pull requests.&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%2Frxtpeo4leb9w7xm55n8l.webp" 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%2Frxtpeo4leb9w7xm55n8l.webp" alt="One agent job reserves a session, workspace, subprocesses, credentials, and upstream capacity for its lifetime, so concurrency multiplies the whole resource footprint rather than only the number of API requests" width="799" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This introduces familiar distributed-systems questions. What isolates jobs? Who owns each resource? What happens when a worker disappears? Can a job run twice? Which resource sets the safe concurrency limit? Did a timed-out operation fail before or after changing an external system?&lt;/p&gt;

&lt;p&gt;The novelty is in who chooses the commands. The operational concerns are not new.&lt;/p&gt;

&lt;p&gt;Three decisions need to remain separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Isolation:&lt;/strong&gt; Each repository gets its own session and workspace, ideally inside a disposable sandbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency:&lt;/strong&gt; The worker count is bounded by the first resource likely to run out, not simply CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistence:&lt;/strong&gt; Agent sessions can be ephemeral while job identities, attempts, and external effects remain durable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sequential execution had been protecting shared state
&lt;/h2&gt;

&lt;p&gt;The first implementation was intentionally boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;repository&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;repositories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fixRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repository&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;It helped validate prompts, permissions, branch strategy, and review before concurrency obscured product mistakes. It also hid unsafe assumptions.&lt;/p&gt;

&lt;p&gt;The prompt cloned every repository into &lt;code&gt;/tmp/agent-workdir&lt;/code&gt;. With two agents, one install could rewrite the lockfile another was preparing to commit. One cleanup could delete the other job’s files.&lt;/p&gt;

&lt;p&gt;The service now allocates the path and passes it to the agent as job data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;randomUUID&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;safeName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;branch&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9_-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;workdir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/tmp/agent-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;safeName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same audit applies to ports, branches, cache keys, and temporary filenames. Any literal value becomes shared state when two jobs can use it.&lt;/p&gt;

&lt;p&gt;A unique directory prevents accidental overlap, but it is not a security boundary. Repository-controlled code still needs a disposable sandbox with bounded access to the host and network.&lt;/p&gt;

&lt;p&gt;The service did not call an LLM endpoint directly. For each job, it started a GitHub Copilot SDK client inside the unique working directory and created one agent session. Cleanup also needed to survive failure. The original code disconnected the session only after successful work, so an exception leaked both session and runtime resources.&lt;/p&gt;

&lt;p&gt;A simplified version of the corrected lifecycle looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;CopilotClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CopilotSession&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@github/copilot-sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runCopilotRemediation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;workdir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CopilotClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;workingDirectory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;workdir&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CopilotSession&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createSession&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;systemMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;append&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;remediationInstructions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;onPermissionRequest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;remediationPolicy&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendAndWait&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;buildRemediationTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&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="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;removeWorkspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workdir&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;&lt;code&gt;client.start()&lt;/code&gt; launches or connects to the Copilot runtime. &lt;code&gt;createSession()&lt;/code&gt; gives the job its own transcript and agent loop. &lt;code&gt;sendAndWait()&lt;/code&gt; lets that loop continue through repository reads, edits, shell commands, and validation until the session becomes idle. A worker pool of five can therefore mean five Copilot runtimes, five sessions, five mutable checkouts, and all of their subprocesses operating at once.&lt;/p&gt;

&lt;p&gt;The component that acquires a resource owns its lifecycle. Concurrency makes violations more frequent, not more complicated.&lt;/p&gt;

&lt;p&gt;Stay in the loop&lt;/p&gt;

&lt;p&gt;Enjoying this article?&lt;/p&gt;

&lt;p&gt;Get practical engineering notes in your inbox, usually once or twice a month.&lt;/p&gt;

&lt;p&gt;Get new posts →&lt;/p&gt;
&lt;h2&gt;
  
  
  Bound concurrency with evidence
&lt;/h2&gt;

&lt;p&gt;This is concise but unsafe:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repositories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fixRepository&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;It lets the input size set infrastructure policy. Thirty jobs may work; 300 may exhaust memory, fill disk, or trigger rate limits.&lt;/p&gt;

&lt;p&gt;I used a small worker pool instead:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runPool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;Promise&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;next&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;recordFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;error&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;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;worker&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;The cap is explicit, one repository failure does not cancel unrelated work, and the batch drains before completion is reported.&lt;/p&gt;

&lt;p&gt;I started with five workers. Five is not a generally safe number for coding agents. It was a conservative operating point for this workload.&lt;/p&gt;

&lt;p&gt;The real cap is the smallest limit implied by memory, ephemeral storage, subprocesses, file descriptors, provider requests and tokens, source-control operations, network bandwidth, spend, and acceptable blast radius. For memory, the rough calculation is:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;memory_cap = floor(
  (container_limit - service_baseline - safety_headroom)
  / p95_incremental_memory_per_session
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;A job-level cap does not replace API rate limiting. Five agents can still push branches or create pull requests simultaneously. The source-control client must independently honour rate-limit headers, &lt;code&gt;Retry-After&lt;/code&gt;, and backoff.&lt;/p&gt;


&lt;p&gt;Capacity needs evidence&lt;/p&gt;
&lt;br&gt;
  &lt;p&gt;“Five at a time” is not a capacity plan. “Five because memory is the first measured budget we approach” is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local limits stop being global limits
&lt;/h2&gt;

&lt;p&gt;A process-local cap works only while there is one process. With a cap of five and four replicas, the service can create 20 live sessions:&lt;br&gt;
&lt;/p&gt;

&lt;pre class="highlight plaintext"&gt;&lt;code&gt;effective_concurrency = replicas × per_replica_cap
&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;An autoscaler can increase that number precisely when an upstream system is already under pressure.&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%2Fd4mwch17hj2ifwzperql.webp" 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%2Fd4mwch17hj2ifwzperql.webp" alt="A single process can coordinate a local worker pool, but replicated workers need durable job claims and a global limiter to protect shared provider and source-control budgets" width="799" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At this point, job ownership moves to a durable queue or database table. A worker atomically claims a job for a limited period, renews the lease while running, and records the outcome before acknowledging completion. If it disappears, the lease expires and another worker can retry. A global limiter protects shared provider and credential budgets.&lt;/p&gt;

&lt;p&gt;The agent session and workspace remain disposable. The durable state is the job identity, lease, attempt count, and record of external effects.&lt;/p&gt;

&lt;p&gt;Retries need reconciliation. Suppose the agent opens a pull request, but the response is lost before the job records success. A retry can create a duplicate.&lt;/p&gt;

&lt;p&gt;I gave each remediation a stable idempotency key derived from the repository and requested change. It enforces one active job, supports a stable branch name, and lets a retry find an existing branch or pull request. An atomic claim or uniqueness constraint closes the race that an existence check alone cannot.&lt;/p&gt;

&lt;p&gt;Stateless workers are useful. Statelessness means any worker can continue the protocol, not that the system remembers nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrency needs gauges and guardrails
&lt;/h2&gt;

&lt;p&gt;Total batch duration is not enough to tune the pool. I needed queue wait and execution time separately, active sessions, peak memory, workspace size, subprocess count, cost per job, upstream throttling, retries, cleanup failures, and orphaned sessions.&lt;/p&gt;

&lt;p&gt;Three questions make those measurements useful:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does active work regularly reach the cap?&lt;/li&gt;
&lt;li&gt;Is queue time growing while constrained resources still have headroom?&lt;/li&gt;
&lt;li&gt;Do failures, latency, throttling, or resource pressure rise with inflight work?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the pool never fills, a higher cap will not help. If queues grow while resources remain healthy, there may be room. If failures rise with inflight work, the system has found a boundary.&lt;/p&gt;

&lt;p&gt;Security belongs in the same discussion. The &lt;a href="https://www.sahansera.dev/most-dependency-vulnerability-remediation-is-a-packaging-problem/" rel="noopener noreferrer"&gt;dependency fixer&lt;/a&gt; already treated repositories, install scripts, and tests as untrusted input. Concurrency multiplies that exposure. Each job needs bounded CPU, memory, processes, disk, time, network access, and short-lived repository-scoped credentials. It should have no ambient infrastructure credentials or permission to merge.&lt;/p&gt;

&lt;p&gt;Audit trails also need redaction. Terminal output, environment dumps, remote URLs, and package-manager logs can contain credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why five workers were not five times faster
&lt;/h2&gt;

&lt;p&gt;For one representative batch of 30 repositories:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Sequential&lt;/th&gt;
&lt;th&gt;Pool of five&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Clone and dependency setup&lt;/td&gt;
&lt;td&gt;~10 minutes&lt;/td&gt;
&lt;td&gt;~4 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent inspection and editing&lt;/td&gt;
&lt;td&gt;~22 minutes&lt;/td&gt;
&lt;td&gt;~5 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push and pull-request creation&lt;/td&gt;
&lt;td&gt;~12 minutes&lt;/td&gt;
&lt;td&gt;~3 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Artificial inter-job delay&lt;/td&gt;
&lt;td&gt;~1.5 minutes&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~46 minutes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~12 minutes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is about a 3.8-times speedup. It is an operational measurement, not a benchmark. Removing an artificial delay contributed, and the rest was limited by uneven job duration, disk and network contention, provider latency, and source-control operations.&lt;/p&gt;

&lt;p&gt;The goal is not maximum concurrency. It is the best useful throughput inside the safety, cost, and reliability budgets.&lt;/p&gt;

&lt;p&gt;I would scale the next service in the same order: isolate each job, make cleanup unconditional, add a bounded local pool, measure it, then introduce durable claims and reconciliation before adding replicas.&lt;/p&gt;

&lt;p&gt;The worker pool reduced this batch from roughly 46 minutes to 12. Everything else was the cost of making that number safe.&lt;/p&gt;

&lt;p&gt;That is what concurrency actually costs.&lt;/p&gt;

&lt;p&gt;Thanks for reading ✌️&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/github/copilot-sdk/tree/main/nodejs" rel="noopener noreferrer"&gt;GitHub Copilot SDK for Node.js and TypeScript&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api" rel="noopener noreferrer"&gt;GitHub REST API rate limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api" rel="noopener noreferrer"&gt;GitHub REST API best practices&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>github</category>
      <category>javascript</category>
      <category>ai</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Build a Safe Repository Maintenance Agent with GitHub Copilot and Microsoft Agent Framework in Python</title>
      <dc:creator>Sahan</dc:creator>
      <pubDate>Mon, 10 Aug 2026 22:30:00 +0000</pubDate>
      <link>https://dev.to/sahan/build-a-safe-repository-maintenance-agent-with-github-copilot-and-microsoft-agent-framework-in-59fh</link>
      <guid>https://dev.to/sahan/build-a-safe-repository-maintenance-agent-with-github-copilot-and-microsoft-agent-framework-in-59fh</guid>
      <description>&lt;p&gt;In the &lt;a href="https://www.sahansera.dev/safe-repository-maintenance-agent-dotnet/" rel="noopener noreferrer"&gt;.NET tutorial&lt;/a&gt;, we built a repository agent that could inspect a checkout, propose a one-line repair, run its tests, and report the result. Every write and shell command required explicit approval.&lt;/p&gt;

&lt;p&gt;Now we will build the same agent in Python.&lt;/p&gt;

&lt;p&gt;The outcome and safety policy stay identical on purpose - I didn’t want to quietly pick an easier demo just because Python made one more convenient. Both implementations work against the same failing fixture, get the same instructions, stop at the same approval boundaries, and have to pass the same tests. Only the host-language implementation changes.&lt;/p&gt;

&lt;p&gt;The result is an async Python command-line application using the GitHub Copilot harness for repository work and Microsoft Agent Framework for the agent abstraction, streaming, sessions, and telemetry.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Code repository:&lt;/strong&gt; The complete Python and .NET implementations use the same fixture and are available at &lt;a href="https://github.com/sahansera/safe-repository-maintenance-agent" rel="noopener noreferrer"&gt;github.com/sahansera/safe-repository-maintenance-agent&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&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%2Fomag6detuw0x3ee6jmu2.webp" 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%2Fomag6detuw0x3ee6jmu2.webp" alt="The agent proposes, the host decides: the same permission-gated loop as the .NET version" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What we are building
&lt;/h2&gt;

&lt;p&gt;The agent receives a local repository path and a maintenance task. Its system instructions require it to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Work only inside that repository.&lt;/li&gt;
&lt;li&gt;Read its &lt;code&gt;AGENTS.md&lt;/code&gt; before changing anything.&lt;/li&gt;
&lt;li&gt;Make the smallest coherent repair.&lt;/li&gt;
&lt;li&gt;Avoid the network, package installation, commits, pushes, and pull requests.&lt;/li&gt;
&lt;li&gt;Run focused validation.&lt;/li&gt;
&lt;li&gt;Report the changed files, commands, and result.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The included fixture contains a small JavaScript function. The agent is written in Python, but the target repository does not have to be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&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;One test expects ordinary title normalization. A second expects whitespace around the title to be ignored. The second test fails until the implementation trims the input.&lt;/p&gt;

&lt;p&gt;Using a language-neutral target is deliberate. A Python agent can maintain a .NET, JavaScript, Go, or documentation repository. The agent host language determines how we integrate and operate the harness, not which source files the harness can understand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites and package setup
&lt;/h2&gt;

&lt;p&gt;You need Python 3.11 or later and an active GitHub Copilot subscription. The example uses Python 3.12 as its documented baseline and was also verified with Python 3.13.&lt;/p&gt;

&lt;p&gt;Here’s the environment I actually tested this against:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[project]&lt;/span&gt;
&lt;span class="py"&gt;requires-python&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="py"&gt;"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;3.11&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="py"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="py"&gt;"agent-framework-github-copilot=&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;  &lt;span class="py"&gt;"github-copilot-sdk=&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nn"&gt;[project.optional-dependencies]&lt;/span&gt;
&lt;span class="py"&gt;dev&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="py"&gt;["pytest&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;8.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="s"&gt;"]&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create an isolated environment and install the project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'.[dev]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here’s what it looks like when run end to end:&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%2Fsjfe8tyk9mh7mrvizbqm.webp" 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%2Fsjfe8tyk9mh7mrvizbqm.webp" width="800" height="697"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The current Python SDK package includes the Copilot runtime for supported platforms. Authentication still depends on GitHub Copilot and may ask you to sign in when you run the agent for the first time.&lt;/p&gt;

&lt;p&gt;The Agent Framework integration is stable; the GitHub Copilot SDK underneath it isn’t, and its preview label didn’t stop it from changing shape more than once while I was writing this pair of posts. Pin these versions - it’s cheap insurance against a future SDK bump silently rewriting what you’re reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make permissions a small, testable policy
&lt;/h2&gt;

&lt;p&gt;The agent can request several capability types, including &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;write&lt;/code&gt;, &lt;code&gt;shell&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, and &lt;code&gt;mcp&lt;/code&gt;. We will not treat them equally:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read inside the selected repository&lt;/td&gt;
&lt;td&gt;Approve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write a file&lt;/td&gt;
&lt;td&gt;Ask&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run a shell command&lt;/td&gt;
&lt;td&gt;Ask&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fetch a URL or call MCP&lt;/td&gt;
&lt;td&gt;Deny&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anything unknown&lt;/td&gt;
&lt;td&gt;Deny&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The policy is an ordinary function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;APPROVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approve&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;DENY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deny&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;permission_kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&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;permission_kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read&lt;/span&gt;&lt;span class="sh"&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;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APPROVE&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;permission_kind&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;write&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shell&lt;/span&gt;&lt;span class="sh"&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;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROMPT&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DENY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fallback denies URL access, MCP calls, new SDK permission kinds, and any malformed value. The application has to be changed deliberately before one of those capabilities becomes available.&lt;/p&gt;

&lt;p&gt;The test suite makes that contract visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kind&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expected&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APPROVE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;write&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROMPT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shell&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROMPT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DENY&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_decide_returns_expected_decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These tests do not need a model, a Copilot subscription, or a repository. They test application authority rather than probabilistic behavior.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You do not need an LLM evaluation to prove that URL access is denied. Separate deterministic authorization policy from the agent and test it like any other security-sensitive function.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Convert policy into Copilot decisions
&lt;/h2&gt;

&lt;p&gt;The permission handler receives a typed request and a context dictionary. It first prints enough detail for the operator to understand the action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_permission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;[permission: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&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;decision&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APPROVE&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;PermissionHandler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approve_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&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;decision&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PermissionDecisionReject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Blocked by the repository agent policy.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Approve once? [y/N] &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&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;answer&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="sh"&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;PermissionHandler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approve_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PermissionDecisionReject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The operator denied this action.&lt;/span&gt;&lt;span class="sh"&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;code&gt;input()&lt;/code&gt; is blocking, so &lt;code&gt;asyncio.to_thread&lt;/code&gt; keeps it away from the event loop. That detail is easy to miss in a console sample and becomes more important when the application also streams output or handles more than one session.&lt;/p&gt;

&lt;p&gt;For a write request, &lt;code&gt;describe&lt;/code&gt; prints &lt;code&gt;file_name&lt;/code&gt; and &lt;code&gt;diff&lt;/code&gt;. For a shell request, it prints&lt;code&gt;full_command_text&lt;/code&gt;. URL and MCP requests are displayed before being denied, leaving an audit-friendly record of what the agent attempted.&lt;/p&gt;

&lt;p&gt;The helper name &lt;code&gt;approve_all&lt;/code&gt; can be misleading in this context. It constructs an approval response for the current request. Our application still decides which requests reach that line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configure the agent without ambient repository state
&lt;/h2&gt;

&lt;p&gt;The command resolves the repository path before creating the agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expanduser&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_dir&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;NotADirectoryError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Copilot session options include that working directory and our permission callback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GitHubCopilotOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;working_directory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;enable_config_discovery&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;on_permission_request&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;handle_permission&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GitHubCopilotAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;INSTRUCTIONS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;default_options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;options&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;Repository instructions are genuinely useful, so turning configuration discovery off seems backwards at first. I ran into the reason for it while building the .NET version of this project: the fixture sat inside another Git checkout during development, and the runtime happily walked up to that parent repo’s &lt;code&gt;AGENTS.md&lt;/code&gt; before it ever noticed the fixture’s own. Nested repositories, monorepos, and temporary worktrees all make that boundary easy to blur without meaning to.&lt;/p&gt;

&lt;p&gt;So the application instructions explicitly tell the agent to read &lt;code&gt;AGENTS.md&lt;/code&gt; inside its working directory itself. That keeps the source of project guidance visible in the tool activity, instead of letting an unrelated parent directory quietly change what the agent thinks the rules are.&lt;/p&gt;

&lt;p&gt;That does not make repository instructions trusted. A repository can contain prompt injection just as it can contain a malicious build script. The host policy remains authoritative when instructions ask for a forbidden action.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Repository instructions can improve the patch, but they must not expand the agent's authority. The host permission policy wins when project guidance asks for a forbidden side effect.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Stream the maintenance run
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GitHubCopilotAgent&lt;/code&gt; owns an async client, so the natural Python lifecycle is an async context manager:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The context manager starts and stops the Copilot client even if the run raises an exception. The command entry point keeps the synchronous boundary small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This lifecycle matters in longer-running applications. Agent sessions own processes, connections, history, and sometimes temporary files. An exception should not leave those resources attached to a worker indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the same repair
&lt;/h2&gt;

&lt;p&gt;Start the agent against the included fixture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;safe-repo-agent ../fixture
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads the source and tests, then proposes the same one-line change as the .NET version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- return value.toLowerCase().replace(/\s+/g, "-");
&lt;/span&gt;&lt;span class="gi"&gt;+ return value.trim().toLowerCase().replace(/\s+/g, "-");
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The write does not happen until the operator approves it. The later &lt;code&gt;npm test&lt;/code&gt; request has its own prompt, so approving a patch does not grant standing permission to execute arbitrary commands.&lt;/p&gt;

&lt;p&gt;The verified run ended with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;File changed: src/normalize-title.js
Fix: Added .trim() before .toLowerCase().
Validation: Both tests pass (npm test exit 0).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the deterministic policy tests separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pytest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All six policy cases pass without starting Copilot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding OpenTelemetry
&lt;/h2&gt;

&lt;p&gt;The Python &lt;code&gt;GitHubCopilotAgent&lt;/code&gt; includes Agent Framework’s telemetry layer. For local exploration, the framework can configure console exporters before the agent is created:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;agent_framework.observability&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;configure_otel_providers&lt;/span&gt;

&lt;span class="nf"&gt;configure_otel_providers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enable_console_exporters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a service, export through OTLP to your normal observability backend and attach the repository job identifier to the surrounding trace. Useful signals include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;End-to-end run duration&lt;/li&gt;
&lt;li&gt;Time waiting for human approval&lt;/li&gt;
&lt;li&gt;Tool calls by permission kind&lt;/li&gt;
&lt;li&gt;Denied actions&lt;/li&gt;
&lt;li&gt;Command duration and exit status&lt;/li&gt;
&lt;li&gt;Repair attempts and validation failures&lt;/li&gt;
&lt;li&gt;Cleanup failures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not enable prompt and completion capture casually. Repository paths, source code, terminal output, and environment-related errors can contain sensitive information. Telemetry should explain the run without becoming another copy of every secret the agent could see.&lt;/p&gt;

&lt;h2&gt;
  
  
  From console tutorial to production worker
&lt;/h2&gt;

&lt;p&gt;The console application demonstrates the control points, not a complete isolation platform. Before I would let it process untrusted repositories in a service, I would add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A fresh container or microVM for every job&lt;/li&gt;
&lt;li&gt;A read-only base image and disposable writable workspace&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;asyncio&lt;/code&gt;-aware timeouts and cancellation for CPU, memory, disk, and wall-clock limits, not just a number typed into a config file&lt;/li&gt;
&lt;li&gt;Network deny-by-default&lt;/li&gt;
&lt;li&gt;No ambient developer or cloud credentials&lt;/li&gt;
&lt;li&gt;Short-lived repository credentials without merge permission&lt;/li&gt;
&lt;li&gt;Durable approval records instead of terminal input&lt;/li&gt;
&lt;li&gt;A bounded repair loop and diff-size limit&lt;/li&gt;
&lt;li&gt;Cleanup and audit recording in &lt;code&gt;finally&lt;/code&gt;, including cancelled tasks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same concerns apply to a deterministic tool that runs repository scripts. The agent makes the risk easier to see because it chooses commands dynamically, but the repository and its dependencies were already untrusted executable input.&lt;/p&gt;

&lt;h2&gt;
  
  
  What differs from .NET?
&lt;/h2&gt;

&lt;p&gt;The architectural boundaries did not change. Both implementations use the same harness, working directory, permission table, task, and fixture.&lt;/p&gt;

&lt;p&gt;The Python version expresses the lifecycle with &lt;code&gt;async with&lt;/code&gt;, passes Copilot session settings through&lt;code&gt;GitHubCopilotOptions&lt;/code&gt;, and moves blocking operator input to a thread. The .NET version uses&lt;code&gt;CopilotClient&lt;/code&gt;, &lt;code&gt;SessionConfig&lt;/code&gt;, typed permission request subclasses, and &lt;code&gt;IAsyncEnumerable&lt;/code&gt; for streaming.&lt;/p&gt;

&lt;p&gt;Those are ecosystem differences, not different safety models.&lt;/p&gt;

&lt;p&gt;Building the same agent twice made one thing obvious: the language wrapping the model barely mattered. What mattered was the authority boundary around its tools, and whether “it worked” was backed by a diff and a passing test, or just the model’s own word for it.&lt;/p&gt;

&lt;p&gt;Thanks for reading ✌️&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://devblogs.microsoft.com/agent-framework/build-production-ready-agents-with-the-github-copilot-harness-and-agent-framework/" rel="noopener noreferrer"&gt;Build Production-Ready Agents with the GitHub Copilot Harness and Agent Framework&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/copilot/how-tos/copilot-sdk/integrations/microsoft-agent-framework" rel="noopener noreferrer"&gt;GitHub Copilot integration with Microsoft Agent Framework&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/agent-framework/agents/providers/github-copilot" rel="noopener noreferrer"&gt;GitHub Copilot agents in Microsoft Agent Framework&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/github/copilot-sdk" rel="noopener noreferrer"&gt;GitHub Copilot SDK&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>tutorial</category>
      <category>python</category>
      <category>github</category>
      <category>ai</category>
    </item>
    <item>
      <title>Build a Safe Repository Maintenance Agent with GitHub Copilot and Microsoft Agent Framework in .NET</title>
      <dc:creator>Sahan</dc:creator>
      <pubDate>Mon, 10 Aug 2026 22:27:00 +0000</pubDate>
      <link>https://dev.to/sahan/build-a-safe-repository-maintenance-agent-with-github-copilot-and-microsoft-agent-framework-in-net-4721</link>
      <guid>https://dev.to/sahan/build-a-safe-repository-maintenance-agent-with-github-copilot-and-microsoft-agent-framework-in-net-4721</guid>
      <description>&lt;p&gt;Giving an AI agent access to a repository takes only a few lines of code. Giving it useful access without silently approving every command is the real tutorial.&lt;/p&gt;

&lt;p&gt;In this post, we will build a .NET 10 console application that uses the GitHub Copilot harness as its coding runtime and Microsoft Agent Framework as the application-facing agent abstraction. The agent will inspect a small repository, repair a failing test, ask before changing a file, ask again before running the test command, and finish with a concise maintenance report.&lt;/p&gt;

&lt;p&gt;It will not access the network, install packages, commit, push, or create a pull request.&lt;/p&gt;

&lt;p&gt;This is a hands-on continuation of my earlier article about &lt;a href="https://www.sahansera.dev/most-dependency-vulnerability-remediation-is-a-packaging-problem/" rel="noopener noreferrer"&gt;building an AI-assisted dependency vulnerability fixer&lt;/a&gt;. That system used an agent to handle repository-specific exceptions while keeping review and merge authority with the repository owner. Here, we will build the smaller execution boundary behind that idea.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Code repository:&lt;/strong&gt; The complete .NET and Python implementations use the same fixture and are available at &lt;a href="https://github.com/sahansera/safe-repository-maintenance-agent" rel="noopener noreferrer"&gt;github.com/sahansera/safe-repository-maintenance-agent&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&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%2Fomag6detuw0x3ee6jmu2.webp" 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%2Fomag6detuw0x3ee6jmu2.webp" alt="The agent proposes, the host decides: a permission-gated repository maintenance loop" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the two frameworks contribute
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/github/copilot-sdk" rel="noopener noreferrer"&gt;GitHub Copilot SDK&lt;/a&gt; supplies the coding harness. It owns the agent loop and provides repository-oriented capabilities such as reading files, writing files, running shell commands, fetching URLs, and calling MCP tools.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://learn.microsoft.com/en-us/agent-framework/" rel="noopener noreferrer"&gt;Microsoft Agent Framework&lt;/a&gt; wraps that runtime in the same &lt;code&gt;AIAgent&lt;/code&gt; abstraction used by its other providers. That gives the application a consistent run interface, streaming, sessions, middleware, and OpenTelemetry integration.&lt;/p&gt;

&lt;p&gt;The distinction matters. We are not asking Agent Framework to recreate a coding loop around a chat model. Copilot remains responsible for planning and tool execution. Agent Framework gives us the surface on which the rest of the application can depend.&lt;/p&gt;

&lt;p&gt;The Agent Framework integration itself is stable, but the GitHub Copilot SDK underneath it is still labeled public preview. I pinned exact versions below for that reason - the lower-level APIs moved twice while I was drafting this post, and I’d rather you hit a clean build than chase a breaking change mid-tutorial.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repository we will repair
&lt;/h2&gt;

&lt;p&gt;The completed sample accepts any repository path, including a checkout such as &lt;code&gt;sahansera.dev&lt;/code&gt;. For the write demonstration, however, it includes a disposable fixture with no dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fixture/
├── AGENTS.md
├── package.json
├── src/
│ └── normalize-title.js
└── test/
    └── normalize-title.test.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implementation is deliberately wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&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;The second test expects surrounding whitespace to be ignored. Running &lt;code&gt;npm test&lt;/code&gt; gives us one pass and one failure because the actual result is &lt;code&gt;-safe-repository-agent-&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This fixture gives the agent a real task with an objectively verifiable result. It also means nobody has to grant a first experiment write access to an important repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites and project setup
&lt;/h2&gt;

&lt;p&gt;You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;.NET 10&lt;/li&gt;
&lt;li&gt;An active GitHub Copilot subscription&lt;/li&gt;
&lt;li&gt;The GitHub Copilot and Agent Framework integration packages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here’s what I had installed when this worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;ItemGroup&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"GitHub.Copilot.SDK"&lt;/span&gt; &lt;span class="na"&gt;Version=&lt;/span&gt;&lt;span class="s"&gt;"1.0.9"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"Microsoft.Agents.AI.GitHub.Copilot"&lt;/span&gt; &lt;span class="na"&gt;Version=&lt;/span&gt;&lt;span class="s"&gt;"1.17.0"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/ItemGroup&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK bundles its compatible Copilot runtime, so a separate global CLI installation is not required by the current .NET package. You still need to authenticate and have an active subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the permission policy before creating the agent
&lt;/h2&gt;

&lt;p&gt;The quickest demo is an approval callback that returns &lt;code&gt;ApproveOnce()&lt;/code&gt; for everything. It is also a poor default for an application that can run commands and rewrite a checkout.&lt;/p&gt;

&lt;p&gt;Our policy separates four decisions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read inside the selected working directory&lt;/td&gt;
&lt;td&gt;Approve once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write a file&lt;/td&gt;
&lt;td&gt;Ask the operator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run a shell command&lt;/td&gt;
&lt;td&gt;Ask the operator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fetch a URL or call an MCP server&lt;/td&gt;
&lt;td&gt;Deny&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Unknown permission types are denied. A new SDK capability should not become authorized merely because the application has not been updated to recognize it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Approve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt; &lt;span class="nf"&gt;Decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;permissionKind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;permissionKind&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"read"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Approve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"write"&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="s"&gt;"shell"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"url"&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="s"&gt;"mcp"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deny&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;This function contains no agent or console dependencies, so it is easy to unit test. The sample has six cases covering every known branch and the fail-closed fallback.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Permission handling is application policy, not prompt wording. Put it in ordinary code, test every branch, and deny capabilities you do not recognize.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Turn policy decisions into operator prompts
&lt;/h2&gt;

&lt;p&gt;The Copilot SDK sends a typed &lt;code&gt;PermissionRequest&lt;/code&gt;. That means we can show the operator the actual command, filename, diff, URL, or MCP tool instead of asking them to approve an unexplained action.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PermissionDecision&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;HandleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;PermissionRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;PermissionInvocation&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;PolicyDecision&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PermissionPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"\n[permission: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&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;decision&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Approve&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PermissionDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ApproveOnce&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deny&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PermissionDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"Blocked by the repository agent policy."&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Prompt&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;For a write request, &lt;code&gt;Describe&lt;/code&gt; prints both the path and proposed diff. For a shell request, it prints&lt;code&gt;FullCommandText&lt;/code&gt;. Approval is always for the current action rather than the whole session.&lt;/p&gt;

&lt;p&gt;Do not treat the displayed command as a complete security parser. Shell syntax, symlinks, subprocesses, and package scripts make static classification difficult. The prompt improves operator judgment; the real containment boundary should still be a disposable sandbox with limited credentials and network access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope the Copilot runtime to one repository
&lt;/h2&gt;

&lt;p&gt;The application resolves the supplied path before starting Copilot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;repositoryPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetFullPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;Directory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repositoryPath&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;$"Repository directory does not exist: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;repositoryPath&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&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;We use that path for both the client process and the session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;CopilotClient&lt;/span&gt; &lt;span class="n"&gt;copilotClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;CopilotClientOptions&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;WorkingDirectory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repositoryPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;copilotClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;SessionConfig&lt;/span&gt; &lt;span class="n"&gt;sessionConfig&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;WorkingDirectory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repositoryPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;EnableConfigDiscovery&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;OnPermissionRequest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ConsolePermissionHandler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandleAsync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SystemMessage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;SystemMessageConfig&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SystemMessageMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Append&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;instructions&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;This revised system instruction tells the agent to read the selected repository’s &lt;code&gt;AGENTS.md&lt;/code&gt; itself. With ambient discovery disabled, a nested checkout no longer silently inherits instructions from a parent checkout.&lt;/p&gt;

&lt;p&gt;A working directory also limits the paths Copilot considers available by default. It is useful scope, but it is not process isolation. I would still run an agent against an untrusted repository in a container with a non-root user, a disposable filesystem, no ambient cloud credentials, and a narrow network policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create the Agent Framework agent and stream the result
&lt;/h2&gt;

&lt;p&gt;With the client and session configured, the integration is one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;AIAgent&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;copilotClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsAIAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sessionConfig&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AgentResponseUpdate&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunStreamingAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update&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;The instructions constrain the task further:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Work only inside the supplied working directory.
Read AGENTS.md before making changes.
Make the smallest change that satisfies the task.
Do not access the network, install packages, commit, push, or create a pull request.
Run focused validation and report the files changed, commands run, and result.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those instructions improve agent behavior, but they do not replace the permission callback. The network prohibition exists in both places intentionally: the prompt tells the agent not to try, and the callback denies the capability if it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the repair
&lt;/h2&gt;

&lt;p&gt;Start the sample against the fixture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; src/SafeRepositoryAgent &lt;span class="nt"&gt;--&lt;/span&gt; ../fixture
&lt;/code&gt;&lt;/pre&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%2Flfa892hzk9okkt5t42yl.webp" 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%2Flfa892hzk9okkt5t42yl.webp" width="800" height="497"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The agent reads the repository instructions and tests. When it proposes adding &lt;code&gt;.trim()&lt;/code&gt;, the application prints the exact diff and pauses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;permission: write]
File: .../fixture/src/normalize-title.js

- &lt;span class="k"&gt;return &lt;/span&gt;value.toLowerCase&lt;span class="o"&gt;()&lt;/span&gt;.replace&lt;span class="o"&gt;(&lt;/span&gt;/&lt;span class="se"&gt;\s&lt;/span&gt;+/g, &lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
+ &lt;span class="k"&gt;return &lt;/span&gt;value.trim&lt;span class="o"&gt;()&lt;/span&gt;.toLowerCase&lt;span class="o"&gt;()&lt;/span&gt;.replace&lt;span class="o"&gt;(&lt;/span&gt;/&lt;span class="se"&gt;\s&lt;/span&gt;+/g, &lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

Approve once? &lt;span class="o"&gt;[&lt;/span&gt;y/N]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After approval, it asks separately before running &lt;code&gt;npm test&lt;/code&gt;. The verified run finished with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Changed: src/normalize-title.js - added .trim&lt;span class="o"&gt;()&lt;/span&gt; before lowercasing.
Command: npm &lt;span class="nb"&gt;test&lt;/span&gt; - 2/2 tests pass.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result matters because it is supported by a small diff and a repeatable test, not because the agent described itself as successful.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A useful maintenance result contains the proposed diff, the command that was allowed, and its exit status. The final natural-language answer is only a summary of that evidence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What would change for a production service?
&lt;/h2&gt;

&lt;p&gt;An interactive console prompt is appropriate for a tutorial and a developer workstation. A service needs a durable approval protocol instead.&lt;/p&gt;

&lt;p&gt;I would keep the same policy function, then replace &lt;code&gt;Console.ReadLine()&lt;/code&gt; with an approval record tied to a stable job and tool-call identity. The worker would pause, persist the request, notify an authorized reviewer, and resume only after receiving a valid decision. Every decision would be part of the audit trail.&lt;/p&gt;

&lt;p&gt;I would also add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A disposable container or microVM per repository job&lt;/li&gt;
&lt;li&gt;Host-enforced CPU, memory, disk, process, and wall-clock limits, driven by a &lt;code&gt;CancellationToken&lt;/code&gt;rather than just requested of Copilot&lt;/li&gt;
&lt;li&gt;Short-lived repository credentials with no merge permission&lt;/li&gt;
&lt;li&gt;Network deny-by-default with explicit destinations where required&lt;/li&gt;
&lt;li&gt;OpenTelemetry export, wired through the worker’s own lifetime, for agent runs, permission latency, tool calls, and failures&lt;/li&gt;
&lt;li&gt;A maximum number of repair attempts before the job fails loudly instead of looping&lt;/li&gt;
&lt;li&gt;A final diff-size limit and required validation commands&lt;/li&gt;
&lt;li&gt;Deterministic cleanup via &lt;code&gt;await using&lt;/code&gt;/&lt;code&gt;finally&lt;/code&gt;, including failed and cancelled runs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Agent Framework emits OpenTelemetry-compatible telemetry, but traces can contain prompts, paths, commands, and model output. Keep sensitive-data capture disabled unless you have an explicit reason and a suitable storage policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The important part is outside the model
&lt;/h2&gt;

&lt;p&gt;The agent itself did the easy part - a two-line fix, the kind of thing plenty of tutorials would stop at. What actually took the iteration was everything around that decision: scoping the working directory, closing the config-discovery hole, making every write and shell command visible before it ran, and keeping the test suite - not the model’s own summary - as the judge of success.&lt;/p&gt;

&lt;p&gt;It’s a small application. But the boundary it draws is the whole point.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.sahansera.dev/safe-repository-maintenance-agent-python/" rel="noopener noreferrer"&gt;Python version of this tutorial&lt;/a&gt; builds the same agent and fixture using async context managers, a typed options dictionary, and pytest. Keeping the task and permission table identical makes the differences between the two SDKs much easier to see.&lt;/p&gt;

&lt;p&gt;Thanks for reading ✌️&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://devblogs.microsoft.com/agent-framework/build-production-ready-agents-with-the-github-copilot-harness-and-agent-framework/" rel="noopener noreferrer"&gt;Build Production-Ready Agents with the GitHub Copilot Harness and Agent Framework&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/copilot/how-tos/copilot-sdk/integrations/microsoft-agent-framework" rel="noopener noreferrer"&gt;GitHub Copilot integration with Microsoft Agent Framework&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/agent-framework/agents/providers/github-copilot" rel="noopener noreferrer"&gt;GitHub Copilot agents in Microsoft Agent Framework&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/github/copilot-sdk" rel="noopener noreferrer"&gt;GitHub Copilot SDK&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>tutorial</category>
      <category>dotnet</category>
      <category>github</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Grew an Engineering Blog from 0 to 463,000 Pageviews - Here's What Worked</title>
      <dc:creator>Sahan</dc:creator>
      <pubDate>Tue, 04 Aug 2026 01:43:00 +0000</pubDate>
      <link>https://dev.to/sahan/i-grew-an-engineering-blog-from-0-to-463000-pageviews-heres-what-worked-gmj</link>
      <guid>https://dev.to/sahan/i-grew-an-engineering-blog-from-0-to-463000-pageviews-heres-what-worked-gmj</guid>
      <description>&lt;p&gt;I published the first posts on &lt;a href="https://www.sahansera.dev/" rel="noopener noreferrer"&gt;sahansera.dev&lt;/a&gt; in December 2019.&lt;/p&gt;

&lt;p&gt;There was no launch campaign, existing audience, or reliable stream of visitors waiting for them. Like most new personal sites, the blog started at zero. I wrote about problems I had encountered, shared the posts where I could, and hoped somebody searching for the same answers would eventually find them.&lt;/p&gt;

&lt;p&gt;By 1 August 2026, the blog had accumulated &lt;strong&gt;463,362 recorded pageviews&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Along the way, it has reached readers in &lt;strong&gt;at least 184 countries and territories&lt;/strong&gt;. Here is where readers came from last month:&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%2Fxkto2zn16pzpqq2o7deh.webp" 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%2Fxkto2zn16pzpqq2o7deh.webp" alt="World map showing readers reaching the blog from countries and territories around the world" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Earlier geography data is not included, so the lifetime reach may be broader.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That number is personally meaningful, but it is not the most interesting part of the story. The useful part is what happened underneath it: a small collection of practical engineering articles generated most of the traffic, some posts kept helping people for years, and many things I assumed would matter barely moved the numbers at all.&lt;/p&gt;

&lt;p&gt;This is what I learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Specific, practical articles generated most of the traffic.&lt;/li&gt;
&lt;li&gt;Evergreen posts kept growing for years after publication.&lt;/li&gt;
&lt;li&gt;The blog continued reaching readers during a long publishing break.&lt;/li&gt;
&lt;li&gt;Traffic revealed demand, but it did not tell me whether readers returned.&lt;/li&gt;
&lt;li&gt;Comparing posts fairly requires consistent time windows, not raw lifetime totals.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  From zero to 463,362 pageviews
&lt;/h2&gt;

&lt;p&gt;I calculated the lifetime total using two consecutive analytics exports with no overlapping dates.&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%2F3lfg8de3ptvdsjwssxv7.webp" 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%2F3lfg8de3ptvdsjwssxv7.webp" alt="Timeline showing the blog launching with zero visitors in December 2019, reaching 307,476 pageviews in its first three and a half years, then adding 155,886 views over the next three years for a total of 463,362 recorded pageviews" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A few posts did most of the work
&lt;/h2&gt;

&lt;p&gt;The distribution of traffic surprised me more than the total. Just five articles account for roughly 46% of all the pageviews the blog has recorded since launch.&lt;/p&gt;

&lt;p&gt;The leading articles are remarkably consistent in what they offer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Article&lt;/th&gt;
&lt;th&gt;Lifetime pageviews&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.sahansera.dev/in-memory-caching-aspcore-dotnet/" rel="noopener noreferrer"&gt;Simple In-Memory Caching in .NET with &lt;code&gt;IMemoryCache&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;86,975&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.sahansera.dev/understanding-websockets-with-aspnetcore-5/" rel="noopener noreferrer"&gt;Understanding WebSockets with ASP.NET&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;43,272&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.sahansera.dev/distributed-caching-aspnet-core-redis/" rel="noopener noreferrer"&gt;Distributed Caching in ASP.NET Core with Redis&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;31,911&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.sahansera.dev/dotnet-core-ioc-container/" rel="noopener noreferrer"&gt;Having Fun with Microsoft IoC Container for .NET Core&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;27,812&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.sahansera.dev/dotnet-core-generic-host/" rel="noopener noreferrer"&gt;Understanding the .NET Generic Host Model&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;23,040&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Other durable performers cover securing the Hangfire dashboard, Kubernetes commands and arguments, and gRPC across Go and .NET.&lt;/p&gt;

&lt;p&gt;These are not broad opinion pieces. Each one helps a developer understand a specific concept or complete a specific task.&lt;/p&gt;

&lt;p&gt;The lesson is not simply that these technologies are popular. It is that &lt;strong&gt;clear intent compounds&lt;/strong&gt;. A post answering a concrete question can remain useful every day for years, even when I am not actively promoting it.&lt;/p&gt;


&lt;div class="crayons-card c-embed"&gt;

  &lt;br&gt;
&lt;strong&gt;💡 The pattern:&lt;/strong&gt; Useful, specific posts can compound for years.&lt;br&gt;

&lt;/div&gt;


&lt;h2&gt;
  
  
  Evergreen technical writing compounds slowly
&lt;/h2&gt;

&lt;p&gt;My highest-traffic article is an introduction to in-memory caching that I published in January 2020. Years later, it is still the largest entry point to the site.&lt;/p&gt;

&lt;p&gt;That changed how I think about the return on writing.&lt;/p&gt;

&lt;p&gt;A social post has a short distribution window. It may reach many people immediately and then disappear. A useful technical article behaves differently. It may receive very little attention on its first day, but it can be discovered repeatedly through search, links, code repositories, and recommendations.&lt;/p&gt;

&lt;p&gt;The early results can feel underwhelming because the compounding is almost invisible. The article needs to be indexed. It needs to answer the query well enough for people to stay. Other pages need to link to it. Search engines need time to understand whether it is useful.&lt;/p&gt;

&lt;p&gt;None of my successful posts felt like a breakthrough when I pressed publish. Their value accumulated quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blog kept working while life took priority
&lt;/h2&gt;

&lt;p&gt;The growth was not driven by a perfectly consistent publishing schedule.&lt;/p&gt;

&lt;p&gt;In 2023, I became a first-time dad. My family also moved into a new home, and I changed jobs. I wrote about that season in &lt;a href="https://www.sahansera.dev/my-plans-for-sahanseradev-2024/" rel="noopener noreferrer"&gt;my plans for sahansera.dev in 2024&lt;/a&gt;, acknowledging that blogging had taken a back seat while I focused on being the best dad I could be.&lt;/p&gt;

&lt;p&gt;The publication dates tell the story plainly. I published two posts in 2023, none in 2024, and returned with seven posts in 2025. I had hoped to resume a regular schedule sooner, but life had a different rhythm.&lt;/p&gt;

&lt;p&gt;I am glad I did not treat the pause as a reason to abandon the blog. While I was not publishing, the existing articles continued answering questions, appearing in search results, and bringing new readers to the site. The work I had already done kept compounding when I did not have the time or energy to add more.&lt;/p&gt;

&lt;p&gt;That makes the lifetime total more meaningful to me. It did not come from operating a content machine or forcing myself to publish through every season of life. It came from building a useful body of work, letting it breathe, and returning when I had something worthwhile to share.&lt;/p&gt;


&lt;div class="crayons-card c-embed"&gt;

  &lt;br&gt;
&lt;strong&gt;💡 The pause:&lt;/strong&gt; The blog kept growing even when publishing had to wait.&lt;br&gt;

&lt;/div&gt;


&lt;p&gt;When I started writing again in 2025, I explored streaming APIs, HTTP internals, Python environments, and home-lab Kubernetes. The break had not erased the audience. It gave me a chance to return with different experiences and better questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical specificity beats broad ambition
&lt;/h2&gt;

&lt;p&gt;The best-performing titles make a small promise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Configure in-memory caching.&lt;/li&gt;
&lt;li&gt;Build a gRPC server or client.&lt;/li&gt;
&lt;li&gt;Run Kafka locally for testing.&lt;/li&gt;
&lt;li&gt;Secure a Hangfire dashboard.&lt;/li&gt;
&lt;li&gt;Understand how WebSockets work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The posts are narrow enough for the reader to know why they should click, but substantial enough to teach the surrounding concepts.&lt;/p&gt;

&lt;p&gt;This balance matters. A title such as "Everything You Need to Know About Distributed Systems" sounds ambitious but does not reveal which problem it solves. "Building a gRPC Server in Go" is less grand and much more useful to the person who needs exactly that.&lt;/p&gt;

&lt;p&gt;My better articles tend to combine three layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A direct answer to a practical problem.&lt;/li&gt;
&lt;li&gt;An explanation of what is happening underneath.&lt;/li&gt;
&lt;li&gt;A working implementation readers can adapt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That combination has become the clearest description of what I want this blog to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  One successful article should become a cluster
&lt;/h2&gt;

&lt;p&gt;For a long time, I treated each post as an isolated piece of work. The analytics show why that leaves value on the table.&lt;/p&gt;

&lt;p&gt;The audience for an in-memory caching tutorial is likely to care about distributed caching, Redis, invalidation, cache stampedes, testing, and production failure modes. Someone building a gRPC server may next need a client, authentication, retries, deadlines, streaming, observability, or Kubernetes deployment guidance.&lt;/p&gt;


&lt;div class="crayons-card c-embed"&gt;

  &lt;br&gt;
&lt;strong&gt;💡 The opportunity:&lt;/strong&gt; A successful article is evidence that a useful topic cluster exists.&lt;br&gt;

&lt;/div&gt;


&lt;p&gt;The gRPC series already demonstrates this. Its introduction, Go server and client, .NET server and client, and deployment posts reinforce one another. The individual posts can satisfy focused searches while the series gives interested readers a natural route through the broader subject.&lt;/p&gt;

&lt;p&gt;I want to apply the same model to three areas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;.NET caching and application reliability&lt;/li&gt;
&lt;li&gt;Kafka and event-driven system failure modes&lt;/li&gt;
&lt;li&gt;Kubernetes operations and production troubleshooting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This does not mean publishing minor variations of the same article. Each post still needs a distinct problem and search intent. The connection between them should help a reader progress from a basic implementation to the difficult production questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traffic is not the same as an audience
&lt;/h2&gt;

&lt;p&gt;The historical reports showed that most visitors left after reading a single page. That sounds alarming until the context is considered.&lt;/p&gt;

&lt;p&gt;Many visitors arrive from search, find a code sample or explanation, solve their immediate problem, and leave. For a reference-style engineering article, that can be a successful visit rather than a rejection.&lt;/p&gt;

&lt;p&gt;At the same time, the data exposes a real weakness: I made it easy to consume one answer but did not always make the next useful step obvious.&lt;/p&gt;

&lt;p&gt;Chronological previous-and-next links are not enough. A reader on a caching article probably does not want the post I happened to publish immediately afterward. They want the most relevant continuation of the problem they are already solving.&lt;/p&gt;

&lt;p&gt;The improvements I am making are straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add contextual links to related articles within the explanation.&lt;/li&gt;
&lt;li&gt;Show a clear next step at the end of high-traffic posts.&lt;/li&gt;
&lt;li&gt;Organise related material into visible series and topic hubs.&lt;/li&gt;
&lt;li&gt;Keep the email and RSS subscription options easy to find.&lt;/li&gt;
&lt;li&gt;Link runnable examples to maintained repositories.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is not to trap somebody on the site. It is to make the site more useful when they want to go deeper.&lt;/p&gt;


&lt;div class="crayons-card c-embed"&gt;

  &lt;br&gt;
&lt;strong&gt;💡 The real goal:&lt;/strong&gt; Traffic becomes an audience only when readers have a reason and a way to return.&lt;br&gt;

&lt;/div&gt;


&lt;h2&gt;
  
  
  I measured traffic but not outcomes
&lt;/h2&gt;

&lt;p&gt;Another uncomfortable lesson is that I collected a lot of traffic data without defining what success should mean beyond pageviews.&lt;/p&gt;

&lt;p&gt;My current analytics setup contains no configured conversion events. I can see that people read an article, but I cannot reliably answer questions such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did the article lead someone to another useful post?&lt;/li&gt;
&lt;li&gt;Did they subscribe by email or RSS?&lt;/li&gt;
&lt;li&gt;Did they visit the example repository?&lt;/li&gt;
&lt;li&gt;Did they copy a code sample?&lt;/li&gt;
&lt;li&gt;Which landing pages create returning readers?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These questions matter more now than the raw total.&lt;/p&gt;

&lt;p&gt;Pageviews helped me understand which subjects have demand. The next stage is to measure whether the blog creates a relationship with the reader. I plan to treat a confirmed email subscription as the primary conversion, then track supporting actions such as RSS clicks, repository visits, code copying, deep scrolling, and movement between related articles.&lt;/p&gt;

&lt;p&gt;Not every personal blog needs a conversion funnel. But if I want to improve something, I need to be explicit about what "better" means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Analytics data needs maintenance too
&lt;/h2&gt;

&lt;p&gt;Just over 35% of the views in the recent export have the page title &lt;code&gt;(not set)&lt;/code&gt;. The file also contains fragmented title variants and at least one suspicious spam-like title.&lt;/p&gt;

&lt;p&gt;That is a useful reminder that analytics is not automatically a source of truth just because it contains precise-looking numbers.&lt;/p&gt;

&lt;p&gt;Collection can break. Titles can change. A migration can alter definitions. Filters can create misleading exports. Bots and spam can contaminate reports. Privacy settings can change how users and sessions are identified.&lt;/p&gt;

&lt;p&gt;I will use page paths as the canonical dimension for content reporting, investigate why page titles are missing, filter known noise, and verify that a single page view is recorded for each navigation. I also want Google Search Console beside Analytics so I can see queries, impressions, rankings, and click-through rates - not only the visits that already happened.&lt;/p&gt;

&lt;p&gt;Measurement is part of maintaining the site, not something completed by pasting in a tracking ID once.&lt;/p&gt;

&lt;h2&gt;
  
  
  New posts need a fair evaluation window
&lt;/h2&gt;

&lt;p&gt;Several of my newer articles have far fewer lifetime views than posts published in 2020 or 2021. That does not necessarily mean they failed.&lt;/p&gt;

&lt;p&gt;An article published last month should not be compared directly with one that has accumulated search traffic for six years. Lifetime totals reward age.&lt;/p&gt;

&lt;p&gt;For new work, I am moving toward a smaller set of time-normalised measures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Search impressions and clicks during the first 28 and 90 days&lt;/li&gt;
&lt;li&gt;Views per 30 days since publication&lt;/li&gt;
&lt;li&gt;Engagement and code-copy actions&lt;/li&gt;
&lt;li&gt;Movement to another related article&lt;/li&gt;
&lt;li&gt;Email or RSS subscription actions&lt;/li&gt;
&lt;li&gt;Whether traffic continues growing after the initial promotion window&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This should make it easier to distinguish a promising article that needs time from one whose topic, title, or distribution genuinely missed the mark.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 463,000 pageviews means to me
&lt;/h2&gt;

&lt;p&gt;Four hundred and sixty-three thousand is not a huge number on the scale of the internet. It is huge compared with the zero visitors I had in December 2019.&lt;/p&gt;

&lt;p&gt;More importantly, it represents individual moments when somebody had a problem and something I wrote may have helped them move forward.&lt;/p&gt;

&lt;p&gt;The experience has changed my view of successful technical writing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You do not need a large initial audience.&lt;/li&gt;
&lt;li&gt;Useful, specific posts can compound for years.&lt;/li&gt;
&lt;li&gt;A handful of durable articles may matter more than a constant publishing schedule.&lt;/li&gt;
&lt;li&gt;Updating and connecting existing work can be more valuable than always starting from zero.&lt;/li&gt;
&lt;li&gt;Honest measurement is more useful than the largest possible headline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are starting an engineering blog with no visitors, that is normal. Write down the problem you just solved. Explain enough of the underlying system that the solution remains useful. Include the details you wish had been available when you were searching.&lt;/p&gt;

&lt;p&gt;Then publish it and give it time.&lt;/p&gt;

&lt;p&gt;That is more or less how this blog went from zero to 463,000 pageviews - one specific problem at a time.&lt;/p&gt;

&lt;p&gt;If you write technical articles, what has surprised you most about the posts that keep finding readers? I would love to compare notes in the comments.&lt;/p&gt;

</description>
      <category>blogging</category>
      <category>writing</category>
      <category>webdev</category>
      <category>career</category>
    </item>
    <item>
      <title>How We Replaced a Critical Data Path Without a Flag Day</title>
      <dc:creator>Sahan</dc:creator>
      <pubDate>Mon, 03 Aug 2026 12:55:00 +0000</pubDate>
      <link>https://dev.to/sahan/how-we-replaced-a-critical-data-path-without-a-flag-day-1n2</link>
      <guid>https://dev.to/sahan/how-we-replaced-a-critical-data-path-without-a-flag-day-1n2</guid>
      <description>&lt;p&gt;Replacing an API call is easy. Replacing the source of truth behind an automated decision is not.&lt;/p&gt;

&lt;p&gt;I was reminded of this while migrating a critical workflow from a legacy event feed to a canonical state API. Both systems appeared to answer the same question: &lt;em&gt;should the workflow act on this record now?&lt;/em&gt; But they had different schemas, different update timings, and, more importantly, slightly different models of the same lifecycle.&lt;/p&gt;

&lt;p&gt;This was not a path where we could deploy the new code on Friday and watch the error rate. A false negative could leave work undone. A false positive could trigger an irreversible action from stale data. The HTTP request succeeding told us almost nothing about whether the new path was making the right decision.&lt;/p&gt;

&lt;p&gt;So we did not treat it as a normal code replacement. We treated it as a controlled transfer of authority.&lt;/p&gt;

&lt;p&gt;The migration used a query-only mode, shadow comparisons, discrepancy alerts, and a deliberately boring cutover. None of those techniques are particularly novel. What mattered was how we combined them, what we chose to compare, and how we decided the old path was finally safe to delete.&lt;/p&gt;

&lt;p&gt;This post walks through that process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dangerous assumption: same data, newer API
&lt;/h2&gt;

&lt;p&gt;The legacy path consumed a source built specifically around one class of lifecycle event. The replacement exposed a broader canonical record containing current state, relevant dates, and other attributes used by several workflows.&lt;/p&gt;

&lt;p&gt;On paper, the migration looked like this:&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%2Fyzmhvgt48a3vl1b035vc.webp" 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%2Fyzmhvgt48a3vl1b035vc.webp" alt="The legacy event feed and canonical state API both drive the same decision logic and downstream action" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That diagram hides the risky part. The two sources did not merely encode the same fact using different field names.&lt;/p&gt;

&lt;p&gt;A purpose-built event feed tends to answer an event-shaped question: &lt;em&gt;which transitions were recorded?&lt;/em&gt; A canonical API tends to answer a state-shaped question: &lt;em&gt;what is true about this entity now?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Those questions overlap, but they are not identical.&lt;/p&gt;

&lt;p&gt;An entity can have an old end-state event and later return to an active state. A record can contain several dates, each valid in its own business context. One source may update immediately while another catches up later. Missing data can mean “not applicable,” “not received yet,” or “something is broken.”&lt;/p&gt;

&lt;p&gt;If we had translated fields one-for-one and switched traffic, the code would have looked correct while preserving none of those semantics.&lt;/p&gt;

&lt;p&gt;The first useful decision was therefore to stop calling this an API migration. We were migrating a &lt;strong&gt;business decision&lt;/strong&gt; from one model of the world to another.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start by writing down the invariants
&lt;/h2&gt;

&lt;p&gt;Before building the new path, we wrote down what the workflow must continue to guarantee.&lt;/p&gt;

&lt;p&gt;The important invariants were roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An active entity must never be processed because of a stale historical event.&lt;/li&gt;
&lt;li&gt;A legitimate lifecycle transition must not be missed because one optional field is absent.&lt;/li&gt;
&lt;li&gt;The effective date must have the same business meaning before and after migration.&lt;/li&gt;
&lt;li&gt;Ambiguous or contradictory data must fail safely and become visible.&lt;/li&gt;
&lt;li&gt;Reprocessing the same record must not produce duplicate downstream actions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This step sounds obvious, but it changed the review entirely. Instead of asking whether the new client correctly parsed a payload, we could ask whether it preserved the rules the system existed to enforce.&lt;/p&gt;

&lt;p&gt;It also exposed a subtle problem with “the old system is the source of truth.” If the legacy path had known defects, perfect agreement would reproduce them. The old output was a baseline, not an oracle.&lt;/p&gt;

&lt;p&gt;That meant every mismatch needed investigation, but it did not mean the new path was automatically wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase one: make the new path incapable of causing damage
&lt;/h2&gt;

&lt;p&gt;The first version of the new integration was deliberately incomplete. It could query the canonical state API, normalize the response, and calculate what action it &lt;em&gt;would&lt;/em&gt; take. It could not perform that action.&lt;/p&gt;

&lt;p&gt;I think of this as &lt;strong&gt;query-only mode&lt;/strong&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluate_canonical_record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&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;query_only&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;decision&lt;/span&gt;

&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real implementation had more safeguards than this, but the boundary was just as explicit. Read and decide on one side; mutate on the other.&lt;/p&gt;

&lt;p&gt;Query-only mode gave us a few useful properties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We could run the code with realistic production data.&lt;/li&gt;
&lt;li&gt;We could inspect decisions without creating tickets, sending notifications, or changing access.&lt;/li&gt;
&lt;li&gt;We could debug authentication, pagination, missing fields, and schema assumptions separately from cutover risk.&lt;/li&gt;
&lt;li&gt;We had a reusable operational tool for investigating individual records.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This last point was unexpectedly valuable. Migration controls are often treated as temporary scaffolding, but a safe read-only execution mode is also a good diagnostic interface. It lets an engineer ask, &lt;em&gt;“What would the system do with this input right now?”&lt;/em&gt; without having to reproduce the entire workflow locally.&lt;/p&gt;

&lt;p&gt;There was one rule we kept firm: query-only could not mean “mostly read-only.” If a code path still emitted an event or called a downstream service before checking the flag, the control was cosmetic. The no-side-effect guarantee had to sit at the boundary where side effects began.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase two: one writer, two decision-makers
&lt;/h2&gt;

&lt;p&gt;Once the new path could evaluate real records safely, we ran it alongside the legacy implementation.&lt;/p&gt;

&lt;p&gt;Only the legacy path was allowed to perform actions. The new path observed the same logical input and produced a candidate decision. We then compared the two.&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%2Fy4yv3xon2pthqhxjgg7f.webp" 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%2Fy4yv3xon2pthqhxjgg7f.webp" alt="Production input runs through both the legacy and canonical evaluation paths, while only the legacy path can perform the automated action" width="799" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is usually called a shadow migration or dark launch. The new code sees production-shaped traffic but does not own production effects.&lt;/p&gt;

&lt;p&gt;The obvious implementation compares two Boolean values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;legacy_should_process&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;new_should_process&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is useful, but not enough. When the values differ, a Boolean tells you that the migration is unsafe and nothing about why.&lt;/p&gt;

&lt;p&gt;We made the decision explain itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;should_process&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;effective_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currently_active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;source_record_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&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;Now the shadow result could compare:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Whether each path would act.&lt;/li&gt;
&lt;li&gt;Which effective date it would use.&lt;/li&gt;
&lt;li&gt;Why it reached that conclusion.&lt;/li&gt;
&lt;li&gt;Which source record contributed to the decision.&lt;/li&gt;
&lt;li&gt;Whether either path considered the input incomplete or ambiguous.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the part of the migration I would reuse everywhere: &lt;strong&gt;compare normalized decisions, not raw responses&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Raw payload comparison is noisy. One API may use &lt;code&gt;null&lt;/code&gt; where another omits a field. Dates may use different time zones. Identifiers may refer to different resources. A hundred harmless representation differences can hide the one semantic difference that matters.&lt;/p&gt;

&lt;p&gt;The normalized decision is the contract your users actually experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  A mismatch is a finding, not just an error
&lt;/h2&gt;

&lt;p&gt;As soon as the comparison ran against real data, discrepancies appeared. That was the point.&lt;/p&gt;

&lt;p&gt;It is tempting to turn every mismatch into a page. I would avoid that. Early shadow traffic can be noisy, and training people to ignore an alert stream is a poor way to launch a critical system.&lt;/p&gt;

&lt;p&gt;Instead, we recorded every mismatch with enough context to investigate it and grouped them into a small taxonomy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Expected timing differences&lt;/strong&gt; — one source had updated before the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Representation differences&lt;/strong&gt; — the sources agreed, but normalization was incomplete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic differences&lt;/strong&gt; — both records were valid, but the business interpretation differed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data-quality problems&lt;/strong&gt; — records were missing, stale, or internally contradictory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implementation defects&lt;/strong&gt; — our new code had selected the wrong field or applied the rule incorrectly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This classification matters because each category leads to a different response.&lt;/p&gt;

&lt;p&gt;A timing difference may need a grace period or a later recheck. A representation problem belongs in the adapter. A semantic difference needs a product or domain decision. Bad source data needs a defensive rule and an escalation path. An implementation bug needs code and a regression test.&lt;/p&gt;

&lt;p&gt;Without a taxonomy, teams tend to “fix the diff” until the graphs become green. That can accidentally teach the new system to mimic legacy behaviour without understanding it.&lt;/p&gt;

&lt;p&gt;The goal was not zero differences at any cost. It was zero &lt;strong&gt;unexplained&lt;/strong&gt; differences.&lt;/p&gt;

&lt;h2&gt;
  
  
  The edge cases were the migration
&lt;/h2&gt;

&lt;p&gt;The happy path agreed quickly. That did not make the migration nearly complete.&lt;/p&gt;

&lt;p&gt;Two edge cases forced us to refine the new model.&lt;/p&gt;

&lt;p&gt;The first involved a historical end-state event for an entity whose current state had since changed. If we looked only for the existence of that event, we could produce a false positive. The canonical record gave us another signal: the entity’s current status. We added that verification before allowing the workflow to proceed.&lt;/p&gt;

&lt;p&gt;The second involved choosing the effective date. The new source exposed more than one plausible date, and the most conveniently named field was not necessarily the date the downstream process expected. We had to trace the business meaning through the old path and deliberately select the corresponding value.&lt;/p&gt;

&lt;p&gt;Neither bug was difficult to fix once understood. The hard part was creating a migration that allowed us to see them before they became actions.&lt;/p&gt;

&lt;p&gt;That is why I do not judge shadow migrations by the amount of traffic replayed. A million ordinary records can give more confidence than they deserve. One reactivation, one delayed update, or one contradictory date can tell you much more about whether the new model is correct.&lt;/p&gt;

&lt;p&gt;Coverage should be measured across &lt;strong&gt;business scenarios&lt;/strong&gt; , not only request counts.&lt;/p&gt;

&lt;p&gt;For this kind of workflow I want an explicit scenario set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Normal lifecycle transition.&lt;/li&gt;
&lt;li&gt;Future-dated transition.&lt;/li&gt;
&lt;li&gt;Reactivation or status reversal.&lt;/li&gt;
&lt;li&gt;Missing optional attributes.&lt;/li&gt;
&lt;li&gt;Conflicting dates.&lt;/li&gt;
&lt;li&gt;Duplicate input.&lt;/li&gt;
&lt;li&gt;Source timeout or partial response.&lt;/li&gt;
&lt;li&gt;A record that changes while being processed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some scenarios will occur naturally during the shadow period. Rare but dangerous ones should be exercised with fixtures or controlled replay rather than waiting for production to provide them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deciding when to cut over
&lt;/h2&gt;

&lt;p&gt;“The dashboards look fine” is not a cutover criterion.&lt;/p&gt;

&lt;p&gt;Before transferring authority to the new path, we wanted evidence in several dimensions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Correctness:&lt;/strong&gt; no unexplained decision or effective-date mismatches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scenario coverage:&lt;/strong&gt; important lifecycle transitions had been observed or tested.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stability:&lt;/strong&gt; the comparison stayed clean across a meaningful observation window, not just one quiet day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure behaviour:&lt;/strong&gt; timeouts, missing records, and contradictory data failed safely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability:&lt;/strong&gt; we could tell whether the new path queried, decided, skipped, failed, or acted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback:&lt;/strong&gt; restoring the old authority was understood and quick.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no universal percentage or number of days that makes a migration safe. The right window depends on the frequency and consequence of the events you are trying to observe.&lt;/p&gt;

&lt;p&gt;If an important edge case happens once a month, a clean afternoon tells you nothing about it. If the cost of a false positive is high, the threshold should reflect that asymmetry.&lt;/p&gt;

&lt;p&gt;The cutover itself was intentionally uneventful. We changed which path was authoritative while retaining the ability to compare and roll back. We did not bundle unrelated cleanup into the same release. Boring is a feature when transferring control of a critical workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not leave the old path “just in case”
&lt;/h2&gt;

&lt;p&gt;After the new path had operated successfully, we removed the legacy implementation and the shadow comparison.&lt;/p&gt;

&lt;p&gt;This can feel premature. Keeping the old path around appears to preserve a fallback. In reality, a dormant fallback decays quickly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Its credentials and dependencies still need maintenance.&lt;/li&gt;
&lt;li&gt;Engineers must continue reasoning about two implementations.&lt;/li&gt;
&lt;li&gt;Future changes may update one path but not the other.&lt;/li&gt;
&lt;li&gt;Someone can accidentally reactivate code that has not been exercised in months.&lt;/li&gt;
&lt;li&gt;The temporary feature flag becomes permanent architecture.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A rollback path is valuable during migration. A second production system with no clear retirement date is technical debt.&lt;/p&gt;

&lt;p&gt;We treated deletion as a planned migration phase rather than optional cleanup:&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%2F7dmdlqv1r2migvla2nj8.webp" 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%2F7dmdlqv1r2migvla2nj8.webp" alt="The migration moves through build, observe, reconcile, cut over, soak, and delete phases, with rollback available through the soak period" width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The order matters. Deleting before the soak period removes your quickest recovery option. Never deleting leaves you paying for the migration forever.&lt;/p&gt;

&lt;p&gt;The comparison infrastructure should also be removed or deliberately repurposed. Shadow code often doubles reads, emits high-cardinality logs, and contains branching that the main workflow no longer needs. Once its question has been answered, it should not quietly become part of the permanent request path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would carry into the next migration
&lt;/h2&gt;

&lt;p&gt;The practical pattern is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Define the invariants before translating fields.&lt;/li&gt;
&lt;li&gt;Separate reads and decisions from side effects.&lt;/li&gt;
&lt;li&gt;Give the new path a genuine query-only mode.&lt;/li&gt;
&lt;li&gt;Keep exactly one writer while both paths evaluate.&lt;/li&gt;
&lt;li&gt;Compare normalized decisions and their reasons.&lt;/li&gt;
&lt;li&gt;Investigate and classify every meaningful mismatch.&lt;/li&gt;
&lt;li&gt;Measure coverage across business scenarios, not only traffic volume.&lt;/li&gt;
&lt;li&gt;Set evidence-based cutover and rollback criteria.&lt;/li&gt;
&lt;li&gt;Keep the cutover small.&lt;/li&gt;
&lt;li&gt;Delete the legacy path after a defined soak period.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The broader lesson is that migrations like this are not primarily data plumbing exercises. They are exercises in transferring trust.&lt;/p&gt;

&lt;p&gt;The old path has years of accumulated behaviour, including behaviour nobody documented because the code made it seem obvious. The new source may be cleaner and more canonical, but that does not make your interpretation of it correct. Confidence comes from forcing both systems to make their decisions in the open, then explaining every place they disagree.&lt;/p&gt;

&lt;p&gt;That takes longer than changing an endpoint. It is still much cheaper than discovering after cutover that a green deployment was making the wrong decision perfectly.&lt;/p&gt;

&lt;p&gt;Thanks for reading ✌️&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>distributedsystems</category>
      <category>sre</category>
      <category>backend</category>
    </item>
    <item>
      <title>When "no healthy upstream" isn't about the upstream you think</title>
      <dc:creator>Sahan</dc:creator>
      <pubDate>Thu, 23 Jul 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/sahan/when-no-healthy-upstream-isnt-about-the-upstream-you-think-1lf0</link>
      <guid>https://dev.to/sahan/when-no-healthy-upstream-isnt-about-the-upstream-you-think-1lf0</guid>
      <description>&lt;p&gt;&lt;code&gt;no healthy upstream&lt;/code&gt; is the kind of error that makes you expect wreckage.&lt;/p&gt;

&lt;p&gt;Then you open the dashboards and find… almost nothing. CPU is low. No pods have crashed. The last deployment was hours ago. By the time you refresh the page, the service has recovered by itself.&lt;/p&gt;

&lt;p&gt;That was the scene a few weeks ago when I started chasing an intermittent failure in a search backend. The eventual fix was only a few lines. The interesting part was getting there.&lt;/p&gt;

&lt;p&gt;We already had a confident root-cause analysis (RCA), complete with a tidy explanation and a one-line remedy. It was also pointing at the wrong subsystem. This post is about the gap between a plausible story and the evidence, and about a common failure mode in which a mostly healthy fleet slowly removes itself from service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the failure
&lt;/h2&gt;

&lt;p&gt;The setup was ordinary: a search backend behind a load balancer, with a fixed pool of worker processes on each instance.&lt;/p&gt;

&lt;p&gt;Every so often, with no obvious schedule, a burst of requests failed. The browser showed a bare &lt;code&gt;no healthy upstream&lt;/code&gt;, and a minute or two later everything worked again.&lt;/p&gt;

&lt;p&gt;One clue appeared every time. Backend p99 latency climbed to &lt;em&gt;almost exactly&lt;/em&gt; the load balancer timeout, stayed flat, and then dropped back to normal:&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%2Fqmoff5wwgwnhhacu80tg.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%2Fqmoff5wwgwnhhacu80tg.png" alt="Backend p99 latency rising sharply to the load balancer timeout, remaining flat during the failure window, and then returning to baseline" width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That shape matters. Organic slowdowns tend to be uneven. This was a cliff, a flat top, and a recovery. Requests were not gradually becoming slower; they were running into a deadline and being cut off.&lt;/p&gt;

&lt;p&gt;The theory I inherited was &lt;strong&gt;CPU throttling&lt;/strong&gt;. A heavy periodic job supposedly pegged the pod’s CPU, the scheduler throttled it, request handling starved, and the load balancer eventually evicted the instance.&lt;/p&gt;

&lt;p&gt;It was coherent. Better still, it came with a one-line fix: raise the CPU limit. That is an attractive combination during an incident. But a root-cause theory makes predictions, and these predictions did not survive contact with the evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treating the RCA as a hypothesis, not a conclusion
&lt;/h2&gt;

&lt;p&gt;Instead of treating the existing RCA as a conclusion, I treated it as a hypothesis: if CPU throttling caused the incidents, what else should I be able to observe?&lt;/p&gt;

&lt;p&gt;Three checks came back wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There was no CPU-bound work in the serving path.&lt;/strong&gt; The compute-heavy batch indexers ran on &lt;em&gt;separate&lt;/em&gt; machines and reached the datastore over the network. They never ran inside the pods serving requests. A process outside the pod’s cgroup cannot cause that pod to be CPU-throttled. The graphs agreed: container throttling counters stayed flat during every event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The timing did not fit a scheduled trigger.&lt;/strong&gt; The batch jobs ran on a coarse schedule and produced one sustained utilisation bump. The incidents arrived at arbitrary minutes and happened far more often than the jobs ran. If a timer were responsible, the failures should have followed the timer. They did not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The failures ignored instance and version boundaries.&lt;/strong&gt; The same symptom appeared across different pods and deployment SHAs. A regression usually follows a version. A shared external event usually hits the fleet together. These failures did neither. The pattern looked more like each instance was doing something &lt;em&gt;to itself&lt;/em&gt;, triggered by its own traffic.&lt;/p&gt;

&lt;p&gt;To keep the CPU theory alive, I would have had to explain away the topology, the timing, and the distribution of failures. At that point the theory was creating more questions than it answered, so I dropped it and went back to the logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  One line in the logs, and why it changes the model
&lt;/h2&gt;

&lt;p&gt;The event window contained one recurring error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ConnectionTimeout: Connection timed out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stack ended in the datastore client, blocked on a socket read that never returned.&lt;/p&gt;

&lt;p&gt;That one line flipped the model.&lt;/p&gt;

&lt;p&gt;A CPU-throttled worker is ready to run but cannot get enough scheduler time. A worker blocked on I/O is off-CPU, waiting on the network while still occupying its worker slot. From the outside, both look like “latency went up, then requests timed out.” Underneath, they are opposites.&lt;/p&gt;

&lt;p&gt;Adding CPU to an I/O stall does not unblock the socket. At best, it gives you more workers to park behind the same slow dependency. This is why edge symptoms are a dangerous thing to tune against: resource saturation and dependency blocking can produce the same fever while needing completely different treatment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one slow dependency saturates the whole instance
&lt;/h2&gt;

&lt;p&gt;The mechanism came down to two ordinary client settings that were dangerous in combination:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No timeout on individual datastore calls.&lt;/strong&gt; One call could occupy a worker long after the user had given up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries on connection timeouts.&lt;/strong&gt; After waiting too long once, the worker would wait again, with backoff in between.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing exotic. That is partly what makes this failure mode easy to miss.&lt;/p&gt;

&lt;p&gt;Little’s Law shows why it is fatal to a fixed worker pool. Concurrency is &lt;code&gt;L = λW&lt;/code&gt;: the arrival rate (&lt;code&gt;λ&lt;/code&gt;) multiplied by the average time each request spends in the system (&lt;code&gt;W&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Suppose the service receives 200 requests per second and normally responds in 40 ms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;L = 200 req/s × 0.04 s = 8 concurrent requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight concurrent requests are easy for the pool to absorb. But if the datastore slows down and requests wait for tens of seconds, &lt;code&gt;W&lt;/code&gt; increases by three orders of magnitude. Retries stretch it further. The required concurrency quickly exceeds the number of workers available.&lt;/p&gt;

&lt;p&gt;The pool then looks something like this:&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%2Fri82z29xylte2gmuyvmr.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%2Fri82z29xylte2gmuyvmr.png" alt="Comparison of a healthy worker pool with spare capacity and a saturated pool where every worker waits on the datastore while requests and health checks queue" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This creates &lt;strong&gt;head-of-line blocking&lt;/strong&gt;. Workers parked on datastore calls cannot serve the fast requests behind them, so latency rises for &lt;em&gt;everything&lt;/em&gt;, not only for requests that reached the slow dependency.&lt;/p&gt;

&lt;p&gt;Health checks are caught in the same queue. They time out, the load balancer removes the instance from rotation, and the remaining instances receive more traffic. Once enough instances fail their health checks, the load balancer has nowhere to send the next request. That is when the user sees &lt;code&gt;no healthy upstream&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There was one more twist. The client’s total retry time could exceed the load balancer’s deadline:&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%2F0i8udb7skg7w41jwejt3.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%2F0i8udb7skg7w41jwejt3.png" alt="Timeline showing client retries continuing after the load balancer deadline and holding a worker after the user has gone" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The load balancer returned an error while the worker continued retrying a result nobody could receive. Every millisecond after the outer deadline was wasted work, and the wasted work held a scarce worker slot.&lt;/p&gt;

&lt;p&gt;This is a &lt;strong&gt;deadline-propagation&lt;/strong&gt; failure. Inner operations must finish inside the outer request deadline. Better still, pass the outer deadline through the call chain so every layer knows when its result has become useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it recovered by itself
&lt;/h2&gt;

&lt;p&gt;The recovery was initially reassuring. In hindsight, it was the worrying part.&lt;/p&gt;

&lt;p&gt;The datastore blip triggered retries. Those retries added load to the datastore while it was already struggling, which caused more timeouts and therefore more retries:&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%2F869hast8hhod3x3f82pc.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%2F869hast8hhod3x3f82pc.png" alt="Feedback loop where a slow datastore causes timeouts, retries, and additional datastore load that reinforces the slowdown" width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the start of a &lt;strong&gt;metastable failure&lt;/strong&gt; : a brief trigger knocks the system out of its healthy state, then a feedback loop keeps it unhealthy after the original trigger has passed.&lt;/p&gt;

&lt;p&gt;We got lucky. The datastore blips were short enough that traffic fell below the tipping point before the retry loop became self-sustaining. A slightly longer blip could have kept the loop alive until we restarted the fleet or shed enough traffic to escape it.&lt;/p&gt;

&lt;p&gt;Self-recovery was not proof of resilience. It was a warning shot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and why we kept it small
&lt;/h2&gt;

&lt;p&gt;The instinct during an availability incident is to add headroom: raise CPU limits, increase the worker pool, add replicas. That can help with genuine capacity problems. Here, it would only give the retry loop more workers to occupy.&lt;/p&gt;

&lt;p&gt;The useful fix was to put a hard bound on the cost of one request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, add an explicit timeout to every downstream call.&lt;/strong&gt; Choose it from the dependency’s healthy latency distribution rather than picking a pleasing round number. It should sit comfortably above healthy p99.9, but well below the load balancer’s deadline. A call that can wait forever is a worker you can lose forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, bound retries as a fraction of normal traffic, not as an unconditional count on every request.&lt;/strong&gt; A rule such as “retry three times” allows every client to multiply traffic precisely when the dependency is least able to handle it. A token-bucket retry budget keeps the added load bounded; the Google SRE guidance uses 10% as an example. Add jitter too, or clients can wake up and retry in synchronised waves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, be especially reluctant to retry connection timeouts.&lt;/strong&gt; During saturation, a timeout often means the dependency is already over its limit. Another immediate attempt is unlikely to help. Retries make the most sense for independent, transient failures and only for idempotent operations. Search requests were idempotent, at least, so duplicate side effects were not another problem waiting for us.&lt;/p&gt;

&lt;p&gt;The decision for each failed call becomes straightforward:&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%2F6845gh2q6hg7gwo7o6no.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%2F6845gh2q6hg7gwo7o6no.png" alt="Decision flow where a dependency call returns on success, retries only while deadline and budget remain, and otherwise fails fast to free the worker" width="800" height="507"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Circuit breakers, load shedding, and bulkheads could all strengthen this design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;circuit breaker&lt;/strong&gt; stops callers repeatedly rediscovering the same outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load shedding&lt;/strong&gt; rejects excess work early enough to keep the service responsive.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;bulkhead&lt;/strong&gt; gives the dependency its own bounded concurrency pool, preventing it from occupying every worker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three add code, tuning, and operational state. We did not have evidence that we needed them yet. Once a small timeout and a bounded retry policy removed the amplifier, adding more machinery would have solved a hypothetical problem rather than the incident in front of us.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Won’t failing fast just move the errors to the caller?”
&lt;/h2&gt;

&lt;p&gt;This was the immediate pushback: if the backend gives up sooner, won’t users simply see more errors?&lt;/p&gt;

&lt;p&gt;Only if we compare failing fast with a world in which every request eventually succeeds. That was not the world we had.&lt;/p&gt;

&lt;p&gt;With the worker pool full of hung calls, &lt;em&gt;every&lt;/em&gt; request eventually failed. The entire search feature became a &lt;code&gt;no healthy upstream&lt;/code&gt; page, including requests that never needed the slow dependency in the first place.&lt;/p&gt;

&lt;p&gt;Failing fast keeps the instances responsive and in rotation. It turns one correlated, fleet-wide outage into a smaller number of independent failures: the requests that actually hit the bad path. Those are failures a caller can absorb with a cached result, an empty state, or a retry button.&lt;/p&gt;

&lt;p&gt;A nonessential component should not be able to take down the whole page. Give it its own deadline and a graceful fallback, and a slow dependency degrades one part of the experience instead of replacing the entire document with an error page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the bound, not asserting it
&lt;/h2&gt;

&lt;p&gt;I did not want to ship a fix that worked only on a whiteboard, so the validation was intentionally mechanical.&lt;/p&gt;

&lt;p&gt;With a &lt;strong&gt;healthy dependency&lt;/strong&gt; , normal traffic should remain normal: the same latency distribution and no new errors. If the timeout clips healthy p99.9 requests, it is too aggressive and will create the very failures it is meant to prevent.&lt;/p&gt;

&lt;p&gt;With an &lt;strong&gt;unreachable dependency&lt;/strong&gt; , requests should fail near the configured bound and before the outer load balancer deadline. More importantly, worker occupancy and in-flight request counts should remain flat instead of climbing.&lt;/p&gt;

&lt;p&gt;That flat line under induced failure is the real acceptance test. It shows that the amplifier is gone.&lt;/p&gt;

&lt;p&gt;The saturating case needs its own load test. The happy path will never prove that a service behaves well when every downstream call is stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually took from it
&lt;/h2&gt;

&lt;p&gt;The technical lesson is easy to audit for: an unbounded downstream call plus eager retries is a latent outage with a feedback loop attached. It can sit quietly for months, green on every dashboard, until a dependency has a bad thirty seconds. Then it turns that blip into a fleet-wide event.&lt;/p&gt;

&lt;p&gt;It is worth searching your own services for downstream calls without deadlines and retries without a shared budget. Those two settings deserve to be reviewed together, because together they can change the shape of a failure.&lt;/p&gt;

&lt;p&gt;The lesson that stayed with me, though, was about diagnosis.&lt;/p&gt;

&lt;p&gt;The CPU theory was clean. It was mechanistic. It came with a satisfying one-line fix. And it survived three pieces of contradictory evidence because we had started treating it as an answer instead of a claim.&lt;/p&gt;

&lt;p&gt;A useful root cause makes predictions you can check. When the topology, timing, and distribution of failures all disagree with the story, elegance stops counting. In this case, one dull line in a log file told us more than the tidy explanation we had already grown attached to.&lt;/p&gt;

&lt;p&gt;That was the expensive part of the incident: not the eventual three-line fix, but learning to let the evidence ruin a good story.&lt;/p&gt;

&lt;p&gt;Thanks for reading ✌️&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://sigops.org/s/conferences/hotos/2021/papers/hotos21-s11-bronson.pdf" rel="noopener noreferrer"&gt;Metastable Failures in Distributed Systems - Bronson et al., HotOS ‘21&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/" rel="noopener noreferrer"&gt;Timeouts, retries, and backoff with jitter - Amazon Builders’ Library&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sre.google/sre-book/addressing-cascading-failures/" rel="noopener noreferrer"&gt;Addressing Cascading Failures - Google SRE Book&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sre.google/sre-book/handling-overload/" rel="noopener noreferrer"&gt;Handling Overload - Google SRE Book&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://research.google/pubs/pub40801/" rel="noopener noreferrer"&gt;The Tail at Scale - Dean &amp;amp; Barroso, CACM 2013&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.envoyproxy.io/docs/envoy/latest/faq/load_balancing/disable_circuit_breaking" rel="noopener noreferrer"&gt;no healthy upstream - Envoy load balancing FAQ&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://martinfowler.com/bliki/CircuitBreaker.html" rel="noopener noreferrer"&gt;Circuit Breaker - Martin Fowler&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>devops</category>
      <category>architecture</category>
      <category>backend</category>
      <category>performance</category>
    </item>
    <item>
      <title>The Acknowledgment Gap - How Event-Driven Systems Lose Messages Without Errors</title>
      <dc:creator>Sahan</dc:creator>
      <pubDate>Thu, 16 Jul 2026 01:05:00 +0000</pubDate>
      <link>https://dev.to/sahan/the-acknowledgment-gap-how-event-driven-systems-lose-messages-without-errors-5hmj</link>
      <guid>https://dev.to/sahan/the-acknowledgment-gap-how-event-driven-systems-lose-messages-without-errors-5hmj</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you have built event-driven systems for any length of time, you have probably internalized the mantra of &lt;em&gt;at-least-once delivery&lt;/em&gt;: keep retrying until the work is done, and design everything downstream to be idempotent. It’s good advice. But there’s a subtle failure mode that hides right underneath it - one where your system faithfully reports success, commits its progress, and quietly drops work on the floor. No exception, no alert, no dead letter. Just a message that was supposed to do something, and didn’t.&lt;/p&gt;

&lt;p&gt;I ran into this recently while debugging why a small percentage of events were mysteriously never being processed. Everything &lt;em&gt;looked&lt;/em&gt; healthy. The producer got a &lt;code&gt;2xx&lt;/code&gt;. The consumer committed its offset. The dashboards were green. And yet the work never happened. This post is about that gap - the space between “the system accepted my request” and “the system actually did the work” - and why it’s one of the more dangerous places for a distributed system to lose data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Motivation
&lt;/h2&gt;

&lt;p&gt;Most write-ups about message-processing reliability focus on the well-known hazards: duplicate delivery, out-of-order messages, poison pills, consumer lag. Those are real, and there’s plenty written about them. What I found much less discussed is the case where &lt;strong&gt;the acknowledgment itself is a lie&lt;/strong&gt; - where a component reports success for an operation that has only been &lt;em&gt;accepted&lt;/em&gt;, not &lt;em&gt;completed&lt;/em&gt;, and a second component treats that acknowledgment as permission to throw the original message away.&lt;/p&gt;

&lt;p&gt;This is a design smell that shows up across all sorts of stacks: a queue consumer that calls an async API, a workflow engine that enqueues a job, a service that hands off to a background worker. Any time you have a &lt;strong&gt;handoff across an asynchronous boundary&lt;/strong&gt; , you have the potential for this gap. So I want to walk through the anatomy of the bug from first principles, then talk about how to close it properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background: two kinds of “yes”
&lt;/h2&gt;

&lt;p&gt;Before we get to the bug, let’s be precise about acknowledgments, because the whole problem lives in some sloppy vocabulary.&lt;/p&gt;

&lt;p&gt;When a system replies to your request, it can mean one of two very different things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;“I have accepted your request.”&lt;/strong&gt; - I’ve durably recorded your intent, and I promise to &lt;em&gt;try&lt;/em&gt; to do the work. Think &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/202" rel="noopener noreferrer"&gt;HTTP 202 Accepted&lt;/a&gt;. The work hasn’t happened yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“I have completed your request.”&lt;/strong&gt; - The work is done, and its effects are durable. Think &lt;code&gt;200 OK&lt;/code&gt; with a result body.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are worlds apart, and conflating them is the root of a lot of pain. The trouble is that many APIs return the &lt;em&gt;same status code&lt;/em&gt; for “accepted” whether or not the work will eventually succeed. A &lt;code&gt;202&lt;/code&gt; (or a &lt;code&gt;204 No Content&lt;/code&gt;, which is even more ambiguous) tells you the request was received. It tells you &lt;em&gt;nothing&lt;/em&gt; about whether the work will run.&lt;/p&gt;

&lt;p&gt;Now layer on the consumer side. A huge number of event-driven systems are built on brokers that use &lt;strong&gt;offset-based consumer groups&lt;/strong&gt; - &lt;a href="https://en.wikipedia.org/wiki/Apache_Kafka" rel="noopener noreferrer"&gt;Apache Kafka&lt;/a&gt; being the canonical example. If you want a primer, I wrote an &lt;a href="https://sahansera.dev/introduction-to-apache-kafka/" rel="noopener noreferrer"&gt;introduction to Apache Kafka&lt;/a&gt; a while back. The mental model is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Messages in a partition have monotonically increasing offsets.&lt;/li&gt;
&lt;li&gt;Your consumer reads a message, does some work, and then &lt;strong&gt;commits&lt;/strong&gt; (or “marks”) the offset to say &lt;em&gt;“I’m done with everything up to here.”&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;If the consumer crashes before committing, the broker redelivers from the last committed offset. That’s what gives you at-least-once semantics.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 The offset commit is a &lt;em&gt;promise about the past&lt;/em&gt;. When you commit offset N, you are telling the broker “every message up to and including N has been fully handled, and you never need to give them to me again.” If that statement isn’t actually true, you have manufactured data loss with your own hands.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Hold onto those two ideas - the ambiguous “yes” and the offset-as-promise - because the bug is what happens when they collide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of the pipeline
&lt;/h2&gt;

&lt;p&gt;Let me describe a deliberately generic pipeline. Strip away the specific technologies and almost every async system looks like this:&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%2Fyf0tq0ee976zmexiqrxh.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%2Fyf0tq0ee976zmexiqrxh.png" alt="Event processing pipeline showing the source, broker, consumer, asynchronous job API, worker, and offset commit path" width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The consumer’s job is to read an event and translate it into an &lt;em&gt;action&lt;/em&gt; by calling some downstream control plane - an async job API, a workflow trigger, a task queue. The control plane accepts the request and, at some later point, a worker actually executes it.&lt;/p&gt;

&lt;p&gt;The consumer’s loop, in pseudocode, looked roughly like this:&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;for&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;broker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Read&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;dispatchJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// calls the async control plane&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="n"&gt;retryWithBackoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dispatchJob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Always commit so we never get stuck reprocessing a bad message.&lt;/span&gt;
    &lt;span class="n"&gt;broker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&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;At a glance this looks reasonable, even defensive. There’s a retry with backoff. There’s a comment explaining that we always commit to avoid getting wedged on a poison message. Someone clearly thought about failure here.&lt;/p&gt;

&lt;p&gt;And that is exactly what makes the bug so insidious.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: the acknowledgment gap
&lt;/h2&gt;

&lt;p&gt;Here’s the sequence that loses data.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The consumer reads a message and calls &lt;code&gt;dispatchJob&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The control plane returns &lt;strong&gt;&lt;code&gt;204 No Content&lt;/code&gt;&lt;/strong&gt; - &lt;em&gt;“request accepted, a job has been created.”&lt;/em&gt; From the consumer’s point of view, this is success. &lt;code&gt;err&lt;/code&gt; is &lt;code&gt;nil&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Milliseconds later, and &lt;strong&gt;entirely outside the consumer’s view&lt;/strong&gt; , the control plane &lt;em&gt;rejects&lt;/em&gt; the job before it executes. Maybe a concurrency quota was exceeded. Maybe an admission controller said no. Maybe the queue was full. The job transitions straight to a terminal “rejected” state without a single line of business logic ever running.&lt;/li&gt;
&lt;li&gt;Back in the consumer, &lt;code&gt;dispatchJob&lt;/code&gt; returned &lt;code&gt;nil&lt;/code&gt;, so the retry loop never fires - there was nothing to retry, as far as it knows.&lt;/li&gt;
&lt;li&gt;The consumer commits the offset.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The offset moves forward. The broker will never redeliver that message. The job never ran. And nobody was told.&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%2F8raghqfmzp3r9lx03ywe.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%2F8raghqfmzp3r9lx03ywe.png" alt="Sequence diagram showing a job being accepted, rejected before execution, and then lost when the consumer commits its offset" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the acknowledgment gap. The failure happened in the &lt;strong&gt;window between “accepted” and “executed”&lt;/strong&gt; , and our success signal was wired to the wrong end of that window. We treated &lt;em&gt;“a job was created”&lt;/em&gt; as if it meant &lt;em&gt;“a job will run,”&lt;/em&gt; and those are not the same statement.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 The most dangerous bugs aren’t the ones that throw. They’re the ones that return &lt;code&gt;nil&lt;/code&gt;. An exception is a gift - it’s the system telling you something is wrong. Silent, structurally-invisible loss gives you nothing to catch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What made it worse is that the “always commit” decision - added defensively to avoid an infinite reprocessing loop - turned a &lt;em&gt;recoverable&lt;/em&gt; failure into an &lt;em&gt;unrecoverable&lt;/em&gt; one. The one safety mechanism that could have saved us (letting the broker redeliver) was disabled precisely when we needed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the usual instincts don’t save you
&lt;/h2&gt;

&lt;p&gt;When engineers first see this, they reach for familiar fixes. Most of them don’t actually close the gap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;“Just check the status code.”&lt;/strong&gt; We did. It was &lt;code&gt;204&lt;/code&gt;. The status code describes the &lt;em&gt;acceptance&lt;/em&gt;, not the &lt;em&gt;outcome&lt;/em&gt;. The information we needed didn’t exist yet at the moment we got the response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Add a retry.”&lt;/strong&gt; There was one. It only triggers on a failed &lt;em&gt;dispatch&lt;/em&gt;, not a failed &lt;em&gt;execution&lt;/em&gt;. You can’t retry something you don’t know failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Make it idempotent.”&lt;/strong&gt; Idempotency is necessary but not sufficient here. Idempotency protects you from doing the work &lt;em&gt;twice&lt;/em&gt;; it does nothing to protect you from doing it &lt;em&gt;zero&lt;/em&gt; times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Use exactly-once semantics.”&lt;/strong&gt; Setting aside the long debate about whether &lt;a href="https://en.wikipedia.org/wiki/Two_Generals%27_Problem" rel="noopener noreferrer"&gt;exactly-once is even a coherent goal&lt;/a&gt; across independent systems - the transactional guarantees of your broker do not extend into a third-party control plane you’re calling over HTTP. The moment you cross that boundary, you’re back to coordinating two independent systems with no shared transaction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real issue is architectural: &lt;strong&gt;we committed our durable progress based on a signal that didn’t actually confirm the work was durable.&lt;/strong&gt; No amount of tuning the individual pieces fixes that. You have to move the acknowledgment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it: verify before you ack
&lt;/h2&gt;

&lt;p&gt;The core principle is a single sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Never acknowledge a message until you have confirmed the work it represents has actually started (or completed) - not merely been accepted.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Everything else is mechanics. Let’s walk through them, because the mechanics are where the interesting distributed-systems problems hide.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Close the loop: confirm execution, don’t assume it
&lt;/h3&gt;

&lt;p&gt;Instead of trusting the &lt;code&gt;204&lt;/code&gt;, the consumer now &lt;em&gt;verifies&lt;/em&gt; that the dispatched job reached a real running (or terminal-success) state before committing. In practice that means polling the control plane’s read API after dispatch:&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="n"&gt;started&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;dispatchAndVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&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;started&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;broker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// safe: the work is genuinely underway&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// do NOT commit - let redelivery give us another shot&lt;/span&gt;
    &lt;span class="n"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dispatchAndVerify&lt;/code&gt; dispatches, then polls: &lt;em&gt;did a job actually enter a non-rejected state?&lt;/em&gt; If it sees the tell-tale “rejected before executing” terminal state, or it can’t find the job at all within a bounded window, it treats that as a failure - which is the thing our original code could never see.&lt;/p&gt;

&lt;p&gt;This is really just applying &lt;strong&gt;read-after-write&lt;/strong&gt; thinking to a control plane. Don’t trust the write acknowledgment; go read the state back and confirm reality matches your intent.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The correlation problem
&lt;/h3&gt;

&lt;p&gt;Here’s a genuinely tricky sub-problem that this exposes, and it’s a great example of why distributed systems are hard: &lt;strong&gt;the dispatch API often doesn’t tell you the ID of the thing it just created.&lt;/strong&gt; You fire a request, you get back &lt;code&gt;204 No Content&lt;/code&gt; - literally no content - and now you need to find “the job I just created” among all the jobs.&lt;/p&gt;

&lt;p&gt;You’re left correlating on secondary signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;creation timestamp window&lt;/strong&gt; (“a job created after time T”), which is racy under concurrency - two near-simultaneous dispatches can be ambiguous.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;business key&lt;/strong&gt; embedded into the job’s metadata at creation time, if the API lets you set something like a name or a label.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The robust fix is to make the work &lt;strong&gt;self-identifying&lt;/strong&gt; : stamp a correlation key you already own (the entity ID, a request UUID) into the job at dispatch time, so that when you read the state back you can match on it &lt;em&gt;exactly&lt;/em&gt; rather than guessing by time. If your control plane supports naming or tagging the work, use it. This is the async equivalent of propagating a trace ID, and it pays for itself the first time you have to debug a race.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Any time you hand work across an async boundary, ask: &lt;em&gt;“When this comes back, how will I know it’s mine?”&lt;/em&gt; If the answer is “by timestamp,” you have a race waiting to happen.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3. Bounded redelivery, or: don’t trade loss for a hot loop
&lt;/h3&gt;

&lt;p&gt;The moment you say “don’t commit on failure so the broker redelivers,” someone will rightly point out the opposite failure mode: what if the work &lt;em&gt;keeps&lt;/em&gt; failing? Now you’ve built an infinite reprocessing loop, and you’re hammering a control plane that’s already unhappy. This is the eternal tension:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Commit too eagerly&lt;/strong&gt; → you lose messages (the original bug).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never commit on failure&lt;/strong&gt; → you can wedge the consumer forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The answer is &lt;strong&gt;bounded retries with escalation&lt;/strong&gt;. Track how many times a given message has been through the wringer - keyed by its stable identity (partition + offset, or a business key) - and:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Retry with &lt;a href="https://en.wikipedia.org/wiki/Exponential_backoff" rel="noopener noreferrer"&gt;exponential backoff&lt;/a&gt; while attempts remain, so a transient quota exhaustion gets a chance to clear.&lt;/li&gt;
&lt;li&gt;Once you’ve exhausted the budget, &lt;strong&gt;stop, escalate loudly, and then commit&lt;/strong&gt; so a single doomed message can’t block the whole partition behind it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That final commit is not “giving up silently” - it’s a deliberate, &lt;em&gt;observable&lt;/em&gt; decision to route the message to a human (or a &lt;a href="https://en.wikipedia.org/wiki/Dead_letter_queue" rel="noopener noreferrer"&gt;dead-letter queue&lt;/a&gt;) instead of blocking the stream. The difference between this and the original bug is everything: the original dropped work with &lt;em&gt;zero&lt;/em&gt; signal; this drops it only after N visible, alarmed attempts.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Separate transient failures from terminal ones
&lt;/h3&gt;

&lt;p&gt;A subtle but important refinement: when you poll to verify, &lt;strong&gt;a failure to read the state is not the same as the work being rejected.&lt;/strong&gt; If your verification call itself hits a network blip or a &lt;code&gt;503&lt;/code&gt;, and you treat that as “the job failed,” you’ll re-dispatch and potentially create &lt;em&gt;duplicate&lt;/em&gt; work - trading a lost-message bug for a double-processing bug.&lt;/p&gt;

&lt;p&gt;So the verification loop needs to distinguish:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;“I confirmed the job was rejected”&lt;/strong&gt; → terminal, re-dispatch is warranted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“I couldn’t reach the control plane to check”&lt;/strong&gt; → transient, just retry the &lt;em&gt;read&lt;/em&gt;, don’t re-dispatch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only definitive answers should drive irreversible decisions. Everything else is a retryable read. This is the same discipline as not making state transitions on ambiguous signals - you wait until you actually &lt;em&gt;know&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Make the invisible visible
&lt;/h3&gt;

&lt;p&gt;The reason this bug survived in production is that it was &lt;strong&gt;structurally unobservable&lt;/strong&gt;. So the last piece is observability, and it’s not optional:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Emit a metric/event whenever a dispatched unit of work fails to start.&lt;/li&gt;
&lt;li&gt;Alert when the retry budget is exhausted and a message is dropped.&lt;/li&gt;
&lt;li&gt;Log the correlation key, the attempt count, and a link to the rejected work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your system is going to make a hard decision like “I’m dropping this after five tries,” that decision must be &lt;em&gt;the loudest thing in the room&lt;/em&gt;, not a silent commit. A good rule of thumb: &lt;strong&gt;every place your code can decide to discard work should be capable of paging a human.&lt;/strong&gt; You may choose not to page - but the capability being there forces you to consciously design the failure path instead of falling into one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stepping back: the general principle
&lt;/h2&gt;

&lt;p&gt;If you zoom out from the specific mechanics, this whole class of bug reduces to a few reusable principles that are worth carrying into any distributed system you build:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Distinguish “accepted” from “completed” - always.&lt;/strong&gt; Treat them as different events with different names, different metrics, and different handling. Never let one masquerade as the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anchor your durable acknowledgment to the durable outcome.&lt;/strong&gt; Commit your offset (or delete your message, or mark your row done) based on confirmation of the &lt;em&gt;effect you care about&lt;/em&gt;, not on a transport-level receipt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;nil&lt;/code&gt; error is a claim, not a fact.&lt;/strong&gt; Verify claims that cross trust boundaries, especially async ones. Read-after-write is cheap insurance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound every retry, and escalate at the boundary.&lt;/strong&gt; Unbounded retries and silent drops are two sides of the same coin; the cure for both is a visible, finite budget with a loud exit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only act irreversibly on unambiguous signals.&lt;/strong&gt; Transient “I don’t know” should never trigger a decision that assumes “no.”&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are novel on their own. What’s interesting is how a single missing distinction - &lt;em&gt;accepted vs. executed&lt;/em&gt; - cascades into silent data loss when it meets an offset commit. It’s a good reminder that in distributed systems, the bugs rarely live inside a component. They live in the &lt;strong&gt;seams between components&lt;/strong&gt; , where two reasonable local decisions add up to one unreasonable global one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tradeoffs
&lt;/h2&gt;

&lt;p&gt;Nothing here is free, and I’d be doing you a disservice to pretend otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you gain&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No more silent loss - the failure mode that’s hardest to detect and most damaging to trust.&lt;/li&gt;
&lt;li&gt;A verifiable, observable processing pipeline where “done” actually means done.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What it costs&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Extra latency and load.&lt;/strong&gt; Verifying execution means additional reads against the control plane per message. Poll intervals and budgets need tuning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More moving parts.&lt;/strong&gt; Correlation keys, attempt tracking, and escalation paths are code you now own and test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You must embrace at-least-once for real.&lt;/strong&gt; Verify-and-redeliver &lt;em&gt;will&lt;/em&gt; occasionally produce duplicates (e.g., if a job actually started but your confirmation read failed). Idempotency downstream stops being optional - but that was always true; this just makes it honest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For low-value, high-volume telemetry you might happily accept silent loss and skip all of this. For work where &lt;em&gt;every single message must result in an action&lt;/em&gt;, the cost is obviously worth it. As always, the right answer depends on what the data is worth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The most memorable bugs are the ones that teach you to distrust a word you’d been using carelessly. For me, that word was “success.” A &lt;code&gt;2xx&lt;/code&gt; is not success. A committed offset is not success. Success is &lt;em&gt;the effect you actually wanted, confirmed to be durable.&lt;/em&gt; Everything else is just a system being polite.&lt;/p&gt;

&lt;p&gt;If you take one thing away: go look at your event-driven pipelines and ask where you commit progress based on an &lt;em&gt;acknowledgment&lt;/em&gt; rather than a &lt;em&gt;confirmation&lt;/em&gt;. If those two things are wired together, you probably have an acknowledgment gap hiding in there too - quietly green on every dashboard, right up until someone asks where their data went.&lt;/p&gt;

&lt;p&gt;Thanks for reading ✌️&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/202" rel="noopener noreferrer"&gt;HTTP 202 Accepted - MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Apache_Kafka" rel="noopener noreferrer"&gt;Apache Kafka - Wikipedia&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Exponential_backoff" rel="noopener noreferrer"&gt;Exponential backoff - Wikipedia&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Dead_letter_queue" rel="noopener noreferrer"&gt;Dead letter queue - Wikipedia&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Two_Generals%27_Problem" rel="noopener noreferrer"&gt;The Two Generals’ Problem - Wikipedia&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>architecture</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>Introducing gh-weekly-updates - Automate Your Weekly GitHub Impact Summaries</title>
      <dc:creator>Sahan</dc:creator>
      <pubDate>Sat, 21 Mar 2026 22:22:00 +0000</pubDate>
      <link>https://dev.to/sahan/introducing-gh-weekly-updates-automate-your-weekly-github-impact-summaries-1f1c</link>
      <guid>https://dev.to/sahan/introducing-gh-weekly-updates-automate-your-weekly-github-impact-summaries-1f1c</guid>
      <description>&lt;p&gt;If you are anything like me, you’ve probably spent a Friday afternoon trying to remember everything you did that week. Maybe it’s for a standup, a 1:1 with your manager, or just to keep track of your own progress. You end up clicking through PRs, issues, and Slack threads, trying to piece together a coherent story. It’s tedious, and honestly, it’s time you could spend doing actual work.&lt;/p&gt;

&lt;p&gt;That’s why I built &lt;a href="https://github.com/sahansera/gh-weekly-updates" rel="noopener noreferrer"&gt;&lt;strong&gt;gh-weekly-updates&lt;/strong&gt;&lt;/a&gt; - a CLI tool that automatically collects your GitHub activity and generates a structured weekly summary using AI.&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.amazonaws.com%2Fuploads%2Farticles%2Fvc015gr9a7oohb9o2m8r.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.amazonaws.com%2Fuploads%2Farticles%2Fvc015gr9a7oohb9o2m8r.png" alt="pypi" width="798" height="179"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;GitHub repo&lt;/strong&gt; : &lt;a href="https://github.com/sahansera/gh-weekly-updates" rel="noopener noreferrer"&gt;github.com/sahansera/gh-weekly-updates&lt;/a&gt;. It’s open source and available on PyPI!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;As engineers, we’re constantly shipping code, reviewing PRs, filing issues, and jumping into discussions. But when it comes time to reflect on the week, all that context is scattered across repos. I wanted something that could:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pull all my GitHub activity into one place&lt;/li&gt;
&lt;li&gt;Summarise it in a way that highlights what actually matters&lt;/li&gt;
&lt;li&gt;Run on a schedule so I don’t have to think about it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I couldn’t find anything that did exactly this, so I built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;gh-weekly-updates&lt;/code&gt; connects to the GitHub API, collects your activity for a given period, and sends it to an AI model (via &lt;a href="https://github.com/marketplace/models" rel="noopener noreferrer"&gt;GitHub Models&lt;/a&gt;) to produce a structured Markdown summary.&lt;/p&gt;

&lt;p&gt;Here’s what it picks up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pull requests&lt;/strong&gt; you authored (with merge status, additions/deletions, changed files)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull requests&lt;/strong&gt; you reviewed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issues&lt;/strong&gt; you created&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issue comments&lt;/strong&gt; you left&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discussions&lt;/strong&gt; you started or participated in&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The output is grouped by project or theme and structured into sections like Wins, Challenges, and What’s Next. You can also customise the prompt to match whatever format your team uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;It’s a Python CLI tool, so you can install it with pip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;gh-weekly-updates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then just run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# If you're already logged in with the GitHub CLI&lt;/span&gt;
gh auth login
gh-weekly-updates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s it. It will auto-discover repos you contributed to in the past week and generate a summary.&lt;/p&gt;

&lt;p&gt;You can also point it at specific repos and date ranges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh-weekly-updates &lt;span class="nt"&gt;--since&lt;/span&gt; 2026-02-09 &lt;span class="nt"&gt;--until&lt;/span&gt; 2026-02-16 &lt;span class="nt"&gt;--repos&lt;/span&gt; my-org/my-repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Configuration
&lt;/h2&gt;

&lt;p&gt;For more control, you can create a &lt;code&gt;config.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;org&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-org&lt;/span&gt;

&lt;span class="na"&gt;repos&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;my-org/api-service&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;my-org/web-app&lt;/span&gt;

&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openai/gpt-4.1&lt;/span&gt;

&lt;span class="c1"&gt;# Automatically push the summary to a repo&lt;/span&gt;
&lt;span class="na"&gt;push_repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-user/my-weekly-updates&lt;/span&gt;

&lt;span class="c1"&gt;# Customise the AI prompt&lt;/span&gt;
&lt;span class="na"&gt;prompt_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-prompt.txt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The config supports everything from repo lists to custom prompts. You can even swap out the AI model if you have a preference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running on a Schedule with GitHub Actions
&lt;/h2&gt;

&lt;p&gt;This is where it gets really useful. You can set up a GitHub Actions workflow to run it every Monday morning and push the summary to a repo automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Weekly Summary&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;9&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt; &lt;span class="c1"&gt;# Every Monday at 9am UTC&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;summarise&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.12'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip install gh-weekly-updates&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generate summary&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GH_PAT }}&lt;/span&gt; &lt;span class="c1"&gt;# must be named GITHUB_TOKEN&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gh-weekly-updates --config config.yaml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every Monday, you get a fresh summary committed to your repo. No manual effort required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom Prompts
&lt;/h2&gt;

&lt;p&gt;The default prompt produces a summary with Wins, Challenges, and What’s Next sections. But you can tailor it to your needs. For example, if your team does impact-style updates, you might want sections like Strategic Influence or Next Steps.&lt;/p&gt;

&lt;p&gt;Just create a text file with your prompt and reference it in your config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;prompt_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-custom-prompt.txt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prompt receives all your raw activity data as context, so you can shape the output however you like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Open Source?
&lt;/h2&gt;

&lt;p&gt;I initially built this for myself to automate my own weekly updates at work. But I figured other engineers probably have the same problem, so I cleaned it up and open-sourced it. The tool is intentionally simple - it does one thing and tries to do it well.&lt;/p&gt;

&lt;p&gt;If you find it useful, give it a ⭐ on &lt;a href="https://github.com/sahansera/gh-weekly-updates" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. And if you have ideas for improvements, PRs and issues are always welcome!&lt;/p&gt;

&lt;h2&gt;
  
  
  What’s Next
&lt;/h2&gt;

&lt;p&gt;A few things I’m thinking about for future releases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More activity sources&lt;/strong&gt; : Picking up commit messages, release notes, and code review comments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple output formats&lt;/strong&gt; : Slack messages, email digests, Notion pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team summaries&lt;/strong&gt; : Aggregate activity across a whole team, not just one person&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of these sound interesting to you, feel free to open an issue or start a discussion on the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt; : &lt;a href="https://github.com/sahansera/gh-weekly-updates" rel="noopener noreferrer"&gt;github.com/sahansera/gh-weekly-updates&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyPI&lt;/strong&gt; : &lt;a href="https://pypi.org/project/gh-weekly-updates/" rel="noopener noreferrer"&gt;pypi.org/project/gh-weekly-updates&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Models&lt;/strong&gt; : &lt;a href="https://github.com/marketplace/models" rel="noopener noreferrer"&gt;github.com/marketplace/models&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading! If you have any questions, feel free to reach out on &lt;a href="https://twitter.com/_SahanSera" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt; or drop a comment below. 🤗&lt;/p&gt;

</description>
      <category>github</category>
      <category>python</category>
      <category>productivity</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Deploying GitHub Self-Hosted Runners on Your Home Kubernetes Cluster with ARC</title>
      <dc:creator>Sahan</dc:creator>
      <pubDate>Tue, 05 Aug 2025 12:28:00 +0000</pubDate>
      <link>https://dev.to/sahan/deploying-github-self-hosted-runners-on-your-home-kubernetes-cluster-with-arc-3gaj</link>
      <guid>https://dev.to/sahan/deploying-github-self-hosted-runners-on-your-home-kubernetes-cluster-with-arc-3gaj</guid>
      <description>&lt;p&gt;If you followed my last post, &lt;a href="https://sahansera.dev/building-home-lab-kubernetes-cluster-old-hardware-k3s/" rel="noopener noreferrer"&gt;Building a Home Lab Kubernetes Cluster with Old Hardware and k3s&lt;/a&gt;, you now have a proper x86 Kubernetes cluster humming away on your old laptops. So, what’s next? Time to put that cluster to work—let’s run GitHub Actions jobs on your own hardware!&lt;/p&gt;

&lt;p&gt;Why? Because if you have got decent hardware - self-hosting your runners might be faster, gives you full control (no GitHub minutes limit!), and lets you run bigger jobs (CI, builds, ML, you name it) on your home infra. And with &lt;a href="https://github.com/actions/actions-runner-controller" rel="noopener noreferrer"&gt;Actions Runner Controller - ARC&lt;/a&gt;, managing runners at scale on Kubernetes is surprisingly easy.&lt;/p&gt;

&lt;p&gt;Here’s how to set it all up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is ARC and Why Should You Care?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/actions/actions-runner-controller" rel="noopener noreferrer"&gt;ARC (Actions Runner Controller)&lt;/a&gt; is an open-source Kubernetes operator from GitHub. It spins up and manages GitHub Actions runners as Kubernetes pods—no more manually registering runners, no more pets, just cattle. Runners auto-scale up and down as jobs arrive. It’s perfect for CI/CD, especially on clusters you own.&lt;/p&gt;

&lt;p&gt;Here’s a high-level view of how it works under the hood&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.amazonaws.com%2Fuploads%2Farticles%2Feie9qn3jfwmtedxba37s.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.amazonaws.com%2Fuploads%2Farticles%2Feie9qn3jfwmtedxba37s.png" alt="ARC Architecture" width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Working Kubernetes cluster (see &lt;a href="https://sahansera.dev/building-home-lab-kubernetes-cluster-old-hardware-k3s/" rel="noopener noreferrer"&gt;previous post&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl&lt;/code&gt; and &lt;a href="https://helm.sh/" rel="noopener noreferrer"&gt;&lt;code&gt;helm&lt;/code&gt;&lt;/a&gt; installed on your machine&lt;/li&gt;
&lt;li&gt;A GitHub Personal Access Token (PAT) with &lt;code&gt;repo&lt;/code&gt; and &lt;code&gt;admin:org&lt;/code&gt; scopes&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1️⃣ Pre-Setup: Quick Checks
&lt;/h2&gt;

&lt;p&gt;Make sure you have what you need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;which helm
kubectl version &lt;span class="nt"&gt;--client&lt;/span&gt;
helm list &lt;span class="nt"&gt;-A&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If those commands work, you’re good to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  2️⃣ Install ARC Controller
&lt;/h2&gt;

&lt;p&gt;Let’s install the ARC controller into your control plane namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;NAMESPACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"actions-runner-controller"&lt;/span&gt;
helm &lt;span class="nb"&gt;install &lt;/span&gt;arc &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NAMESPACE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This deploys the controller which manages your runners.&lt;/p&gt;

&lt;h2&gt;
  
  
  3️⃣ Deploy a Runner Scale Set
&lt;/h2&gt;

&lt;p&gt;Time to create the runners that will actually do the work. Replace the example GitHub URL and PAT with your details:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;INSTALLATION_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"arc-runner-set"&lt;/span&gt;
&lt;span class="nv"&gt;NAMESPACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"arc-runners"&lt;/span&gt;
&lt;span class="nv"&gt;GITHUB_CONFIG_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://github.com/youruser/yourrepo"&lt;/span&gt;
&lt;span class="nv"&gt;GITHUB_PAT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ghp_123456..."&lt;/span&gt;

helm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;INSTALLATION_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NAMESPACE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;githubConfigUrl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GITHUB_CONFIG_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; githubConfigSecret.github_token&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GITHUB_PAT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GITHUB_CONFIG_URL&lt;/code&gt;: The repo or org you want to run jobs for.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GITHUB_PAT&lt;/code&gt;: Your Personal Access Token.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4️⃣ Check That It’s Working
&lt;/h2&gt;

&lt;p&gt;Verify the controller and runner pods are up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Controller&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; actions-runner-controller

&lt;span class="c"&gt;# Runners&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners

&lt;span class="c"&gt;# Runner set status&lt;/span&gt;
kubectl get AutoscalingRunnerSet &lt;span class="nt"&gt;-A&lt;/span&gt;
kubectl describe AutoscalingRunnerSet arc-runner-set &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see your runners show up as pods. If you trigger a workflow in your GitHub repo, you’ll see a pod spin up, do the job, and then shut down—magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  5️⃣ Testing It Out
&lt;/h2&gt;

&lt;p&gt;Here’s the fun part. Create a simple GitHub Actions workflow in your repo to test the runners:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Test ARC Runners&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arc-runners&lt;/span&gt; &lt;span class="c1"&gt;# This tells GitHub to use your self-hosted runners. Use the NAMESPACE name you defined in step 3.&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v2&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run a script&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "Hello from ARC Runner!"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;List files&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ls -la&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here’s ci.yaml from my githubstats repo if you need a working example: &lt;a href="https://github.com/sahansera/githubstats/blob/main/.github/workflows/ci.yml" rel="noopener noreferrer"&gt;ci.yaml&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Make a commit to trigger the workflow. You should see the runner pod spin up, execute the job, and then terminate.&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.amazonaws.com%2Fuploads%2Farticles%2Fvaaybcapar2anb3sr9a6.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.amazonaws.com%2Fuploads%2Farticles%2Fvaaybcapar2anb3sr9a6.png" alt="arc self hosted github runners k8s 1" width="456" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;center&gt;
Here's the workflow running on my ARC self-hosted runner
&lt;/center&gt;

&lt;h2&gt;
  
  
  6️⃣ Monitoring (Bonus: Grafana)
&lt;/h2&gt;

&lt;p&gt;Want to geek out and monitor your runners? If you’ve set up Prometheus/Grafana (see my upcoming post if not!), you can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Check pod CPU/memory usage&lt;/li&gt;
&lt;li&gt;Track how many runners are running&lt;/li&gt;
&lt;li&gt;See logs for each pod&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Handy queries for Grafana dashboards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Number of ARC runner pods
kube_pod_status_phase{namespace="arc-runners", phase="Running"}

# Pod CPU usage
rate(container_cpu_usage_seconds_total{namespace="arc-runners"}[5m])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's mine:&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.amazonaws.com%2Fuploads%2Farticles%2Fjmeo8nezem3f4fvqiv15.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.amazonaws.com%2Fuploads%2Farticles%2Fjmeo8nezem3f4fvqiv15.png" alt="arc self hosted github runners k8s 2" width="800" height="193"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;center&gt;
Here's what the workflow looks like in action
&lt;/center&gt;

&lt;h2&gt;
  
  
  6️⃣ Useful Commands for Day-to-Day Ops
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Watch runner scaling in real-time&lt;/span&gt;
kubectl get AutoscalingRunnerSet &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners &lt;span class="nt"&gt;-w&lt;/span&gt;

&lt;span class="c"&gt;# See events and troubleshoot&lt;/span&gt;
kubectl get events &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners &lt;span class="nt"&gt;--sort-by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.metadata.creationTimestamp

&lt;span class="c"&gt;# Pod logs (for a specific runner)&lt;/span&gt;
kubectl logs &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners &amp;lt;pod-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Troubleshooting Tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Runner not connecting?&lt;/strong&gt; Double-check your PAT and network access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pods stuck or crash-looping?&lt;/strong&gt; Check logs for clues and make sure your cluster has enough resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can’t see runners in GitHub?&lt;/strong&gt; Make sure the config URL matches your repo/org and the PAT has correct scopes.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  That’s It! You’re Running GitHub Actions on Your Own Cluster
&lt;/h2&gt;

&lt;p&gt;You now have GitHub Actions jobs running &lt;em&gt;at home&lt;/em&gt; on your cluster, scaling up and down automatically. No more slow or limited runners. Your home lab just levelled up—CI/CD, builds, ML, you name it.&lt;/p&gt;

&lt;p&gt;Stay tuned for my next post where I’ll show you how to get beautiful observability dashboards and set up alerting for your home cluster.&lt;br&gt;&lt;br&gt;
Happy automating!&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Questions? Want to show off your setup? Ping me on &lt;a href="https://twitter.com/_SahanSera" rel="noopener noreferrer"&gt;X (Twitter)&lt;/a&gt; or drop a comment below!&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>github</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Home Lab Kubernetes Cluster with Old Hardware and k3s</title>
      <dc:creator>Sahan</dc:creator>
      <pubDate>Fri, 01 Aug 2025 10:30:00 +0000</pubDate>
      <link>https://dev.to/sahan/building-a-home-lab-kubernetes-cluster-with-old-hardware-and-k3s-f76</link>
      <guid>https://dev.to/sahan/building-a-home-lab-kubernetes-cluster-with-old-hardware-and-k3s-f76</guid>
      <description>&lt;p&gt;If you've read my previous &lt;a href="https://sahansera.dev/building-your-own-private-kubernetes-cluster-on-a-raspberry-pi-4-with-k3s/" rel="noopener noreferrer"&gt;post&lt;/a&gt; about building a Raspberry Pi k3s cluster, you know I'm a huge fan of home labs. There's something uniquely satisfying about getting distributed systems running on a bunch of hardware you already own. This time, though, I wanted something a bit more powerful-a cluster that could handle not just learning and tinkering, but also heavier dev, CI/CD, and even ML workloads.&lt;/p&gt;

&lt;p&gt;And as it turns out, there's a ton you can do with a handful of old laptops, a simple switch, and Ubuntu Server. If you're thinking about upgrading your home cluster or want to avoid vendor lock-in, this one's for you.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Move From Raspberry Pi to x86?
&lt;/h2&gt;

&lt;p&gt;Don't get me wrong-RPi clusters are awesome for learning, hacking, and even some light home automation. But if you've ever tried running real dev pipelines, CI/CD, or any data-heavy ML stuff, you'll hit those limits &lt;em&gt;fast&lt;/em&gt;. Plus, WiFi can get a bit flaky when you're trying to keep nodes connected under load.&lt;/p&gt;

&lt;p&gt;I had a few spare laptops sitting around, and it made perfect sense to give them a second life and push them to their limits. Bonus: with Ethernet and a decent switch, you get much more reliable connectivity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Network Setup: Keep It Simple (and Reliable)
&lt;/h2&gt;

&lt;p&gt;For this build, I kept things straightforward: all nodes are wired to a simple network switch. This gives much better performance and reliability compared to WiFi, but honestly, you could still pull this off over wireless if needed.&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.amazonaws.com%2Fuploads%2Farticles%2F8j0sn4wz9bupla03rrkg.jpg" 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.amazonaws.com%2Fuploads%2Farticles%2F8j0sn4wz9bupla03rrkg.jpg" alt="Network Switch" width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;center&gt;
   My network switch - how it all started 🛜
&lt;/center&gt;

&lt;p&gt;&lt;strong&gt;A quick tip:&lt;/strong&gt;  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reserve static IPs for each node using your router's DHCP reservations.
&lt;/li&gt;
&lt;li&gt;This makes node management, SSH, and Kubernetes networking so much easier.
&lt;/li&gt;
&lt;li&gt;Check your DHCP table and make sure every machine has a unique, predictable IP address.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Getting Ubuntu Server Set Up on Each Node
&lt;/h2&gt;

&lt;p&gt;Here's a high level diagram of what my setup looks like:&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.amazonaws.com%2Fuploads%2Farticles%2Fd5y197omtv8e22ipk8m8.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.amazonaws.com%2Fuploads%2Farticles%2Fd5y197omtv8e22ipk8m8.png" alt="Home Lab Setup Diagram" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's talk about prepping each machine. My old laptops had a new lease on life running Ubuntu Server. I recommend Ubuntu Server LTS for its simplicity and compatibility. If you're using VMs, just make sure to set the NIC to bridged mode so each VM acts as a full member of your home LAN.&lt;/p&gt;

&lt;p&gt;Here's my go-to checklist for each node:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Update the system and install SSH:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&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;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
   &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;openssh-server &lt;span class="nt"&gt;-y&lt;/span&gt;
   &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;ssh
   &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Set a memorable hostname:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&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;hostnamectl set-hostname master   &lt;span class="c"&gt;# or worker-1, etc.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;(Optional) Update /etc/hosts for local resolution:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&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;nano /etc/hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change any lines like &lt;code&gt;127.0.1.1 old-hostname&lt;/code&gt; to your new hostname.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find your node's IP address:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   ip a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use this IP for your DHCP reservation so it always stays the same.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test SSH from another machine:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   ssh &amp;lt;username&amp;gt;@&amp;lt;node-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Installing k3s: Lightweight, Powerful, Familiar
&lt;/h2&gt;

&lt;p&gt;This process feels pretty magical the first time you see it all come together. I stuck with &lt;a href="https://k3s.io/" rel="noopener noreferrer"&gt;k3s&lt;/a&gt;-lightweight and perfect for home or edge clusters.&lt;/p&gt;

&lt;h3&gt;
  
  
  On the Control Plane Node ("master")
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Install k3s:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.k3s.io | sh -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check that k3s is running:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&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;systemctl status k3s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Get your cluster join token:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nb"&gt;sudo cat&lt;/span&gt; /var/lib/rancher/k3s/server/node-token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy this somewhere safe-you'll need it for your worker nodes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Get the node's LAN IP:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   ip a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's say your master node IP is &lt;code&gt;192.168.0.200&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check your cluster status:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;If you ever want to reset/reinstall k3s, just run:&lt;/em&gt;&lt;br&gt;
&lt;/p&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; /usr/local/bin/k3s-uninstall.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  On Each Worker Node ("worker-1", "worker-2", etc.)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Join the cluster:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.k3s.io | &lt;span class="nv"&gt;K3S_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://192.168.0.200:6443 &lt;span class="nv"&gt;K3S_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;your-token-here&amp;gt; &lt;span class="nv"&gt;INSTALL_K3S_EXEC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"--node-ip=&amp;lt;worker-ip&amp;gt;"&lt;/span&gt; sh -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;192.168.0.200&lt;/code&gt; with your control plane's IP.&lt;br&gt;&lt;br&gt;
   Replace &lt;code&gt;&amp;lt;your-token-here&amp;gt;&lt;/code&gt; with the token from your master node.&lt;br&gt;&lt;br&gt;
   Replace &lt;code&gt;&amp;lt;worker-ip&amp;gt;&lt;/code&gt; with this worker's IP, e.g., &lt;code&gt;192.168.0.201&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;That's it!&lt;/strong&gt; The node will auto-register with your cluster. No manual kubeconfig needed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Check the cluster again from the master:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see both &lt;code&gt;master&lt;/code&gt; and your worker(s) as &lt;code&gt;Ready&lt;/code&gt;!&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting and Gotchas
&lt;/h2&gt;

&lt;p&gt;As with any home lab project, there are always a few snags-here's what I ran into and how to fix it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Node not joining?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Double-check your token (no spaces, copy the whole thing).&lt;/li&gt;
&lt;li&gt;Make sure you can &lt;code&gt;ping&lt;/code&gt; the control plane from your worker node.&lt;/li&gt;
&lt;li&gt;Firewalls can get in the way. Ensure port 6443 is open between nodes.&lt;/li&gt;
&lt;li&gt;If you get hostname conflicts, just change the hostname and restart the k3s agent (&lt;code&gt;sudo systemctl restart k3s-agent&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Cluster state looks weird after hostname change?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You might see both the old and new hostnames in &lt;code&gt;kubectl get nodes&lt;/code&gt;. Just delete the old one:
&lt;/li&gt;
&lt;/ul&gt;

&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete node &amp;lt;old-node-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;IP address keeps changing?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Make a DHCP reservation for each node's MAC address in your router.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;WiFi unreliable?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ethernet is always the best bet for clusters, especially for heavy workloads.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;With the basics in place, you're ready to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run and test real-world apps and dev environments&lt;/li&gt;
&lt;li&gt;Build your own CI/CD pipelines&lt;/li&gt;
&lt;li&gt;Experiment with ML workloads on dedicated nodes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This way, you get all the power of a real Kubernetes lab, but full control and no monthly surprises from a cloud provider.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RPi clusters are great for edge computing and learning,&lt;/strong&gt; but once you need real muscle, x86 hardware makes a &lt;em&gt;huge&lt;/em&gt; difference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WiFi is convenient, but Ethernet is still king&lt;/strong&gt; for stable clusters-especially if you're doing anything performance-sensitive.&lt;/li&gt;
&lt;li&gt;Old laptops/desktops are a goldmine for home lab builds. Don't let them collect dust!&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;If you have old machines and a bit of curiosity, you can build a cluster that's surprisingly capable-no Raspberry Pis required.&lt;br&gt;&lt;br&gt;
And if you've already done it with RPis, this is the perfect upgrade path.&lt;br&gt;&lt;br&gt;
Keep an eye out for my next post where I'll deep-dive into adding observability and tooling!&lt;/p&gt;

&lt;p&gt;Happy clustering! 🫡&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>tutorial</category>
      <category>distributedsystems</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
