<?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>How to find LM Studio plugins and MCP servers</title>
      <dc:creator>Sahan</dc:creator>
      <pubDate>Mon, 14 Sep 2026 03:34:48 +0000</pubDate>
      <link>https://dev.to/sahan/how-to-find-lm-studio-plugins-and-mcp-servers-hmc</link>
      <guid>https://dev.to/sahan/how-to-find-lm-studio-plugins-and-mcp-servers-hmc</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://sahansera.dev/finding-lm-studio-plugins/" rel="noopener noreferrer"&gt;sahansera.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You have a model running in LM Studio. Now you want it to search the web, work with files, or trim a video. Where do you find the tool that makes that possible?&lt;/p&gt;

&lt;p&gt;I've built &lt;a href="https://aitools.sahansera.dev/" rel="noopener noreferrer"&gt;Local AI Tools&lt;/a&gt; to help with that step. It's a free, open-source directory where you can browse native LM Studio plugins and MCP servers, search by capability, and inspect their setup information. You don't need an account to use it.&lt;/p&gt;

&lt;p&gt;If you already know the plugin you want, a direct link is fine. This is for the stage before that: knowing what you want to do, but not what to install.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/sahansera" rel="noopener noreferrer"&gt;
        sahansera
      &lt;/a&gt; / &lt;a href="https://github.com/sahansera/local-ai-tools" rel="noopener noreferrer"&gt;
        local-ai-tools
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Open-source marketplace for discovering LM Studio plugins, MCP servers, and other local AI tools.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/sahansera/local-ai-tools/apps/web/public/app-icon.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fsahansera%2Flocal-ai-tools%2FHEAD%2Fapps%2Fweb%2Fpublic%2Fapp-icon.png" alt="Local AI Tools" width="128" height="128"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Local AI Tools&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  &lt;strong&gt;Discover LM Studio plugins and MCP servers — with compatibility, setup, runtime, and provenance information in one place.&lt;/strong&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/sahansera/local-ai-tools/actions/workflows/ci.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/sahansera/local-ai-tools/actions/workflows/ci.yml/badge.svg" alt="CI"&gt;&lt;/a&gt;
  &lt;a href="https://github.com/sahansera/local-ai-tools/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/7013272bd27ece47364536a221edb554cd69683b68a46fc0ee96881174c4214c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c75652e737667" alt="MIT License"&gt;&lt;/a&gt;
  &lt;a href="https://sahansera.github.io/local-ai-tools/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/27080feb022eefdd06ad51b5b071c259f5c34c9ca7abc0b74f93127074524da5/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6d61726b6574706c6163652d6c6976652d3763336165642e737667" alt="Live marketplace"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/555c6be017284e25b89080a0ad3f4b075acc6902525bb1bf7414ade150012689/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6275696c74253230666f722d4c4d25323053747564696f2d3562356366362e737667"&gt;&lt;img src="https://camo.githubusercontent.com/555c6be017284e25b89080a0ad3f4b075acc6902525bb1bf7414ade150012689/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6275696c74253230666f722d4c4d25323053747564696f2d3562356366362e737667" alt="Built for LM Studio"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://sahansera.github.io/local-ai-tools/" rel="nofollow noopener noreferrer"&gt;&lt;strong&gt;Browse the marketplace →&lt;/strong&gt;&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What is Local AI Tools?&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;Local AI Tools is an open-source discovery marketplace focused on one question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What can I add to LM Studio?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;LM Studio has its own native plugin ecosystem, while thousands of MCP servers live across the broader Model Context Protocol ecosystem. Finding the right tool — and understanding whether it can actually be used with LM Studio — is fragmented.&lt;/p&gt;

&lt;p&gt;Local AI Tools brings those ecosystems together into a single searchable catalogue and adds an LM Studio-specific compatibility layer on top.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;At a glance&lt;/h3&gt;
&lt;/div&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

&lt;thead&gt;

&lt;tr&gt;


&lt;/tr&gt;

&lt;/thead&gt;

&lt;tbody&gt;

&lt;tr&gt;

&lt;td&gt;&lt;strong&gt;Native LM Studio plugins&lt;/strong&gt;&lt;/td&gt;

&lt;td&gt;Discover public plugins from the LM Studio Hub and jump straight into LM Studio.&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;strong&gt;MCP servers&lt;/strong&gt;&lt;/td&gt;

&lt;td&gt;Discover servers from the official MCP Registry and see whether their published metadata maps safely to an&lt;/td&gt;

&lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;…&lt;p&gt;&lt;/p&gt;&lt;/div&gt;
&lt;br&gt;
  &lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/sahansera/local-ai-tools" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;h2&gt;
  
  
  Finding the plugin is its own problem
&lt;/h2&gt;

&lt;p&gt;In this &lt;a href="https://www.reddit.com/r/LocalLLM/comments/1qdn224/lm_studio_plugins/" rel="noopener noreferrer"&gt;Reddit discussion about LM Studio plugins&lt;/a&gt;, someone asks for a central listing. They then have to clarify that they mean native plugins, because several replies point them towards MCP servers.&lt;/p&gt;

&lt;p&gt;Both can extend a local AI setup, but they're different integration types. A useful directory needs to make that distinction visible and help you understand the setup behind a promising description.&lt;/p&gt;

&lt;p&gt;Local AI Tools brings together public plugin listings from &lt;strong&gt;LM Studio Hub&lt;/strong&gt; and server listings from the &lt;strong&gt;official MCP Registry&lt;/strong&gt;. You can browse them together or select either type. Search terms such as &lt;code&gt;video&lt;/code&gt;, &lt;code&gt;memory&lt;/code&gt;, or &lt;code&gt;web search&lt;/code&gt; give you a starting point without needing a package name.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frq1g89w2g66xu3emnmj9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frq1g89w2g66xu3emnmj9.webp" alt="Local AI Tools homepage with search, filters, and tool cards" width="800" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Discovery:&lt;/strong&gt; Start with the capability you need, then check the type of integration and the setup it requires.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Try it with a video tool
&lt;/h2&gt;

&lt;p&gt;Here's a concrete example using &lt;strong&gt;Local Video Tools&lt;/strong&gt;, a native plugin I also maintain. It exposes video inspection, trimming, conversion, and FFmpeg job management to LM Studio.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/sahansera" rel="noopener noreferrer"&gt;
        sahansera
      &lt;/a&gt; / &lt;a href="https://github.com/sahansera/lmstudio-local-video-tools" rel="noopener noreferrer"&gt;
        lmstudio-local-video-tools
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Native LM Studio video tools for local inspection, trimming and conversion with FFmpeg — privacy-first and hardware accelerated.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🎬 Local Video Tools for LM Studio&lt;/h1&gt;
&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;Reliable video processing for your local AI.&lt;/h3&gt;
&lt;/div&gt;
&lt;p&gt;Inspect, trim, convert, and manage long-running FFmpeg jobs directly from LM Studio — &lt;strong&gt;locally&lt;/strong&gt;, with &lt;strong&gt;hardware acceleration&lt;/strong&gt;, and without holding one AI tool call open for the duration of a video encode.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/sahansera/lmstudio-local-video-tools/releases/tag/v0.1.2" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/7cb398800b14fdb0e4cb99a683657eaa207b7fdbf706bb0ab5205ef3afdf1509/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f762f7461672f736168616e736572612f6c6d73747564696f2d6c6f63616c2d766964656f2d746f6f6c733f7374796c653d666f722d7468652d6261646765266c6162656c3d52656c6561736526636f6c6f723d627269676874677265656e" alt="Release"&gt;&lt;/a&gt;
&lt;a href="https://github.com/sahansera/lmstudio-local-video-tools/actions/workflows/test.yml" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/c396ba71f16eb188d50fb04e10aa0789f1037a8eb07e6412597f36ae1ac28174/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f736168616e736572612f6c6d73747564696f2d6c6f63616c2d766964656f2d746f6f6c732f746573742e796d6c3f6272616e63683d6d61696e267374796c653d666f722d7468652d6261646765266c6162656c3d4275696c64" alt="CI"&gt;&lt;/a&gt;
&lt;a href="https://github.com/sahansera/lmstudio-local-video-tools/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/14c18de56f444c1fe9ce211617981054c718974c142bad8e923ade7eb94bfd77/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f736168616e736572612f6c6d73747564696f2d6c6f63616c2d766964656f2d746f6f6c733f7374796c653d666f722d7468652d626164676526636f6c6f723d627269676874677265656e2663616368655365636f6e64733d333030" alt="License"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/9a789c35d8731c5e4b78b03cb32dc5e94d6fd568af20119af16604c7f929dd12/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c4d25323053747564696f2d4e6174697665253230506c7567696e2d3643354345373f7374796c653d666f722d7468652d6261646765"&gt;&lt;img src="https://camo.githubusercontent.com/9a789c35d8731c5e4b78b03cb32dc5e94d6fd568af20119af16604c7f929dd12/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c4d25323053747564696f2d4e6174697665253230506c7567696e2d3643354345373f7374796c653d666f722d7468652d6261646765" alt="LM Studio"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/ccb5a2a64cf976c0d6f89436882782d18fd377ebfef8b349b7de999d97f05808/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f46466d7065672d4c6f63616c2d3030373830383f7374796c653d666f722d7468652d6261646765266c6f676f3d66666d706567266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/ccb5a2a64cf976c0d6f89436882782d18fd377ebfef8b349b7de999d97f05808/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f46466d7065672d4c6f63616c2d3030373830383f7374796c653d666f722d7468652d6261646765266c6f676f3d66666d706567266c6f676f436f6c6f723d7768697465" alt="FFmpeg"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://lmstudio.ai/sahansera/local-video-tools" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/42a13ffd2a1b10d487aa1f58d67af740fd293e9beda118496ebe580f2cd38a10/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f416464253230746f2d4c4d25323053747564696f2d3643354345373f7374796c653d666f722d7468652d6261646765" alt="Add to LM Studio"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/sahansera/lmstudio-local-video-tools#-try-these-prompts" rel="noopener noreferrer"&gt;Try the prompts&lt;/a&gt; · &lt;a href="https://github.com/sahansera/lmstudio-local-video-tools#-quick-start" rel="noopener noreferrer"&gt;Install&lt;/a&gt; · &lt;a href="https://github.com/sahansera/lmstudio-local-video-tools#-how-it-works" rel="noopener noreferrer"&gt;How it works&lt;/a&gt; · &lt;a href="https://github.com/sahansera/lmstudio-local-video-tools/releases/tag/v0.1.2" rel="noopener noreferrer"&gt;Latest release&lt;/a&gt; · &lt;a href="https://github.com/sahansera/lmstudio-local-video-tools/docs/roadmap.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;v0.1.2 Early Preview is out.&lt;/strong&gt; The core workflow has been validated with real &lt;strong&gt;4K HEVC/H.265 MOV video on Apple Silicon&lt;/strong&gt;, including lossless clipping, accurate re-encoding, VideoToolbox conversion, progress tracking, and cancellation. This release also includes filesystem and persisted-job boundary hardening ahead of the public preview.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What this is:&lt;/strong&gt; a reliable local execution layer for concrete video operations. &lt;strong&gt;The model decides what operation to request; FFmpeg does the actual media processing locally.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;💬 Try these prompts&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Attach a video in LM Studio and talk to it naturally:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/sahansera/lmstudio-local-video-tools" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Open &lt;a href="https://aitools.sahansera.dev/" rel="noopener noreferrer"&gt;Local AI Tools&lt;/a&gt; and select &lt;strong&gt;LM Studio plugins&lt;/strong&gt; in the type filter.&lt;/li&gt;
&lt;li&gt;Search for &lt;code&gt;video&lt;/code&gt;. Look for &lt;strong&gt;Local Video Tools&lt;/strong&gt; by &lt;code&gt;sahansera&lt;/code&gt;; the author matters when similarly named tools appear.&lt;/li&gt;
&lt;li&gt;Use the &lt;strong&gt;arrow&lt;/strong&gt; to open its &lt;a href="https://aitools.sahansera.dev/tools/sahansera-local-video-tools--2ecb0301e378bbc2/" rel="noopener noreferrer"&gt;detail page&lt;/a&gt;. Check the description, runtime information, and upstream link.&lt;/li&gt;
&lt;li&gt;Read the &lt;a href="https://lmstudio.ai/sahansera/local-video-tools" rel="noopener noreferrer"&gt;plugin's installation instructions&lt;/a&gt;. This one needs FFmpeg and ffprobe installed locally, even though it has a direct LM Studio installation link.&lt;/li&gt;
&lt;li&gt;With those requirements understood, use the &lt;strong&gt;+&lt;/strong&gt; on its card or &lt;strong&gt;Add to LM Studio&lt;/strong&gt; on the details page to open the installation flow in LM Studio.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F340cqoslqhhycydea0it.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F340cqoslqhhycydea0it.webp" alt="Video tool search results in Local AI Tools" width="800" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Filw86dwoo1u3ujhpi2hj.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Filw86dwoo1u3ujhpi2hj.webp" alt="Local Video Tools detail page with setup information and the Add to LM Studio action" width="800" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same browsing approach works for other capabilities: narrow the type, search for the task, and inspect a candidate. The directory gets you to the tool and its setup information; the upstream instructions explain how to finish configuring it.&lt;/p&gt;

&lt;p&gt;This example also shows why “there's an install button” and “everything is already set up” aren't the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Native plugins and MCP servers have different setup paths
&lt;/h2&gt;

&lt;p&gt;Native Hub plugins use LM Studio's plugin installation links. Individual plugins can still depend on software installed on your machine, as the video example does.&lt;/p&gt;

&lt;p&gt;MCP servers expose capabilities through the Model Context Protocol. LM Studio supports both local and remote MCP servers, with configuration supplied through &lt;code&gt;mcp.json&lt;/code&gt; or an available Add to LM Studio link. The &lt;a href="https://lmstudio.ai/docs/app/mcp" rel="noopener noreferrer"&gt;LM Studio MCP documentation&lt;/a&gt; explains those options.&lt;/p&gt;

&lt;p&gt;For MCP listings, Local AI Tools checks whether the upstream metadata is sufficient to generate an LM Studio configuration. You'll see three outcomes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Label&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;th&gt;What to do next&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LM Studio Ready&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The metadata can produce a configuration without additional user-supplied values.&lt;/td&gt;
&lt;td&gt;Review the source and runtime requirements before adding it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Setup required&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Additional values are needed, such as credentials, paths, or arguments.&lt;/td&gt;
&lt;td&gt;Open the details and check which inputs you need to supply.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compatibility unknown&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;There isn't enough information to determine a configuration.&lt;/td&gt;
&lt;td&gt;Check the upstream documentation for manual setup.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The homepage initially filters for ready entries. Change the compatibility filter if you want to include tools that need setup or have unknown compatibility. An unknown label can reflect incomplete metadata, so it doesn't by itself establish that the server is incompatible.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Compatibility:&lt;/strong&gt; Generating a configuration and verifying a tool's behaviour are separate jobs. The directory helps with the first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Check what will run, and where
&lt;/h2&gt;

&lt;p&gt;A local model can use an integration that calls a remote service. An API key, network access, or filesystem access can therefore matter just as much as the tool's name.&lt;/p&gt;

&lt;p&gt;The directory surfaces runtime, API-key, source, and risk information where it's available. Some metadata is inferred, and these signals aren't a security audit. I haven't tested every listed tool with every model. Check the upstream project when deciding whether to install it.&lt;/p&gt;

&lt;p&gt;The practical distinction is between finding a tool, configuring it, and having your chosen model use it successfully. The catalogue helps with the first two. Actual behaviour still depends on the tool, its environment, and the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open source, with room for better metadata
&lt;/h2&gt;

&lt;p&gt;Local AI Tools is MIT-licensed and built with Astro. Discovery and enrichment happen during the build, and the site is published as static files. The source repository is linked with this article if you'd like to inspect the implementation or contribute.&lt;/p&gt;

&lt;p&gt;Listed tools stay with their own maintainers and retain their own licenses. This is an independent community project, not affiliated with or endorsed by LM Studio.&lt;/p&gt;

&lt;p&gt;The most useful contributions are concrete: a missing plugin, an incorrect category, or a setup requirement that a listing overlooks. Include the public tool link and what needs correcting in a GitHub issue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aitools.sahansera.dev/" rel="noopener noreferrer"&gt;Browse Local AI Tools&lt;/a&gt; and search for something you'd like your local model to do. You shouldn't need to know the plugin's name before you can find it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: This article was written with AI assistance.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>When IMemoryCache Stops Scaling: Moving to Redis with .NET Aspire</title>
      <dc:creator>Sahan</dc:creator>
      <pubDate>Fri, 28 Aug 2026 01:48:00 +0000</pubDate>
      <link>https://dev.to/sahan/when-imemorycache-stops-scaling-moving-to-redis-with-net-aspire-1ok1</link>
      <guid>https://dev.to/sahan/when-imemorycache-stops-scaling-moving-to-redis-with-net-aspire-1ok1</guid>
      <description>&lt;p&gt;&lt;code&gt;IMemoryCache&lt;/code&gt; is a perfectly good cache until the assumption behind it changes.&lt;/p&gt;

&lt;p&gt;That assumption is simple: the state belongs to one process.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sahansera/PulseOps" rel="noopener noreferrer"&gt;PulseOps&lt;/a&gt; starts with one API instance, so keeping recent service-health results in memory is cheap and easy. Then we run a second API instance. Suddenly each process can cache a different answer to the same question.&lt;/p&gt;

&lt;p&gt;Nothing is wrong with &lt;code&gt;IMemoryCache&lt;/code&gt;. The architecture changed underneath it.&lt;/p&gt;

&lt;p&gt;This is the first post in &lt;strong&gt;&lt;a href="https://www.sahansera.dev/series/building-pulseops/" rel="noopener noreferrer"&gt;Building PulseOps&lt;/a&gt;&lt;/strong&gt;, a series where I'm getting back into modern .NET by building one system and letting real requirements pull in the next piece of infrastructure. No catalog app. No infrastructure added just because a getting-started guide says so.&lt;/p&gt;

&lt;p&gt;In this post, scaling the API gives us the first reason to change the architecture: move the cache to Redis and bring .NET Aspire into the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;IMemoryCache&lt;/code&gt; remains correct for state owned by one process. Once PulseOps runs multiple API replicas, each replica can cache a different service-health result. Redis gives those replicas shared cache state, while .NET Aspire makes the Redis dependency explicit, runnable, and observable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll learn
&lt;/h2&gt;

&lt;p&gt;By the end, we'll have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reproduced the inconsistency that appears when process-local caches are spread across multiple instances;&lt;/li&gt;
&lt;li&gt;moved the shared cache state to Redis with &lt;code&gt;IDistributedCache&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;modeled Redis as an Aspire resource instead of hand-managing a local container and connection string;&lt;/li&gt;
&lt;li&gt;verified that separate API instances can read the same cached value.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want the fundamentals first, I've already covered &lt;a href="https://www.sahansera.dev/in-memory-caching-aspcore-dotnet/" rel="noopener noreferrer"&gt;&lt;code&gt;IMemoryCache&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://www.sahansera.dev/distributed-caching-aspnet-core-redis/" rel="noopener noreferrer"&gt;distributed caching with Redis&lt;/a&gt; separately.&lt;/p&gt;

&lt;p&gt;This post starts where those two meet a distributed application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'm building PulseOps this way
&lt;/h2&gt;

&lt;p&gt;I'm building &lt;a href="https://github.com/sahansera/PulseOps" rel="noopener noreferrer"&gt;PulseOps&lt;/a&gt; as the open-source companion project for this series.&lt;/p&gt;

&lt;p&gt;The idea is simple: keep one application alive across the whole series and evolve it as the requirements get harder.&lt;/p&gt;

&lt;p&gt;PulseOps will eventually ingest alerts, track incidents, persist operational data, expose telemetry, and grow an agentic incident assistant. But each capability has to earn its place first.&lt;/p&gt;

&lt;p&gt;That gives the series a useful rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Start with the simplest thing that is correct. Make the limitation visible. Then add infrastructure because you can explain exactly which property it changes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So this isn't going to be a sequence of disconnected Aspire demos. &lt;code&gt;main&lt;/code&gt; will keep moving, while tagged snapshots preserve the exact version used by each article.&lt;/p&gt;

&lt;p&gt;The rough direction is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;shared cache state with Redis;&lt;/li&gt;
&lt;li&gt;durable persistence with PostgreSQL;&lt;/li&gt;
&lt;li&gt;logs, traces, and metrics that make failures observable;&lt;/li&gt;
&lt;li&gt;an incident agent built with the GitHub Copilot SDK;&lt;/li&gt;
&lt;li&gt;giving that agent useful access to the running Aspire application.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Favlf3bj4xukd6hpseskk.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Favlf3bj4xukd6hpseskk.webp" alt="PulseOps series roadmap from Redis shared cache state through PostgreSQL persistence and observability to a GitHub Copilot SDK incident agent." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The details may change as PulseOps grows. That's part of the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  PulseOps starts deliberately small
&lt;/h2&gt;

&lt;p&gt;The first version is intentionally boring:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzz0819tjxcclno46u7td.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzz0819tjxcclno46u7td.webp" alt="PulseOps starts with a single web frontend calling a single API." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The API has a small registry of services PulseOps knows about. A service looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"payments-api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Payments API"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/health"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"unknown"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;When the dashboard asks for a service's current status, PulseOps may need to make a remote health request. There is no point hammering the same endpoint every time someone refreshes the page, so caching the result for a short period is an obvious first optimization.&lt;/p&gt;
&lt;h2&gt;
  
  
  The simplest cache works fine with one process
&lt;/h2&gt;

&lt;p&gt;For one API instance, &lt;code&gt;IMemoryCache&lt;/code&gt; is a good fit.&lt;/p&gt;

&lt;p&gt;The shape is straightforward:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fije4apfbr202dyri9o6a.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fije4apfbr202dyri9o6a.webp" alt="A request flows through PulseOps.Api to a process-local IMemoryCache holding a short-lived service status." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A simplified implementation might look like this:&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;async&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;ServiceStatus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetStatusAsync&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;serviceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cacheKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"service-status:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;serviceId&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="n"&gt;ServiceStatus&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;cachedStatus&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cachedStatus&lt;/span&gt;&lt;span class="p"&gt;!;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;status&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;CheckServiceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serviceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;_cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&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;status&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 first request checks the service. Requests during the next 30 seconds get the cached result.&lt;/p&gt;

&lt;p&gt;Fast, dependency-free, and easy to understand.&lt;/p&gt;


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

  &lt;br&gt;
&lt;strong&gt;The starting point:&lt;/strong&gt; Use process-local state when the state really is local to one process. Don't distribute it before you have a reason.&lt;br&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A second API instance changes the correctness model
&lt;/h2&gt;

&lt;p&gt;Now suppose we run two instances of &lt;code&gt;PulseOps.Api&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnzx3xu0er14ynr70prfi.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnzx3xu0er14ynr70prfi.webp" alt="Requests are load balanced across two PulseOps.Api instances, each with its own independent local cache." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each process gets its own memory and therefore its own cache.&lt;/p&gt;

&lt;p&gt;Consider this sequence:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcvhl6xe3cfj4hw4xcv3r.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcvhl6xe3cfj4hw4xcv3r.webp" alt="Timeline showing API instance one caching a healthy result, the monitored service failing, and API instance two later caching an unhealthy result." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the next few seconds, API #1 can still report &lt;strong&gt;Healthy&lt;/strong&gt; while API #2 reports &lt;strong&gt;Unhealthy&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Both API instances are behaving correctly according to their own cache.&lt;/p&gt;

&lt;p&gt;PulseOps as a system is not.&lt;/p&gt;

&lt;p&gt;A monitoring dashboard that gives you a different answer depending on which backend instance served the request is a fairly creative definition of monitoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  This isn't an IMemoryCache bug
&lt;/h2&gt;

&lt;p&gt;It's tempting to frame this as "&lt;code&gt;IMemoryCache&lt;/code&gt; doesn't scale."&lt;/p&gt;

&lt;p&gt;That's not quite right.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IMemoryCache&lt;/code&gt; is doing exactly what it promises: storing data in the memory of the current process.&lt;/p&gt;

&lt;p&gt;Scaling out changed the ownership boundary:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1tue54hmr24p0xwg6njh.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1tue54hmr24p0xwg6njh.webp" alt="Before scaling, one application owns one local cache. After scaling, each application instance owns separate local state." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The requirement changed from &lt;strong&gt;cache this value&lt;/strong&gt; to &lt;strong&gt;let multiple processes observe the same cached value&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That second requirement needs shared state.&lt;/p&gt;


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

  &lt;br&gt;
&lt;strong&gt;The real boundary:&lt;/strong&gt; A cache stops being an implementation detail when application correctness depends on multiple processes seeing the same cached state.&lt;br&gt;

&lt;/div&gt;


&lt;h2&gt;
  
  
  Moving the cache outside the API process
&lt;/h2&gt;

&lt;p&gt;Redis gives both API instances somewhere shared to store the status value:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvfnpuis9r36livd02wse.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvfnpuis9r36livd02wse.webp" alt="Two PulseOps.Api instances share the same Redis cache so both can observe one cached service status." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If one instance writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;service-status:payments-api = unhealthy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;another instance can read the same entry.&lt;/p&gt;

&lt;p&gt;This is the same basic move I covered in &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;. The difference here is the operational side.&lt;/p&gt;

&lt;p&gt;PulseOps is already becoming a multi-resource application. I don't want the development workflow to turn into a README containing six Docker commands, several hand-picked localhost ports, and a prayer that everyone copied the same connection string.&lt;/p&gt;

&lt;p&gt;That's the point where Aspire becomes useful.&lt;/p&gt;
&lt;h2&gt;
  
  
  Model Redis in the Aspire AppHost
&lt;/h2&gt;

&lt;p&gt;Before Redis, the PulseOps AppHost is roughly:&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;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DistributedApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PulseOps_Api&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"api"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PulseOps_Web&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"web"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Adding Redis to the application model is small:&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;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DistributedApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cache"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PulseOps_Api&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"api"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PulseOps_Web&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"web"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;AddRedis("cache")&lt;/code&gt; adds a Redis resource to the AppHost. &lt;code&gt;WithReference(cache)&lt;/code&gt; expresses that the API consumes it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3f763yk3uyoj1prhmr12.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3f763yk3uyoj1prhmr12.webp" alt="The PulseOps Aspire AppHost models the web frontend, API, and Redis cache resource, with the API referencing the cache." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For local development, Aspire can run the Redis container and make its connection information available to the API. The current Redis hosting integration exposes the resource URI, host, port, and password to the consuming project when the resource is referenced.&lt;/p&gt;

&lt;p&gt;That means the dependency is now part of the application model rather than a separate setup instruction living outside the code.&lt;/p&gt;
&lt;h2&gt;
  
  
  Register Redis as IDistributedCache
&lt;/h2&gt;

&lt;p&gt;Inside the API, the Redis distributed-cache integration can register &lt;code&gt;IDistributedCache&lt;/code&gt; against the resource named &lt;code&gt;cache&lt;/code&gt;:&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;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&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;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddServiceDefaults&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedisDistributedCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cache"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The connection name matches the AppHost resource named &lt;code&gt;cache&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The application service can then depend on &lt;code&gt;IDistributedCache&lt;/code&gt; rather than &lt;code&gt;IMemoryCache&lt;/code&gt;:&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;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ServiceStatusCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IDistributedCache&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IDistributedCache&lt;/span&gt; &lt;span class="n"&gt;_cache&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&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;async&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;ServiceStatus&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetAsync&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;serviceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"service-status:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;serviceId&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="kt"&gt;var&lt;/span&gt; &lt;span class="k"&gt;value&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;_cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&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;value&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
            &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ServiceStatus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="k"&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;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;SetAsync&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;serviceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ServiceStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"service-status:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;serviceId&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="kt"&gt;var&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SerializeToUtf8Bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&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;_cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;value&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;DistributedCacheEntryOptions&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;AbsoluteExpirationRelativeToNow&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&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;ol&gt;
&lt;li&gt;
&lt;strong&gt;Depend on the shared-cache abstraction.&lt;/strong&gt; The service no longer owns process-local cache state. It receives an &lt;code&gt;IDistributedCache&lt;/code&gt; backed by the Aspire Redis resource.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the shared value.&lt;/strong&gt; Every API instance uses the same key and reads the serialized status from Redis. A missing key remains a normal cache miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prepare a shared write.&lt;/strong&gt; The writer builds the same cache key and serializes the status so another process can read the exact value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store it with the same policy.&lt;/strong&gt; Redis changes where the value lives, not the caching rule. The status still expires after 30 seconds.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The caching policy hasn't changed. PulseOps still caches a service status for 30 seconds.&lt;/p&gt;

&lt;p&gt;What changed is ownership: the cached state now lives outside the API process in Redis, where both instances can observe it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Aspire helps operate the dependency, Redis provides the shared state
&lt;/h2&gt;

&lt;p&gt;There's an easy mental trap here.&lt;/p&gt;

&lt;p&gt;You can look at this:&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;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cache"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;and give Aspire too much credit.&lt;/p&gt;

&lt;p&gt;Aspire didn't make our cache distributed. Redis did that.&lt;/p&gt;

&lt;p&gt;Aspire helps with a different set of problems:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp3b76lxn7e209vtfcvcv.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp3b76lxn7e209vtfcvcv.webp" alt="Aspire describes the Redis dependency, runs it with the application, provides connection information, exposes health and logs, and surfaces it in one dashboard." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That distinction matters because it gives us a useful model for the rest of this series.&lt;/p&gt;

&lt;p&gt;Aspire isn't a replacement for Redis, PostgreSQL, RabbitMQ, Kubernetes, or the application framework itself. It gives us a code-first application model that describes those resources and the relationships between them.&lt;/p&gt;


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

  &lt;br&gt;
&lt;strong&gt;What Aspire does:&lt;/strong&gt; Redis solves shared cache state. Aspire makes Redis an explicit, runnable, observable dependency of the application.&lt;br&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Aspire dashboard makes the new dependency visible
&lt;/h2&gt;

&lt;p&gt;At this point PulseOps has three resources we care about during development: &lt;code&gt;PulseOps.Web&lt;/code&gt;, &lt;code&gt;PulseOps.Api&lt;/code&gt;, and Redis.&lt;/p&gt;

&lt;p&gt;The Aspire dashboard gives us one place to see their state and inspect the application while it is running.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foj2uwqf3f3ymsu7lx3f1.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foj2uwqf3f3ymsu7lx3f1.webp" alt="The Aspire dashboard showing Redis, two PulseOps API replicas, and the web application running together." width="800" height="547"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This isn't hugely dramatic with three resources.&lt;/p&gt;

&lt;p&gt;That's fine.&lt;/p&gt;

&lt;p&gt;Later PulseOps will have multiple APIs, workers, a database, a message broker, and agent tooling. The value of having those relationships described in one place grows with the application.&lt;/p&gt;

&lt;p&gt;For now, the useful part is much simpler: Redis is no longer a container I need to remember to start in another terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the property we actually care about
&lt;/h2&gt;

&lt;p&gt;The important test isn't "does Redis start?"&lt;/p&gt;

&lt;p&gt;The property we wanted to change was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two API instances must observe the same cached service status.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So that's what we should verify.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsp8na3clh4rzp87nyd6z.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsp8na3clh4rzp87nyd6z.webp" alt="Two API instances share Redis, with API one writing a service status and API two reading the same cached status." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Run multiple API instances against the same Redis resource. Then exercise a sequence where API #1 populates the cache and API #2 serves the next request.&lt;/p&gt;

&lt;p&gt;That verifies the architectural change we actually made, not just the fact that a container happens to be green.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Redis is the wrong answer
&lt;/h2&gt;

&lt;p&gt;This isn't an argument for replacing every &lt;code&gt;IMemoryCache&lt;/code&gt; with Redis.&lt;/p&gt;

&lt;p&gt;If the data is truly local to one process, a local cache remains simpler and faster. You avoid a network hop and another infrastructure dependency.&lt;/p&gt;

&lt;p&gt;Redis becomes useful here because &lt;strong&gt;shared visibility is part of correctness&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;There are also cache designs where small amounts of per-instance staleness are acceptable. In those systems, a local cache on every instance may be completely reasonable, especially when reducing dependency latency matters more than having one globally consistent cached value.&lt;/p&gt;

&lt;p&gt;The question isn't "which cache is better?"&lt;/p&gt;

&lt;p&gt;It's:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Who needs to observe this state, and how stale is it allowed to be?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For PulseOps service status, we want every API instance using the same short-lived cached observation. A shared cache matches that requirement better.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first architectural change in PulseOps
&lt;/h2&gt;

&lt;p&gt;PulseOps began with one web frontend, one API, and process-local &lt;code&gt;IMemoryCache&lt;/code&gt; state. It ends this post with multiple API instances backed by one shared Redis cache.&lt;/p&gt;

&lt;p&gt;The interesting part isn't that we added Redis.&lt;/p&gt;

&lt;p&gt;It's &lt;strong&gt;why&lt;/strong&gt; we added Redis.&lt;/p&gt;

&lt;p&gt;The original solution was correct while the cache belonged to one process. Scaling the API changed the ownership boundary, so the cache had to move with it.&lt;/p&gt;

&lt;p&gt;That's the pattern I want to keep through the rest of PulseOps: start simple, make the limitation visible, then add infrastructure because we can explain exactly which property it changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Redis gives the API instances shared short-lived state. It doesn't give PulseOps durable history.&lt;/p&gt;

&lt;p&gt;If an incident is created, updated, acknowledged, or resolved, that data has to survive process restarts. Keeping it in memory would make for a wonderfully optimistic incident-management system.&lt;/p&gt;

&lt;p&gt;So the next step is PostgreSQL.&lt;/p&gt;

&lt;p&gt;We'll add durable persistence through Aspire, look at how the database becomes part of the application model, and deal with the first operational questions that appear with real state: startup ordering, migrations, health, and what should happen when the database isn't ready yet.&lt;/p&gt;

&lt;p&gt;After that, we'll make the system observable enough that an agent can eventually reason about what is happening rather than guess from a prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code for this article
&lt;/h2&gt;

&lt;p&gt;PulseOps is available at &lt;a href="https://github.com/sahansera/PulseOps" rel="noopener noreferrer"&gt;github.com/sahansera/PulseOps&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The series uses immutable Git tags so the code in older posts doesn't drift as the project evolves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Starting point: pulseops-00-foundation
Finished version: pulseops-01-redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;main&lt;/code&gt; will keep moving. These tags are the snapshots for this article.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://aspire.dev/integrations/caching/redis/redis-host/" rel="noopener noreferrer"&gt;.NET Aspire Redis hosting integration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aspire.dev/integrations/caching/redis-distributed/redis-distributed-host/" rel="noopener noreferrer"&gt;.NET Aspire Redis distributed caching integration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aspire.dev/integrations/caching/redis/redis-client/" rel="noopener noreferrer"&gt;Connect to Redis with Aspire&lt;/a&gt;&lt;/li&gt;
&lt;li&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;/li&gt;
&lt;li&gt;&lt;a href="https://www.sahansera.dev/in-memory-caching-aspcore-dotnet/" rel="noopener noreferrer"&gt;In-Memory Caching in ASP.NET Core&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Discussion
&lt;/h2&gt;

&lt;p&gt;Have you seen process-local cache state become a correctness problem after scaling an application? I'd be interested to hear how you identified the boundary and what you moved to shared storage.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspnetcore</category>
      <category>redis</category>
      <category>tutorial</category>
    </item>
    <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>
  </channel>
</rss>
