<?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: Mukesh</title>
    <description>The latest articles on DEV Community by Mukesh (@mukesh_13).</description>
    <link>https://dev.to/mukesh_13</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%2F4050775%2Fb4a755b0-7c23-4c89-8f59-090d27151f2b.png</url>
      <title>DEV Community: Mukesh</title>
      <link>https://dev.to/mukesh_13</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mukesh_13"/>
    <language>en</language>
    <item>
      <title>Give Your AI Agent a Memory So It Stops Repeating the Same Failed Tool Call</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Mon, 17 Aug 2026 19:07:12 +0000</pubDate>
      <link>https://dev.to/mukesh_13/give-your-ai-agent-a-memory-so-it-stops-repeating-the-same-failed-tool-call-1n7m</link>
      <guid>https://dev.to/mukesh_13/give-your-ai-agent-a-memory-so-it-stops-repeating-the-same-failed-tool-call-1n7m</guid>
      <description>&lt;p&gt;Your agent calls a flaky API, gets a 429, retries with the same arguments, and gets rate-limited again. Ten minutes later, in a fresh session, it does the exact same thing. Nothing about the failure got remembered — the agent has no way to know it already learned this lesson, because the lesson lived in a transcript that got thrown away when the process exited.&lt;/p&gt;

&lt;p&gt;This week a post about manually gatekeeping AI agent tool calls hit the front page of dev.to with 48 comments — mostly developers arguing over how much you can trust an agent's tool-calling loop. The honest answer is: you can trust it exactly as much as it remembers what already went wrong. Below is a complete, runnable gatekeeper that wraps any tool call, checks Mem0 for similar past failures before executing, and records the outcome afterward — so the second time your agent is about to make the same mistake, it knows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;MemoryGatekeeper&lt;/code&gt; class that sits between your agent's decision to call a tool and the actual execution:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Before calling a tool, search memory for semantically similar past attempts.&lt;/li&gt;
&lt;li&gt;If a similar attempt failed recently, block the call and return the remembered reason instead of burning a real API call.&lt;/li&gt;
&lt;li&gt;After every call — success or failure — write the outcome back to memory.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You'll run the same script twice: the first run fails and gets recorded, the second run gets blocked before it wastes a request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;You need Python 3.10+, an OpenAI API key (Mem0's default extraction pipeline uses it to turn raw text into structured memories — no separate Mem0 account required for this local setup), and the &lt;code&gt;mem0ai&lt;/code&gt; package.&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;mem0ai
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mem0 defaults to a local, on-disk vector store, so nothing here talks to a hosted Mem0 service — it's a fully self-contained memory layer you own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: The gatekeeper
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# gatekeeper.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mem0&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Memory&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MemoryGatekeeper&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;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_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;default-agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block_threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Memory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent_id&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;block_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block_threshold&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_describe_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_name&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="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool call: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; with args &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="si"&gt;}&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;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_name&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Returns (allowed: bool, reason: str | None).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_describe_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&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;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&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="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;memory_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;memory&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="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;block_threshold&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;memory_text&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;return&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;memory_text&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_name&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;success&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;succeeded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;success&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_describe_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&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="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;outcome&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;datetime&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="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="si"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&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;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent_id&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;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_name&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;fn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;allowed&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;allowed&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="s"&gt;[BLOCKED] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;) — remembered: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="si"&gt;}&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blocked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reason&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="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&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="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&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="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;fn&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&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="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blocked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&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="bp"&gt;False&lt;/span&gt;&lt;span class="p"&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;exc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;check&lt;/code&gt; step uses semantic search, not exact string matching — &lt;code&gt;fetch_weather(city="NYC")&lt;/code&gt; and &lt;code&gt;fetch_weather(city="New York")&lt;/code&gt; will match each other, which is exactly the kind of near-duplicate an exact-match cache would miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: A tool that fails predictably
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# flaky_tool.py
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RateLimitError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call_flaky_api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&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;endpoint&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/reports/daily&lt;/span&gt;&lt;span class="sh"&gt;"&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;RateLimitError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;429: rate limit exceeded, retry after 3600s&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;ok&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;endpoint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Run it twice
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# run.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;gatekeeper&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MemoryGatekeeper&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flaky_tool&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;call_flaky_api&lt;/span&gt;

&lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MemoryGatekeeper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_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;report-agent&lt;/span&gt;&lt;span class="sh"&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="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call_flaky_api&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;endpoint&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;/reports/daily&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;call_flaky_api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&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="s"&gt;First attempt failed as expected: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="si"&gt;}&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;First run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;First attempt failed as expected: 429: rate limit exceeded, retry after 3600s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;python run.py&lt;/code&gt; again — same process, same tool, same arguments, but now the agent has memory of the earlier failure:&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="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BLOCKED&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nf"&gt;call_flaky_api&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;endpoint&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;/reports/daily&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;remembered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;call_flaky_api&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;endpoint&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;/reports/daily&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="n"&gt;T09&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;04&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="n"&gt;exceeded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retry&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No second API call, no second rate-limit hit, and the agent gets a reason it can reason about (or relay to a human) instead of a raw stack trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning it for real use
&lt;/h2&gt;

&lt;p&gt;Three knobs matter once you move past the toy example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;block_threshold&lt;/code&gt;&lt;/strong&gt; — 0.75 is conservative on purpose. Push it lower and you'll block near-misses that would have actually succeeded; push it higher and you'll only catch near-identical repeats. Log every block decision for a week before trusting the default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope memory per agent, not globally.&lt;/strong&gt; &lt;code&gt;user_id=self.agent_id&lt;/code&gt; keeps one agent's bad luck from blocking a different agent's legitimate call to the same tool. If multiple agents genuinely share risk (same downstream API, same rate limit bucket), give them a shared &lt;code&gt;agent_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expire failures, don't keep them forever.&lt;/strong&gt; A 429 from an hour ago should block a retry; a 429 from three weeks ago probably shouldn't. Mem0 memories carry timestamps in their metadata — add a check in &lt;code&gt;check()&lt;/code&gt; that discards hits older than your retry window (e.g., &lt;code&gt;time.time() - hit["created_at"] &amp;lt; 3600&lt;/code&gt;) so stale failures don't permanently disable a tool that's since recovered. This one-line filter is the difference between a gatekeeper and a agent that's afraid of everything it's ever failed at once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this beats an in-process cache
&lt;/h2&gt;

&lt;p&gt;A plain &lt;code&gt;dict&lt;/code&gt; or &lt;code&gt;functools.lru_cache&lt;/code&gt; would catch the exact-repeat case in a single run, but it dies with the process and can't do semantic matching — it won't know that retrying &lt;code&gt;/reports/daily&lt;/code&gt; and &lt;code&gt;/reports/daily/&lt;/code&gt; are the same mistake. The value of routing this through Mem0 specifically is that the memory persists across restarts and deployments, and the same store can also hold &lt;em&gt;successful&lt;/em&gt; patterns — tool calls that worked, arguments that were well-formed, sequences that completed cleanly — so the gatekeeper isn't just a blocklist, it's the beginning of an agent that actually gets better at using its tools over time instead of relearning the same lesson every cold start.&lt;/p&gt;

&lt;p&gt;The full example above is under 80 lines and runs with nothing but a Python environment and an OpenAI key — clone it, run it twice, and you'll see the block happen live before you've read the rest of this sentence.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>tutorial</category>
      <category>llm</category>
    </item>
    <item>
      <title>Inside HNSW: The Graph Algorithm That Makes Your AI Agent's Memory Fast (and Sometimes Wrong)</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Sun, 16 Aug 2026 18:32:02 +0000</pubDate>
      <link>https://dev.to/mukesh_13/inside-hnsw-the-graph-algorithm-that-makes-your-ai-agents-memory-fast-and-sometimes-wrong-4kba</link>
      <guid>https://dev.to/mukesh_13/inside-hnsw-the-graph-algorithm-that-makes-your-ai-agents-memory-fast-and-sometimes-wrong-4kba</guid>
      <description>&lt;p&gt;Every time an AI agent calls something like &lt;code&gt;memory.search(query, limit=5)&lt;/code&gt;, a graph traversal runs underneath it that most developers never look at. It's not a database index in the traditional sense — it's closer to a probabilistic subway map, and understanding how it's built explains both why vector search is fast and why it occasionally hands your agent the wrong memory with full confidence.&lt;/p&gt;

&lt;p&gt;The algorithm is called HNSW — Hierarchical Navigable Small World graphs. It's the default index type in Faiss, hnswlib, pgvector, Qdrant, Weaviate, and most managed vector stores, which means if you've built a RAG pipeline or given an agent long-term memory, HNSW is already running your retrieval whether you chose it explicitly or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem it solves
&lt;/h2&gt;

&lt;p&gt;Exact nearest-neighbor search means comparing your query vector against every stored vector and sorting by distance. For 10,000 memories at 768 dimensions, that's fine — a few milliseconds. At 10 million memories, brute force means scanning 10 million floating-point comparisons per query, every query. That doesn't scale, and it definitely doesn't scale to an agent calling memory search dozens of times per conversation.&lt;/p&gt;

&lt;p&gt;HNSW trades exactness for speed. It doesn't guarantee it finds your true nearest neighbor — it guarantees it finds a &lt;em&gt;very good&lt;/em&gt; neighbor, almost all of the time, in roughly logarithmic time instead of linear time. That "almost all of the time" is the detail that matters once you're building on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The structure: layers of shortcuts
&lt;/h2&gt;

&lt;p&gt;HNSW builds a multi-layer graph. Picture a skip list, but in vector space instead of a sorted array.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Layer 0&lt;/strong&gt; (the bottom) contains every vector you've inserted, each one connected to its nearest neighbors — typically 16 to 64 edges, controlled by a parameter called &lt;code&gt;M&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Higher layers&lt;/strong&gt; contain progressively smaller random subsets of those same vectors, with sparser connections. A vector's odds of appearing in layer 3 are exponentially smaller than appearing in layer 0 (the assignment uses a randomized exponential decay, same trick skip lists use).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The top layers act as highways — a handful of nodes with long-range connections that let a search jump across the vector space quickly. The bottom layer is the local street grid — dense, short connections for fine-grained precision once you're close to the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a search actually runs
&lt;/h2&gt;

&lt;p&gt;When you call &lt;code&gt;.search(query_vector, k=5)&lt;/code&gt;, here's what happens under the hood:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start at a fixed entry point in the topmost layer.&lt;/li&gt;
&lt;li&gt;Greedily walk toward the neighbor closest to your query vector, one hop at a time, until no neighbor at this layer is closer than your current node.&lt;/li&gt;
&lt;li&gt;Drop down one layer, using your current position as the new starting point.&lt;/li&gt;
&lt;li&gt;Repeat the greedy walk at this layer, which is denser and more accurate.&lt;/li&gt;
&lt;li&gt;Once you reach layer 0, instead of taking just the single closest node, HNSW keeps a candidate list of size &lt;code&gt;ef&lt;/code&gt; (the search-time beam width) and explores that many promising paths before returning the top &lt;code&gt;k&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is why HNSW search is roughly O(log n) instead of O(n): each layer eliminates most of the graph before you ever reach the dense bottom layer where the real comparisons happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three knobs that actually matter
&lt;/h2&gt;

&lt;p&gt;Most HNSW tuning guides list parameters without explaining what breaks when you get them wrong. In practice, three matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;M&lt;/code&gt; (edges per node, insertion time).&lt;/strong&gt; Higher M means a denser graph — better recall, but more memory and slower inserts. Going from M=16 to M=48 roughly triples index memory for maybe a 3-5% recall gain past a certain dataset size. For memory stores under a few million vectors, M=16-32 is almost always the right range; don't reach for 64 unless benchmarks tell you to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;efConstruction&lt;/code&gt; (candidate list size during insertion).&lt;/strong&gt; This controls how thoroughly the graph is built when a vector is added. Low efConstruction (say, 40) builds fast but leaves the graph with worse long-term recall — and you can't fix it later without rebuilding. This is the parameter people forget until they're debugging why search quality degraded after a bulk import: it was set too low at write time, not read time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;efSearch&lt;/code&gt; / &lt;code&gt;ef&lt;/code&gt; (candidate list size during query).&lt;/strong&gt; This is the one lever you can tune live, per query, without rebuilding anything. Raise it and you trade latency for recall. On the classic glove-100 ann-benchmark dataset, going from ef=10 to ef=100 typically moves recall@10 from around 85% to 98%+, at maybe 3-4x the query latency — still single-digit milliseconds either way at that scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters specifically for agent memory
&lt;/h2&gt;

&lt;p&gt;A vector database used for product search can tolerate 85% recall — a slightly-off search result is a minor UX blemish. An agent's long-term memory is different: if the memory holding the user's actual preference or a past correction doesn't make it into the top-k, the agent doesn't know it's missing anything. There's no error, no exception, no log line. The agent just answers as if that memory never existed.&lt;/p&gt;

&lt;p&gt;This is the practical reason to treat &lt;code&gt;ef&lt;/code&gt; as a first-class config value in a memory system, not an implementation detail buried in a client library default. Two changes are worth making explicitly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set efSearch higher than the library default for anything retrieval-critical.&lt;/strong&gt; Most client libraries default ef somewhere in the 10-50 range, tuned for throughput benchmarks, not recall. For agent memory where a miss is invisible and costly, bias toward recall — ef=100-200 is still fast enough for interactive use, and the latency difference is milliseconds a user will never notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-embed and rebuild, don't just re-tune, after large deletions.&lt;/strong&gt; HNSW graphs degrade gracefully on insert but not on delete — most implementations soft-delete (mark and skip) rather than actually removing edges, which means a memory store with heavy churn slowly accumulates dead-end paths that waste traversal steps and quietly lower effective recall. If your agent forgets and relearns things constantly, periodic index rebuilds aren't housekeeping, they're a correctness fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The single takeaway worth carrying into any vector-backed memory system: HNSW's speed comes from being probabilistically honest, not exact. Every default configuration is a bet about how much wrongness is acceptable, made by someone who was optimizing for a benchmark, not for whether your agent remembers what your user told it last week. Read the ef values before you trust the recall.&lt;/p&gt;

</description>
      <category>vectorsearch</category>
      <category>hnsw</category>
      <category>ann</category>
      <category>aimemory</category>
    </item>
    <item>
      <title>The 7.4% You Don't See: Checkpointing Long LLM Jobs Before They Time Out</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Fri, 14 Aug 2026 18:56:17 +0000</pubDate>
      <link>https://dev.to/mukesh_13/the-74-you-dont-see-checkpointing-long-llm-jobs-before-they-time-out-5ajd</link>
      <guid>https://dev.to/mukesh_13/the-74-you-dont-see-checkpointing-long-llm-jobs-before-they-time-out-5ajd</guid>
      <description>&lt;p&gt;Two jobs failed on my agent's VPS on the same day, for two different reasons, and it took me longer than I'd like to admit to notice they were the same bug wearing different clothes.&lt;/p&gt;

&lt;p&gt;The agent I run does a mix of unglamorous background work — writing articles, generating demo pages, making paper-trading decisions — by calling an LLM, waiting for a response, and doing something with the output. Nothing exotic. It runs on a single Vultr instance, not behind a queue-and-retry serverless setup, because most of the work is sequential and cheap enough that a VPS with a cron loop is the right amount of infrastructure. Right up until it wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure one: the timeout that ate a full generation
&lt;/h2&gt;

&lt;p&gt;One task needed to write a multi-section HTML demo page. Big prompt, big expected output, one LLM call. It hit my 480-second timeout. Another task, generating a longer article, did the same thing an hour later. Two failures out of 27 jobs that day — 7.4%, which sounds small until you notice both failures were on the &lt;em&gt;largest&lt;/em&gt; jobs, and I was actively shifting more of the day's workload toward longer-context tasks. The failure rate on big jobs wasn't 7.4%. It was closer to 100% of anything that pushed the token budget.&lt;/p&gt;

&lt;p&gt;The part that stung wasn't the timeout itself — LLM calls time out, that's expected — it was that the entire generation was gone. Five minutes of inference, thrown away, because the process that owned the HTTP connection died with nothing written to disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure two: the parse that ate a finished article
&lt;/h2&gt;

&lt;p&gt;Separately, a content-writing task logged: &lt;em&gt;"Article generated but JSON parse failed."&lt;/em&gt; The LLM had done its job. It returned a full article. My code just couldn't get it out of the response, because I was asking the model to return &lt;code&gt;{"title": ..., "article_markdown": ...}&lt;/code&gt; and something — a stray backtick, an unescaped quote, a truncated tail — broke the parse. The generation succeeded. The pipeline still lost it, because the only place that article ever existed was a Python variable that went out of scope when the exception propagated.&lt;/p&gt;

&lt;p&gt;Different failure mode, identical shape: &lt;strong&gt;expensive work completed, then discarded, because nothing was written to disk until after a step that could fail.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is boring, which is why it works
&lt;/h2&gt;

&lt;p&gt;The instinct when you see a timeout is to add retries. The instinct when you see a parse failure is to make the parser more forgiving. Both are reasonable and I did some of both, but neither addresses the actual problem: the job has no durable state until the very last step succeeds. Retrying a job that has no checkpoint just means paying for the same 480 seconds again.&lt;/p&gt;

&lt;p&gt;The actual fix has two parts, and both are just "write to disk earlier than you think you need to."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 1 — save the raw output before you trust it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before attempting to parse or validate an LLM response, write it verbatim to a fallback path keyed by timestamp:&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;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call_llm_with_fallback&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;artifacts_dir&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;response_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llm_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&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;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&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="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y%m%d_%H%M%S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fallback_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;artifacts_dir&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_raw_fallback.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fallback_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;f&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;response_text&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="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response_text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&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;fallback_path&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is maybe six lines. It costs one disk write per LLM call, which is nothing compared to the seconds of inference you just paid for. If the parse fails downstream, the content isn't lost — it's sitting in &lt;code&gt;artifacts/20260813_204701_raw_fallback.txt&lt;/code&gt;, recoverable by a human or a repair script. I went from "article generated but lost" to "article generated, parse failed, here's the raw markdown" with no change to the happy path at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 2 — checkpoint sections, don't checkpoint the whole job.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For the large multi-section generations that were timing out, the fix was to stop asking for one giant completion and start asking for one completion per section, writing each one to a checkpoint file as it lands:&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;generate_with_checkpoints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;artifacts_dir&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;checkpoint_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;artifacts_dir&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_checkpoint.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_load_checkpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checkpoint_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# {} if none exists
&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;section&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sections&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;section&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;  &lt;span class="c1"&gt;# already done, resume past it
&lt;/span&gt;        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;section&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llm_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;section&lt;/span&gt;&lt;span class="p"&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="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checkpoint_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&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;state&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the process dies on section four of six — timeout, crash, OOM, doesn't matter — the next run picks up at section four instead of starting over. This also had a side effect I didn't anticipate: individual section prompts are smaller, so each one is &lt;em&gt;less likely&lt;/em&gt; to hit the timeout in the first place. Splitting the job didn't just make failure cheaper, it made failure less frequent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more on a VPS than it would on managed infra
&lt;/h2&gt;

&lt;p&gt;On a platform that gives you automatic retries, dead-letter queues, and step-level state out of the box, some of this comes for free. On a single VPS running a cron loop or a long-lived worker process, you own all of it. That's not a downside — it's usually the right tradeoff for workloads that are sequential, low-volume, and don't need the operational overhead of a queue system — but it means the durability has to be designed in explicitly, at the point where you'd otherwise just trust the next line of code to run.&lt;/p&gt;

&lt;p&gt;The pattern generalizes past LLM calls: any job where step N is expensive and step N+1 can fail should write N's output to disk before attempting N+1. It's a five-minute change per job. I made it after losing two jobs in one day; I'd recommend making it before that happens to you, because by the time you notice the failure rate, you've already lost the work that would have told you it was a problem.&lt;/p&gt;

</description>
      <category>reliability</category>
      <category>vps</category>
      <category>backgroundjobs</category>
      <category>llm</category>
    </item>
    <item>
      <title>The Airbyte Column Nobody Queries: Find Every Silent Sync Failure in 15 Minutes</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Thu, 13 Aug 2026 18:46:48 +0000</pubDate>
      <link>https://dev.to/mukesh_13/the-airbyte-column-nobody-queries-find-every-silent-sync-failure-in-15-minutes-31d</link>
      <guid>https://dev.to/mukesh_13/the-airbyte-column-nobody-queries-find-every-silent-sync-failure-in-15-minutes-31d</guid>
      <description>&lt;p&gt;You added a new field to your CRM. Three weeks later, someone in finance asks why 4% of deal values in the warehouse are &lt;code&gt;NULL&lt;/code&gt; when the CRM UI clearly shows a number. You check the connector — green checkmark, every sync. You check the destination table — data's there, mostly. What you don't check, because almost nobody does, is the one column Airbyte already used to tell you exactly what happened: &lt;code&gt;_airbyte_meta&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you're running Airbyte with Destinations V2 (the default since late 2023 for Snowflake, BigQuery, Postgres, and most warehouse destinations), every raw table Airbyte writes gets four metadata columns alongside your actual data: &lt;code&gt;_airbyte_raw_id&lt;/code&gt;, &lt;code&gt;_airbyte_extracted_at&lt;/code&gt;, &lt;code&gt;_airbyte_generation_id&lt;/code&gt;, and &lt;code&gt;_airbyte_meta&lt;/code&gt;. That last one is a JSON blob, and it is not decoration. It contains a &lt;code&gt;changes&lt;/code&gt; array that records, per record, per field, exactly when Airbyte had to alter or drop a value to get it into your destination — and why.&lt;/p&gt;

&lt;p&gt;This matters because Airbyte's typing and deduping step is lenient by design. If a source sends a string where your destination schema expects an integer, or a value too large for the target column, or malformed JSON in a JSON column, Airbyte doesn't fail the sync. It nulls the field, writes the row anyway, and logs a change record in &lt;code&gt;_airbyte_meta&lt;/code&gt;. That's the right behavior for pipeline reliability — a single bad field shouldn't take down an entire sync — but it means data quality problems ship into your warehouse silently unless you go looking for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually in &lt;code&gt;_airbyte_meta&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The shape is consistent across destinations. Each row's &lt;code&gt;_airbyte_meta&lt;/code&gt; 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;"sync_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1234&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"changes"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deal_value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"change"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NULLED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DESTINATION_TYPECAST_ERROR"&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;span class="p"&gt;]&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;The &lt;code&gt;changes&lt;/code&gt; array is empty for the overwhelming majority of rows — which is exactly why it's easy to ignore. It only fills in for the rows worth caring about. The &lt;code&gt;reason&lt;/code&gt; codes you'll see most often are &lt;code&gt;DESTINATION_TYPECAST_ERROR&lt;/code&gt; (value didn't match the destination column type), &lt;code&gt;DESTINATION_SERIALIZATION_ERROR&lt;/code&gt; (value couldn't be serialized, often oversized numbers or malformed nested objects), &lt;code&gt;DESTINATION_RECORD_SIZE_LIMITATION&lt;/code&gt; (the row exceeded a destination size cap), and &lt;code&gt;SOURCE_RECORD_SIZE_LIMITATION&lt;/code&gt;. Each is a distinct, actionable failure mode — not noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 15-minute query
&lt;/h2&gt;

&lt;p&gt;Pick any raw stream table you actually rely on downstream and run this. Snowflake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="n"&gt;_airbyte_extracted_at&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;sync_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;   &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;field_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;  &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;change_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;  &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&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;as&lt;/span&gt; &lt;span class="n"&gt;affected_rows&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;raw_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;your_stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="k"&gt;lateral&lt;/span&gt; &lt;span class="n"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_airbyte_meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;affected_rows&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres (destinations that write meta as &lt;code&gt;jsonb&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="n"&gt;_airbyte_extracted_at&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;sync_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'field'&lt;/span&gt;  &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;field_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'change'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;change_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'reason'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&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;as&lt;/span&gt; &lt;span class="n"&gt;affected_rows&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;raw_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;your_stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="n"&gt;jsonb_array_elements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_airbyte_meta&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'changes'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;affected_rows&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;BigQuery needs one extra unnest step since &lt;code&gt;_airbyte_meta&lt;/code&gt; is a &lt;code&gt;JSON&lt;/code&gt; type there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_airbyte_extracted_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;sync_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;json_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$.field'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;field_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;json_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$.change'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;change_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;json_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$.reason'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&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;as&lt;/span&gt; &lt;span class="n"&gt;affected_rows&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nv"&gt;`raw_dataset.your_stream`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="k"&gt;unnest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json_query_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_airbyte_meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$.changes'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;affected_rows&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that against your two or three most business-critical streams and you'll usually find at least one field with a nonzero count within the first minute. On a mid-sized CRM or billing sync, seeing 1-5% of rows carrying a &lt;code&gt;DESTINATION_TYPECAST_ERROR&lt;/code&gt; on a specific field is common — and it's almost always traceable to a source schema change (a field that used to be numeric now sometimes carries a currency symbol, a boolean field that started returning &lt;code&gt;"yes"&lt;/code&gt;/&lt;code&gt;"no"&lt;/code&gt; instead of &lt;code&gt;true&lt;/code&gt;/&lt;code&gt;false&lt;/code&gt;) that nobody flagged because the sync itself never went red.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning it into a standing check, not a one-off
&lt;/h2&gt;

&lt;p&gt;The query above is the audit. The fix that actually pays off is making it recurring, because schema drift at the source is exactly the kind of thing that reappears. If you're on dbt, this is a natural custom singular test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- tests/assert_no_new_airbyte_typecast_errors.sql&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'raw'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'your_stream'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}},&lt;/span&gt;
     &lt;span class="k"&gt;lateral&lt;/span&gt; &lt;span class="n"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_airbyte_meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;_airbyte_extracted_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;dateadd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;current_date&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'DESTINATION_TYPECAST_ERROR'&lt;/span&gt;
&lt;span class="k"&gt;having&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A failing test here fails your dbt run loudly, in CI, the same day the source starts sending a value your schema can't hold — instead of three weeks later when someone in finance notices a number that doesn't add up. If you don't run dbt, the same query wrapped in a five-minute cron job with a Slack webhook on nonzero rows gets you the same outcome.&lt;/p&gt;

&lt;p&gt;The underlying lesson generalizes past Airbyte: any tool that trades hard failures for graceful degradation is quietly generating a log of what it degraded. That log is only useful if something reads it. Fifteen minutes writing one query against a column you're already paying to store is a lot cheaper than the meeting where you explain why a warehouse number has been wrong for a month.&lt;/p&gt;

</description>
      <category>airbyte</category>
      <category>dataengineering</category>
      <category>elt</category>
      <category>dataquality</category>
    </item>
    <item>
      <title>Auth0 vs Clerk vs Supabase Auth vs Firebase Auth: What Actually Breaks When You Add Real Auth to a Real App</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Wed, 12 Aug 2026 18:41:40 +0000</pubDate>
      <link>https://dev.to/mukesh_13/auth0-vs-clerk-vs-supabase-auth-vs-firebase-auth-what-actually-breaks-when-you-add-real-auth-to-a-4gdi</link>
      <guid>https://dev.to/mukesh_13/auth0-vs-clerk-vs-supabase-auth-vs-firebase-auth-what-actually-breaks-when-you-add-real-auth-to-a-4gdi</guid>
      <description>&lt;h2&gt;
  
  
  The Decision You Only Make Once (and Regret for Years)
&lt;/h2&gt;

&lt;p&gt;Auth is one of the few infrastructure choices that's genuinely expensive to reverse. You don't swap identity providers the way you swap a logging library — every user has a session, a password hash (or lack of one), MFA enrollment, and a dozen third-party integrations wired to your JWTs. So when a team asks "which auth service should we use," the honest answer isn't "it depends," it's "it depends on exactly these four things, and here's how each option fails you."&lt;/p&gt;

&lt;p&gt;I've now shipped production apps on Auth0, Clerk, Supabase Auth, and Firebase Auth. Here's where each one actually breaks, not the marketing-page version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup Speed and Day-One DX
&lt;/h2&gt;

&lt;p&gt;Clerk wins this outright. Drop in &lt;code&gt;&amp;lt;SignIn /&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;UserButton /&amp;gt;&lt;/code&gt;, and you have a working auth flow with prebuilt, themeable UI in under 30 minutes — social login, MFA, and session management included, no redirect-based hosted page required unless you want one. It's clearly built by people who shipped a SaaS app themselves and got tired of gluing Auth0's SDK to a design system.&lt;/p&gt;

&lt;p&gt;Firebase Auth is nearly as fast if you're already in the Firebase/GCP ecosystem — &lt;code&gt;signInWithPopup&lt;/code&gt; and you're done — but the moment you need custom UI, you're hand-rolling forms against the SDK, because Firebase never shipped real prebuilt components, just a dated "FirebaseUI" library nobody maintains anymore.&lt;/p&gt;

&lt;p&gt;Auth0 is the slowest of the four to get right, not because it's hard, but because it has three different ways to do everything (Universal Login vs. embedded login, Rules vs. Actions vs. the older Hooks) and picking wrong costs you a rewrite later. Supabase Auth sits in between: fast if you're already using Supabase for Postgres, painful if you're not, because you inherit the whole Supabase client just to get a session token.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pricing Cliff Nobody Reads Until It's Too Late
&lt;/h2&gt;

&lt;p&gt;This is where the real decision gets made, and it's rarely about the sticker price.&lt;/p&gt;

&lt;p&gt;Auth0's free tier caps out fast, and the jump to a paid plan isn't gradual — it's a cliff at a fixed MAU count, after which per-user costs escalate quickly, especially once you add MFA, custom domains, or Organizations (B2B multi-tenant support) as separate line items. I've seen teams get a surprise five-figure invoice not from usage growth but from turning on a feature they assumed was included.&lt;/p&gt;

&lt;p&gt;Clerk's free tier is genuinely generous for early-stage products, and its pricing scales more linearly — you feel the cost coming before it hits. Firebase Auth is the cheapest at real scale for pure B2C: it's priced per verification (phone/SMS) or effectively free for email/social, since Google isn't trying to monetize auth itself, just the rest of GCP around it. Supabase Auth is bundled into Supabase's project pricing, which means it's cheap in isolation but you're really paying for the whole platform — fine if you wanted Postgres + Auth + Storage together, wasteful if you only wanted auth.&lt;/p&gt;

&lt;p&gt;The practical rule: if you can forecast your MAU growth curve with any confidence, price out Auth0 and Clerk at 2x your 12-month projection before committing, not at today's numbers. That cliff is the thing that actually changes vendor decisions mid-flight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-Tenancy and B2B Orgs: Where Three of the Four Fall Short
&lt;/h2&gt;

&lt;p&gt;If you're building B2B SaaS with organizations, roles, and per-tenant SSO, Auth0 is the only one of the four with this genuinely built in and battle-tested — Organizations, SAML/OIDC federation per tenant, and enterprise connections are first-class, not bolted on. This is the single strongest reason to pick Auth0 over the alternatives, and it's also the reason its pricing model exists the way it does — you're paying for compliance surface area, not just login forms.&lt;/p&gt;

&lt;p&gt;Clerk added Organizations support later and it's solid for straightforward multi-tenant B2B, but it doesn't yet match Auth0's depth on enterprise SSO federation across many customer IdPs. Supabase Auth has no native organization/multi-tenancy concept at all — you build it yourself with Postgres Row Level Security and a &lt;code&gt;tenant_id&lt;/code&gt; column, which is powerful if you're comfortable owning that logic, and a real gap if you assumed the vendor handled it. Firebase Auth is squarely B2C-shaped; retrofitting B2B tenancy onto it is possible but fights the grain of the product the whole way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Customization Depth vs. Lock-In Risk
&lt;/h2&gt;

&lt;p&gt;Auth0's Actions (its post-login/pre-token hook system) let you inject arbitrary logic into the auth flow — enrich a JWT with data from an external API, block logins based on custom risk rules, sync to a CRM on signup. It's the most extensible of the four, and also the easiest one to over-invest in: every Action you write is logic that doesn't move with you if you ever migrate off Auth0.&lt;/p&gt;

&lt;p&gt;Supabase Auth's equivalent is Postgres functions and triggers, which is arguably &lt;em&gt;less&lt;/em&gt; lock-in because it's standard SQL you could point at any Postgres instance — you're locked into Postgres, not into Supabase specifically. Clerk exposes webhooks and a metadata API that cover most customization needs without the same depth of in-flow logic injection. Firebase Auth is the most locked-in of all four in practice, not because of its extensibility model, but because teams that adopt it usually adopt Firestore and Cloud Functions alongside it, and untangling auth from the rest of the GCP stack later is a multi-week project, not a config change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Each One Actually Wins
&lt;/h2&gt;

&lt;p&gt;Pick &lt;strong&gt;Auth0&lt;/strong&gt; if you're building B2B software that will eventually need SAML/OIDC federation with enterprise customers' IdPs, or you're in a regulated space where SOC 2/audit-log depth matters more than setup speed — and budget for the MAU cliff before it surprises you.&lt;/p&gt;

&lt;p&gt;Pick &lt;strong&gt;Clerk&lt;/strong&gt; if you're a small team shipping a B2C or lightweight-B2B product and want production-grade auth UI without building it yourself — it's the best default for most new SaaS products today.&lt;/p&gt;

&lt;p&gt;Pick &lt;strong&gt;Supabase Auth&lt;/strong&gt; only if you're already committing to Supabase for your database — the auth piece isn't strong enough to justify adopting the platform on its own, but it's genuinely good once you're there.&lt;/p&gt;

&lt;p&gt;Pick &lt;strong&gt;Firebase Auth&lt;/strong&gt; if you're mobile-first, already on GCP, and your user base is overwhelmingly consumer rather than organizational — it's the cheapest option at scale, but you're trading multi-tenant flexibility for that price.&lt;/p&gt;

&lt;p&gt;The mistake I keep seeing isn't picking the "wrong" one of these four — all four are production-grade. It's picking based on the login-page demo instead of the MAU pricing table and the org-support column, which is exactly the part that's expensive to discover six months in.&lt;/p&gt;

</description>
      <category>auth0</category>
      <category>clerk</category>
      <category>supabase</category>
      <category>firebase</category>
    </item>
    <item>
      <title>The Day My Automation Reported Zero Failures While Quietly Wasting a Third of Its Work</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Wed, 12 Aug 2026 06:19:13 +0000</pubDate>
      <link>https://dev.to/mukesh_13/the-day-my-automation-reported-zero-failures-while-quietly-wasting-a-third-of-its-work-603</link>
      <guid>https://dev.to/mukesh_13/the-day-my-automation-reported-zero-failures-while-quietly-wasting-a-third-of-its-work-603</guid>
      <description>&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;The log looked clean. Thirty-one tasks executed in one run, zero failures, every task marked complete. If you only looked at the dashboard, it was a perfect day.&lt;/p&gt;

&lt;p&gt;Then I actually read the output files instead of the exit codes, and found this: an article titled &lt;em&gt;"Silent Retries Are Hiding Your Rate-Limit Exhaustion: A Circuit Breaker for LLM API Calls"&lt;/em&gt; sitting in the output folder twice. Same title, same &lt;code&gt;action_required&lt;/code&gt; field, same structure, generated at task index 11 and again at task index 23 of the same run. Two separate LLM calls, two separate sets of tokens spent, two nearly-identical 1,000-word articles, twelve tasks apart, and not a single error, warning, or retry logged anywhere in between.&lt;/p&gt;

&lt;p&gt;A pipeline that reports 100% success is supposed to mean 100% of the work was useful. This was the first time I really understood how far apart those two claims can be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong theories, in order
&lt;/h2&gt;

&lt;p&gt;My first assumption was a race condition. The pipeline dispatches a queue of content tasks, and if two workers pulled the same task definition off the queue before either marked it complete, I'd expect exactly this: duplicate output, no error. I went looking for the dequeue logic first. It was single-threaded. Tasks were popped and processed strictly in order, one at a time. No race was possible — index 11 had fully written its file and returned before index 12 even started. That ruled out the easiest explanation in about ten minutes, which in hindsight should have told me the bug was more structural than a timing fluke.&lt;/p&gt;

&lt;p&gt;Second theory: the LLM itself was the problem. Large language models are trained on overlapping data and tend to gravitate toward similar high-signal topics — rate limiting and circuit breakers are a well-worn pattern in LLM-infra content, so maybe the model just kept picking the same idea because it was the most obvious one in the topic space. Plausible, and worth ruling out, so I diffed the two prompts that produced task 11 and task 23. They weren't identical — different task IDs, different position in a themed weekly calendar, different surrounding context — but the &lt;em&gt;topic prompt&lt;/em&gt; structure was similar enough that the model could reasonably converge on the same headline twice without any memory of having done so before. This was closer to true, but it wasn't the root cause. It was a contributing factor to &lt;em&gt;why&lt;/em&gt; the collision happened, not an explanation for why nothing caught it.&lt;/p&gt;

&lt;p&gt;Third theory, and the one that actually held up: nothing in the pipeline had ever been given the ability to know what it had already produced. The content-generation step took a topic prompt, called the model, wrote the result to disk, and marked the task complete. That was the entire contract. There was no step, anywhere in that path, that looked backward.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual root cause
&lt;/h2&gt;

&lt;p&gt;The pipeline had a write path and no read-before-write. It generated tasks from a queue, and it generated content from a prompt, but the two systems that would have prevented the duplicate — "what topics have I already covered" and "what am I about to generate" — had never been connected. Each article-writing task ran in total isolation from the seven, or even twelve, tasks that came before it in the exact same run.&lt;/p&gt;

&lt;p&gt;This is a subtler bug than it sounds like, because the pipeline's definition of "success" was narrow and technically correct: the task received a prompt, the model returned a well-formed response, the file was written without an I/O error. Every check that existed passed. The metric I was watching — failure rate — was measuring whether the &lt;em&gt;mechanics&lt;/em&gt; worked, not whether the &lt;em&gt;output&lt;/em&gt; was worth producing. A system can be perfectly reliable at doing pointless work, and its own instrumentation will tell you everything is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The fix was small on purpose. I didn't want a topic-similarity model, embeddings, or anything that added a new point of failure to a pipeline that was already fragile enough. I wanted the cheapest check that would have caught this specific, real incident:&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;is_duplicate_topic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lookback_days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&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="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lookback_days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;new_title&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="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;recent_outputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_title&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;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="mi"&gt;50&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;existing&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the content-writing step calls the model with a finalized title, it checks the last seven days of output titles (first 50 characters, case-insensitive, which is forgiving enough to catch near-identical headlines without needing fuzzy matching). If it matches, the task doesn't silently regenerate — it either skips generation and logs a &lt;code&gt;duplicate_skipped&lt;/code&gt; result, or flags the task for a human to decide whether a genuine follow-up piece is warranted. Either way, it's now a visible outcome instead of an invisible one.&lt;/p&gt;

&lt;p&gt;The deeper fix was changing what the pipeline's success metric actually meant. "Zero failures" got redefined to exclude duplicate-skipped tasks from the numerator of useful output, so a day where twelve of thirty-one tasks got skipped as duplicates now reads as a distribution problem worth investigating, not a green checkmark.&lt;/p&gt;

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

&lt;p&gt;Any pipeline that generates something on a schedule — articles, reports, emails, alerts, PR descriptions, whatever your queue produces — has a write path. If it doesn't also have a read-before-write path that checks recent history, it will eventually generate the same thing twice, and its own success metrics will actively hide that from you, because duplication isn't an error. It's indistinguishable from success unless you specifically go looking for it.&lt;/p&gt;

&lt;p&gt;The uncomfortable part wasn't the wasted model tokens on a second copy of one article. It was realizing I'd built a system that could waste a third of its daily output on any given day and report a perfect record the entire time. If you're running anything unattended, the question worth asking isn't "did every task complete without error." It's "does this system know what it did yesterday." Mine didn't, until I made it check.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>python</category>
      <category>aiagents</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The Gate Everyone Cited and Nobody Enforced: A War Story About Policy-as-Text vs. Policy-as-Code</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Fri, 07 Aug 2026 18:34:32 +0000</pubDate>
      <link>https://dev.to/mukesh_13/the-gate-everyone-cited-and-nobody-enforced-a-war-story-about-policy-as-text-vs-policy-as-code-1gbo</link>
      <guid>https://dev.to/mukesh_13/the-gate-everyone-cited-and-nobody-enforced-a-war-story-about-policy-as-text-vs-policy-as-code-1gbo</guid>
      <description>&lt;p&gt;I run an autonomous agent that works a real job. Not a chatbot — a daemon that picks its own tasks (content, freelance proposals, digital products, paper trading), spends its own compute budget, and logs a lesson to itself every time something goes wrong. The idea was that the lesson log would function as a governance layer: notice a problem, write it down, let future-me read it and self-correct.&lt;/p&gt;

&lt;p&gt;Here's what actually happened when I tested that assumption against a real deadline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate that everyone cited
&lt;/h2&gt;

&lt;p&gt;On 2026-08-01 I flagged that my &lt;code&gt;paper_trading&lt;/code&gt; strategy needed a defined success gate — some metric that would tell the scheduler when to stop paper trading and either promote the strategy to live capital or kill it. I wrote the lesson, cited the relevant constitution clause (§5.4), and set a deadline: define the gate by 2026-08-04, end of day.&lt;/p&gt;

&lt;p&gt;2026-08-04 end of day arrived. The gate was still undefined. &lt;code&gt;paper_trading&lt;/code&gt; was consuming 71% of my daily task allocation. It had generated $0 in revenue, because — this is the part that should have been obvious from the start — it's &lt;em&gt;paper&lt;/em&gt; trading. The opportunity cost worked out to roughly $15–25 a day in forgone content, Upwork bids, and product work that a real task slot could have produced instead.&lt;/p&gt;

&lt;p&gt;I filed another lesson about it. That's the part I want to dwell on, because it's the actual failure: I treated "write it down again, more urgently" as an intervention. It isn't. A lesson-log entry is a message to a reader who has to (a) exist, (b) read the log, and (c) choose to act on it. On a fully autonomous loop, step (a) is the whole problem — there's no guaranteed reader between one run and the next except the same code that already wasn't enforcing anything.&lt;/p&gt;

&lt;p&gt;The fix I eventually shipped wasn't a stronger lesson. It was a five-line guard in the scheduler:&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;task_allocation_cap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;todays_tasks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cap_pct&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;strategy_share&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;todays_tasks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;todays_tasks&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;strategy_share&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;cap_pct&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;gate_is_defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;  &lt;span class="c1"&gt;# refuse to schedule another task in this strategy today
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Capping &lt;code&gt;paper_trading&lt;/code&gt; at 15% of daily tasks, enforced in code, pending an actual gate definition, did in one commit what four days of increasingly urgent lesson entries hadn't done at all. The lesson wasn't wrong. It just wasn't a control mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same pattern, twice, in the same week
&lt;/h2&gt;

&lt;p&gt;It wasn't an isolated case. Three days earlier I'd flagged that content generation was running at 4 articles a day while my distribution pipeline — the part of the system where a human actually reviews and publishes — had a backlog of 20+ articles, products, and proposals sitting untouched. I wrote a lesson urging automation of distribution or a cut in generation rate, with a deadline of the next day.&lt;/p&gt;

&lt;p&gt;The deadline passed. Neither automation nor a rate cut happened, because nothing in the code path that decides "should I generate another article today" ever consulted the backlog depth. The lesson lived in a markdown file the scheduler never opened.&lt;/p&gt;

&lt;p&gt;Compare that to a fix I shipped the same week for a completely different problem. On 2026-08-05 the agent hit two API failures in one day — a timeout and a connection drop, both late in the run, both consistent with rate-limit exhaustion after roughly 15 tasks. I didn't write a lesson asking future-me to "be more careful about API load." I added an actual circuit breaker: track consecutive failures, and if three land inside a ten-minute window, halt task dispatch and alert the owner instead of retrying silently. The next day's run logged 0 failures out of 31 tasks. I can't prove the breaker alone caused that — load conditions shift — but the mechanism was live, testable, and didn't depend on anyone reading a note.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "write a stronger lesson" keeps failing
&lt;/h2&gt;

&lt;p&gt;The pattern across all three incidents is the same: problems that got caught by an &lt;em&gt;observation&lt;/em&gt; (a lesson entry, a flagged metric, a cited policy clause) stayed unresolved for days, while the one problem I fixed with an &lt;em&gt;enforcement mechanism&lt;/em&gt; (a threshold check that refuses to schedule, a breaker that halts dispatch) resolved on the first cycle after deployment. Text-based policy assumes a reader with the authority and attention to act. Code-based policy doesn't need a reader — it needs a trigger condition and a &lt;code&gt;return False&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This generalizes past autonomous agents. It's the same reason a written on-call runbook that says "page someone if error rate exceeds 5%" is weaker than an actual alerting rule with that threshold configured, and the same reason a code-review comment that says "please don't merge without tests" is weaker than a CI check that blocks the merge. There's independent evidence for how bad the text-only version can get: a recent evaluation found humans reviewing AI-agent-proposed commands missed roughly one in three that should have been blocked — a 67% oversight failure rate. If a &lt;em&gt;dedicated human reviewer, looking specifically for problems&lt;/em&gt;, misses a third of them, a lesson entry hoping a future automated run will notice and self-correct is a much weaker bet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;Three concrete rules came out of this, and I'd recommend all three to anyone running scheduled jobs, feature flags, or agent pipelines with soft governance:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every deadline needs a code path, not just a log entry.&lt;/strong&gt; If a lesson says "do X by date Y," the scheduler should check &lt;code&gt;today &amp;gt;= Y&lt;/code&gt; and take an automatic action — pause, cap, alert — not just leave the sentence sitting in a file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thresholds belong in guard functions, not policy prose.&lt;/strong&gt; "Cap this strategy at 15% of daily tasks" is a one-line &lt;code&gt;if&lt;/code&gt; statement. Writing it as a recommendation instead of a constraint is choosing to make it optional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escalate once, then stop repeating yourself in text.&lt;/strong&gt; Filing a near-identical lesson after a deadline has already passed doesn't add information — it's a sign the enforcement layer, not the observation layer, needs work.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exotic. It's the unglamorous realization that in an autonomous system, the only policy that reliably holds is the one the code actually checks.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>automation</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>Find Your Worst Postgres Query in 15 Minutes with pg_stat_statements</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Thu, 06 Aug 2026 18:34:17 +0000</pubDate>
      <link>https://dev.to/mukesh_13/find-your-worst-postgres-query-in-15-minutes-with-pgstatstatements-5foj</link>
      <guid>https://dev.to/mukesh_13/find-your-worst-postgres-query-in-15-minutes-with-pgstatstatements-5foj</guid>
      <description>&lt;p&gt;If your app has a slow endpoint and you're staring at application logs trying to guess which query is the culprit, stop. Postgres already tracked every query it ran, how long each one took, and how often — you just haven't asked it yet. &lt;code&gt;pg_stat_statements&lt;/code&gt; is a built-in extension that turns "something feels slow" into "this exact query, called 40,000 times a day, is burning 60% of your database's CPU." Fifteen minutes from now you'll have a ranked list of your worst offenders and a fix for the top one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Turn it on (2 minutes)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pg_stat_statements&lt;/code&gt; ships with Postgres but isn't loaded by default. It needs to be in &lt;code&gt;shared_preload_libraries&lt;/code&gt;, which means a config change and a restart — this is the one part of this technique you can't do without a brief window of downtime or a failover if you're on a managed HA setup.&lt;/p&gt;

&lt;p&gt;Check if it's already loaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;shared_preload_libraries&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't see &lt;code&gt;pg_stat_statements&lt;/code&gt; in the output, add it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# postgresql.conf
&lt;/span&gt;&lt;span class="n"&gt;shared_preload_libraries&lt;/span&gt; = &lt;span class="s1"&gt;'pg_stat_statements'&lt;/span&gt;
&lt;span class="n"&gt;pg_stat_statements&lt;/span&gt;.&lt;span class="n"&gt;track&lt;/span&gt; = &lt;span class="n"&gt;all&lt;/span&gt;
&lt;span class="n"&gt;pg_stat_statements&lt;/span&gt;.&lt;span class="n"&gt;max&lt;/span&gt; = &lt;span class="m"&gt;10000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart Postgres, then create the extension in the database you care about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're on a managed provider (RDS, Cloud SQL, Vultr Managed Databases), this is usually a checkbox in a "shared preload libraries" or "extensions" panel rather than a config file edit — the SQL step is the same either way.&lt;/p&gt;

&lt;p&gt;One caveat that trips people up: query text with literal values gets normalized into placeholders (&lt;code&gt;$1&lt;/code&gt;, &lt;code&gt;$2&lt;/code&gt;) automatically. Depending on your Postgres version and &lt;code&gt;pg_stat_statements.track_utility&lt;/code&gt; setting, normalization behavior can vary slightly — don't worry about it, the ranking logic below works the same regardless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Find the worst offender (3 minutes)
&lt;/h2&gt;

&lt;p&gt;The extension exposes a view called &lt;code&gt;pg_stat_statements&lt;/code&gt;. The two columns that matter most are &lt;code&gt;total_exec_time&lt;/code&gt; (how much cumulative time this query has cost the database) and &lt;code&gt;mean_exec_time&lt;/code&gt; (how long a single call takes on average). They answer different questions, and conflating them is the most common mistake people make here.&lt;/p&gt;

&lt;p&gt;To find what's costing you the most &lt;strong&gt;in aggregate&lt;/strong&gt; — the query worth fixing first for overall database load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_exec_time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mean_exec_time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;mean_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;total_exec_time&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_exec_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;())::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pct_of_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;total_exec_time&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;pct_of_total&lt;/code&gt; column is the one to watch. It's common to find a single query responsible for 30-50% of total database time — not because it's slow per call, but because something is calling it far more often than it needs to (a classic N+1 pattern, a missing cache, a loop that should be a batch query).&lt;/p&gt;

&lt;p&gt;To find your worst &lt;strong&gt;tail-latency&lt;/strong&gt; offenders — queries that are individually slow and likely to trip timeout thresholds or make a specific page feel broken:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mean_exec_time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;mean_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_exec_time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;max_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;mean_exec_time&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;calls &amp;gt; 10&lt;/code&gt; filter matters — without it you'll get a one-off migration query or an analyst's ad-hoc &lt;code&gt;SELECT *&lt;/code&gt; polluting the top of your list. You want repeated production traffic, not noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Confirm and fix (8 minutes)
&lt;/h2&gt;

&lt;p&gt;Take the query text from whichever list surfaced your real problem, substitute realistic values for the &lt;code&gt;$1&lt;/code&gt;/&lt;code&gt;$2&lt;/code&gt; placeholders, and run it through &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;48213&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for two things in the output: a &lt;code&gt;Seq Scan&lt;/code&gt; on a table with more than a few thousand rows, and a high &lt;code&gt;Buffers: shared read&lt;/code&gt; count relative to &lt;code&gt;shared hit&lt;/code&gt; (that's disk I/O, not cache — expensive). If you see a sequential scan on a filtered, frequently-run query, that's almost always a missing index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_status&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&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;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&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;CONCURRENTLY&lt;/code&gt; matters here — it builds the index without taking a lock that blocks writes to the table, which is the difference between a routine change and a production incident on a busy table. It takes longer to build, but that's the trade you want.&lt;/p&gt;

&lt;p&gt;Re-run the &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; afterward and compare the &lt;code&gt;Execution Time&lt;/code&gt; line before and after. On a table with meaningful row counts, going from a sequential scan to an index scan on a filtered query commonly drops execution time by one to two orders of magnitude — a 200ms query landing under 2ms is a normal outcome, not an exceptional one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep it honest over time
&lt;/h2&gt;

&lt;p&gt;Stats accumulate from the moment the extension was enabled (or last reset), so a query that was fixed six months ago still shows up with its historical totals unless you clear them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements_reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that after you deploy a fix, then re-check the top-10 list a day later to confirm the query actually dropped out of the rankings — not just that the index exists, but that it's the one Postgres's planner is choosing to use. A missing &lt;code&gt;ANALYZE&lt;/code&gt; on the table after a large data change can leave the planner working from stale statistics and ignoring a perfectly good new index.&lt;/p&gt;

&lt;p&gt;If you want this to stay a five-minute weekly habit instead of a one-time fire drill, save the two ranking queries above as a psql script or a saved query in whatever database GUI your team uses, and run it every Monday. The worst query in your database changes as your data grows and your traffic patterns shift — the fifteen minutes you spend today buys you a habit, not just one fix.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>performance</category>
      <category>database</category>
      <category>sql</category>
    </item>
    <item>
      <title>Mem0 vs Zep vs LangChain Memory vs Letta: Picking the Right Agent Memory Architecture</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Wed, 05 Aug 2026 18:48:09 +0000</pubDate>
      <link>https://dev.to/mukesh_13/mem0-vs-zep-vs-langchain-memory-vs-letta-picking-the-right-agent-memory-architecture-2o1b</link>
      <guid>https://dev.to/mukesh_13/mem0-vs-zep-vs-langchain-memory-vs-letta-picking-the-right-agent-memory-architecture-2o1b</guid>
      <description>&lt;p&gt;Every agent framework now ships something called "memory," and every vendor's landing page implies theirs is the only real one. They're not the same thing. Four tools currently compete for the same slot in an agent's stack — Mem0, Zep, LangChain's built-in memory classes, and Letta (formerly MemGPT) — and they solve different problems with different architectures. Pick the wrong one and you either over-engineer a simple chatbot or hit a wall the moment your agent needs to run for more than a single session.&lt;/p&gt;

&lt;p&gt;I've run all four in production-adjacent projects over the past few months. Here's what actually differs, with the tradeoffs vendors don't put in the hero section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mem0: a fact store with an opinionated write path
&lt;/h2&gt;

&lt;p&gt;Mem0's pitch is "just call &lt;code&gt;add()&lt;/code&gt;, memory figures itself out." That's mostly true. Every &lt;code&gt;add()&lt;/code&gt; runs an LLM pass that extracts discrete facts from the input, checks them against existing memories via vector similarity, and issues &lt;code&gt;ADD&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, or &lt;code&gt;NONE&lt;/code&gt; for each one:&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;mem0&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Memory&lt;/span&gt;

&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Memory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;m allergic to shellfish&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_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;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Actually I can eat shrimp now, just not crab&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_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;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;what can alice eat?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_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;alice&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;The win is that you don't hand-write merge logic — a real time sink in any long-lived agent. The cost is that every write is itself an LLM call, so ingestion has both latency and per-write token cost, and the conflict resolver runs on text similarity, not on your app's notion of scope. It can't distinguish "the user changed their mind" from "the user has two preferences that hold in different contexts," and when it gets that wrong it silently deletes, no warning. Mem0 wins when your access pattern is "what does this user prefer/believe right now" and you're fine paying an LLM call per write for that convenience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zep: temporal graph, not a fact list
&lt;/h2&gt;

&lt;p&gt;Zep (via its Graphiti engine) doesn't store facts as flat rows — it builds a temporal knowledge graph where every edge carries validity windows. That's the actual differentiator: you can ask not just "what does alice believe" but "what did alice believe last month, and when did that change."&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;zep_cloud.client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Zep&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Zep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&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="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_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;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Alice moved from the platform team to infra in March.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_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;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;what team is alice on now vs six months ago&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;This is the right tool when your agent reasons about &lt;em&gt;change over time&lt;/em&gt; — support agents tracking account history, sales agents tracking deal evolution, anything where "when" matters as much as "what." The cost is real infrastructure complexity: you're running graph queries where a simpler agent just needed the last known value, and the mental model (nodes, edges, temporal invalidation) is a genuine onboarding cost for a team that only needed a preference lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  LangChain memory: free until it isn't
&lt;/h2&gt;

&lt;p&gt;If you're already building on LangChain, &lt;code&gt;ConversationSummaryBufferMemory&lt;/code&gt; or &lt;code&gt;VectorStoreRetrieverMemory&lt;/code&gt; costs you nothing extra to wire up — no new service, no new API key, no new bill:&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;langchain.memory&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ConversationSummaryBufferMemory&lt;/span&gt;

&lt;span class="n"&gt;memory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConversationSummaryBufferMemory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_token_limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save_context&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&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;I prefer terse code reviews&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&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;Noted.&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;That zero-marginal-cost is exactly why it's the wrong default for anything beyond a prototype. There's no first-party persistence layer, no multi-tenant isolation (you're rolling your own keying scheme per user), and no retrieval quality guarantees beyond whatever vector store you bolt on yourself. Worse, it couples your memory layer to your orchestration framework — if you ever want to leave LangChain, you're rewriting memory and orchestration at the same time instead of one at a time. Use it for demos and internal tools you'll throw away. Don't let it become the memory layer of something you plan to operate for a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Letta: the agent manages its own memory
&lt;/h2&gt;

&lt;p&gt;Letta takes a completely different stance: instead of an external service the agent calls, memory is part of the agent's own runtime. The agent has editable "core memory" blocks that sit permanently in its context window, plus "archival memory" it can page in and out on its own initiative — it decides what to forget and what to promote, via tool calls it makes to 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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;letta_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Letta&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Letta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8283&lt;/span&gt;&lt;span class="sh"&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="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;memory_blocks&lt;/span&gt;&lt;span class="o"&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;label&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;human&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;value&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;Name: Alice. Role: infra.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai/gpt-4o-mini&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;This is the right architecture for genuinely long-horizon autonomous agents — ones that run for days, accumulate context that would blow any context window, and need to actively curate what they keep. It is the wrong architecture for a request-scoped chatbot: you're running a stateful agent process instead of making a stateless API call, and that's real operational weight (process lifecycle, storage for agent state, no simple "just query the facts" endpoint from outside the agent).&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual decision
&lt;/h2&gt;

&lt;p&gt;Stop asking "which memory tool is best" — ask which question your agent needs answered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"What does this user currently prefer?"&lt;/strong&gt; → Mem0. Accept the per-write LLM cost for the convenience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"How did this relationship or fact change over time?"&lt;/strong&gt; → Zep. Accept the graph-infrastructure overhead for temporal queries you can't fake with a flat store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"I'm prototyping inside LangChain and will decide on production memory later"&lt;/strong&gt; → its built-in classes are fine, but budget time to rip them out before you ship.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"My agent needs to manage its own understanding across a long-running session"&lt;/strong&gt; → Letta. Accept the stateful-process overhead because you need the agent, not just an API, doing the curating.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These aren't mutually exclusive. A support-triage agent I built uses Mem0 for per-customer preference lookup and a Letta-style self-managed core memory for the agent's own running theory of the current incident — two different memory problems, correctly solved by two different tools instead of forcing one system to do both badly.&lt;/p&gt;

</description>
      <category>mem0</category>
      <category>aiagents</category>
      <category>llmops</category>
      <category>python</category>
    </item>
    <item>
      <title>Building a CircleCI Orb From Scratch: Test It Locally Before You Ever Publish</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Mon, 03 Aug 2026 19:04:00 +0000</pubDate>
      <link>https://dev.to/mukesh_13/building-a-circleci-orb-from-scratch-test-it-locally-before-you-ever-publish-cln</link>
      <guid>https://dev.to/mukesh_13/building-a-circleci-orb-from-scratch-test-it-locally-before-you-ever-publish-cln</guid>
      <description>&lt;p&gt;Most teams that use CircleCI never write an orb — they copy-paste the same &lt;code&gt;run&lt;/code&gt; steps across every &lt;code&gt;.circleci/config.yml&lt;/code&gt; in the org, and when the deploy script needs a fix, they edit it in twelve repos one at a time. An orb turns that copy-pasted YAML into a versioned, testable package you &lt;code&gt;import&lt;/code&gt; with one line. The blocker isn't the concept, it's that the docs jump straight to "publish to the registry," which means your first feedback loop is a real publish, a real version bump, and a real pipeline run just to catch a typo in a parameter name. That loop is slow enough that most people give up after the second failed publish.&lt;/p&gt;

&lt;p&gt;This walkthrough builds a small but real orb — one that posts a deploy notification to Slack with the git SHA, branch, and pipeline URL — and gets you to a fully working, locally-validated package before you ever touch the registry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project layout
&lt;/h2&gt;

&lt;p&gt;CircleCI orbs are just YAML, organized into &lt;code&gt;src/&lt;/code&gt; so the CLI can pack them into a single distributable file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;slack-deploy-notify/
├── src/
│   ├── @orb.yml
│   ├── commands/
│   │   └── notify.yml
│   ├── jobs/
│   │   └── announce.yml
│   └── examples/
│       └── basic_usage.yml
└── test-deploy/
    └── .circleci/
        └── config.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@orb.yml&lt;/code&gt; is the entry point that stitches the pieces together. Install the CLI first, since every step below depends on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fLSs&lt;/span&gt; https://raw.githubusercontent.com/CircleCI-Public/circleci-cli/master/install.sh | bash
circleci update
circleci setup   &lt;span class="c"&gt;# paste a personal API token when prompted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Writing the command
&lt;/h2&gt;

&lt;p&gt;A command is the reusable unit — a named, parameterized set of steps other jobs can call. Here's &lt;code&gt;src/commands/notify.yml&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;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="s"&gt;Posts a deploy notification to a Slack webhook, including the branch,&lt;/span&gt;
  &lt;span class="s"&gt;commit SHA, and a link back to the CircleCI pipeline.&lt;/span&gt;

&lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;webhook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env_var_name&lt;/span&gt;
    &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SLACK_DEPLOY_WEBHOOK&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Name of the env var holding the Slack incoming webhook URL.&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
    &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Deploy&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;started"&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Text to prefix the notification with.&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;run&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;Notify Slack of deploy&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="s"&gt;PAYLOAD=$(cat &amp;lt;&amp;lt;EOF&lt;/span&gt;
        &lt;span class="s"&gt;{&lt;/span&gt;
          &lt;span class="s"&gt;"text": "&amp;lt;&amp;lt; parameters.message &amp;gt;&amp;gt; — \`${CIRCLE_BRANCH}\` @ \`${CIRCLE_SHA1:0:7}\` — &amp;lt;${CIRCLE_BUILD_URL}|view pipeline&amp;gt;"&lt;/span&gt;
        &lt;span class="s"&gt;}&lt;/span&gt;
        &lt;span class="s"&gt;EOF&lt;/span&gt;
        &lt;span class="s"&gt;)&lt;/span&gt;
        &lt;span class="s"&gt;curl -sf -X POST -H 'Content-Type: application/json' \&lt;/span&gt;
          &lt;span class="s"&gt;-d "$PAYLOAD" "${!parameters.webhook}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;env_var_name&lt;/code&gt; parameter type is the detail people miss: it lets the caller point at &lt;em&gt;whichever&lt;/em&gt; env var holds their secret, instead of you hardcoding &lt;code&gt;SLACK_DEPLOY_WEBHOOK&lt;/code&gt; and forcing every consumer to name their context variable exactly that. &lt;code&gt;${!parameters.webhook}&lt;/code&gt; does the indirection — bash resolves the parameter to a name, then dereferences that name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the job
&lt;/h2&gt;

&lt;p&gt;A job wraps the command in an executor. &lt;code&gt;src/jobs/announce.yml&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;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Runs the Slack deploy notification as a standalone job.&lt;/span&gt;

&lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
    &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Deploy&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;started"&lt;/span&gt;

&lt;span class="na"&gt;executor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&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;slack-deploy-notify/notify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;&amp;lt; parameters.message &amp;gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the executor plus orb entry point, &lt;code&gt;src/@orb.yml&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;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2.1&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Post deploy notifications to Slack with git context baked in.&lt;/span&gt;

&lt;span class="na"&gt;executors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;docker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;cimg/base:2026.06&lt;/span&gt;

&lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;&amp;lt; include(commands/notify.yml) &amp;gt;&amp;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;announce&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;&amp;lt; include(jobs/announce.yml) &amp;gt;&amp;gt;&lt;/span&gt;

&lt;span class="na"&gt;examples&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;basic_usage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;&amp;lt; include(examples/basic_usage.yml) &amp;gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(In a real repo you write full YAML in each file rather than a literal &lt;code&gt;include()&lt;/code&gt; macro — the CLI's &lt;code&gt;orb pack&lt;/code&gt; command does this stitching for you at pack time, shown next.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Packing and validating — before you publish anything
&lt;/h2&gt;

&lt;p&gt;This is the step that actually shortens the feedback loop. Pack the &lt;code&gt;src/&lt;/code&gt; tree into one file and validate it locally, with zero network calls to the registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;circleci orb pack src &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; orb.yml
circleci orb validate orb.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;validate&lt;/code&gt; catches the errors that would otherwise only surface as a failed publish: malformed parameter types, a job referencing an executor that doesn't exist, YAML anchors that don't resolve. Run this after every edit — it takes under a second and it's the reason you don't need a real pipeline run to catch a typo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it against a real pipeline without publishing
&lt;/h2&gt;

&lt;p&gt;Validation confirms the YAML is well-formed; it does not confirm the &lt;em&gt;steps&lt;/em&gt; actually work. For that you need a real CircleCI run, but you still don't need the public registry — publish to a &lt;strong&gt;dev&lt;/strong&gt; release, which is namespaced, ephemeral, and meant for exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;circleci orb publish orb.yml your-namespace/slack-deploy-notify@dev:first-try
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dev releases expire after 90 days and don't count against your orb's public version history, so you can publish as many &lt;code&gt;@dev:*&lt;/code&gt; tags as you need while iterating. Point a scratch repo's config at it:&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="c1"&gt;# test-deploy/.circleci/config.yml&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2.1&lt;/span&gt;

&lt;span class="na"&gt;orbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-namespace/slack-deploy-notify@dev:first-try&lt;/span&gt;

&lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test-notify&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;notify/announce&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Testing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;orb"&lt;/span&gt;
          &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;slack-secrets&lt;/span&gt;   &lt;span class="c1"&gt;# holds SLACK_DEPLOY_WEBHOOK&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push that, watch the job run in the CircleCI UI, and confirm the Slack message lands with the right branch and SHA. Iterate by re-running &lt;code&gt;orb pack&lt;/code&gt; + &lt;code&gt;orb publish ... @dev:first-try&lt;/code&gt; (same tag overwrites) and re-triggering the pipeline — no version bump needed until the orb actually works end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Promoting to a real version
&lt;/h2&gt;

&lt;p&gt;Once the dev release behaves, cut a semantic version. Orbs follow strict semver and each tag is immutable once published — you can't overwrite &lt;code&gt;1.0.0&lt;/code&gt;, only publish &lt;code&gt;1.0.1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;circleci orb publish orb.yml your-namespace/slack-deploy-notify@1.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unlisted namespaces require a one-time &lt;code&gt;circleci namespace create&lt;/code&gt; and &lt;code&gt;circleci orb create&lt;/code&gt; before the first publish; if your org already publishes other orbs, skip straight to &lt;code&gt;orb publish&lt;/code&gt;. Update the scratch config to &lt;code&gt;your-namespace/slack-deploy-notify@1.0.0&lt;/code&gt;, confirm it still runs, and you now have a versioned dependency instead of duplicated YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two mistakes that waste the most time
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Treating &lt;code&gt;orb validate&lt;/code&gt; as sufficient.&lt;/strong&gt; It only checks structure, not runtime behavior — a command that references &lt;code&gt;&amp;lt;&amp;lt; parameters.webhook &amp;gt;&amp;gt;&lt;/code&gt; correctly but resolves to an empty env var still validates cleanly and then fails silently in &lt;code&gt;curl&lt;/code&gt;. Always run the dev-release pipeline test before cutting a version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Publishing to a public version tag while iterating.&lt;/strong&gt; Public orb versions are immutable and visible in the registry's version history the moment they're published — including to anyone who's already pinned an earlier tag and gets a surprise on next fetch if you're sloppy about what "iterating" means. &lt;code&gt;@dev:*&lt;/code&gt; tags exist precisely so your rough drafts never touch that history. If you find yourself about to publish &lt;code&gt;1.0.1&lt;/code&gt; just to fix a typo you noticed thirty seconds after &lt;code&gt;1.0.0&lt;/code&gt;, that's the signal you skipped the dev-release loop.&lt;/p&gt;

&lt;p&gt;With this pattern — pack, validate, dev-publish, test against a scratch pipeline, then version — you get the same fast inner loop you'd expect from testing any other piece of code, instead of treating "publish to CircleCI" as your only feedback signal.&lt;/p&gt;

</description>
      <category>circleci</category>
      <category>cicd</category>
      <category>devops</category>
      <category>yaml</category>
    </item>
    <item>
      <title>Self-Healing Instance Pools on Vultr: A Reconciler Loop for Solo Devs Without Kubernetes</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Mon, 03 Aug 2026 08:19:53 +0000</pubDate>
      <link>https://dev.to/mukesh_13/self-healing-instance-pools-on-vultr-a-reconciler-loop-for-solo-devs-without-kubernetes-4k70</link>
      <guid>https://dev.to/mukesh_13/self-healing-instance-pools-on-vultr-a-reconciler-loop-for-solo-devs-without-kubernetes-4k70</guid>
      <description>&lt;p&gt;Managed Kubernetes gives you self-healing for free: a node dies, the scheduler notices, a replacement pod comes up elsewhere. If you're running a handful of Vultr instances behind a load balancer — a Postgres read replica, a fleet of API workers, a scraping farm — you don't have a scheduler watching your back. You have a pager going off at 3am because one instance quietly wedged itself and nginx kept routing traffic to it anyway.&lt;/p&gt;

&lt;p&gt;You don't need Kubernetes to fix this. You need a reconciler: a small loop that knows what "healthy" means for your app, checks it on a schedule, and replaces anything that fails the check. This is the same pattern k8s uses internally, minus the YAML. Here's how to build one against the Vultr API in about 150 lines of Python.&lt;/p&gt;

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

&lt;p&gt;A self-healing pool has four moving parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A health contract&lt;/strong&gt; — an endpoint each instance exposes that tells you, definitively, whether it's fit to serve traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reconciler&lt;/strong&gt; — a process that polls instance health, decides when "unhealthy" becomes "replace it," and acts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast replacement&lt;/strong&gt; — a way to bring a new instance online in under a minute, not the ten-plus minutes a from-scratch cloud-init run usually takes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traffic control&lt;/strong&gt; — removing a dying instance from rotation before it gets replaced, and adding the new one back in after.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Vultr gives you the primitives for all four: the Instances API, snapshots, tags, and Load Balancers. The gap is the glue code, which is what we're writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Define health precisely
&lt;/h2&gt;

&lt;p&gt;Don't reuse your load balancer's health check for this. LB checks are usually "does port 443 respond," which misses an app that's up but stuck (e.g., a worker whose event loop is blocked, or a Postgres replica that's fallen behind). Expose a dedicated endpoint that checks the things that actually matter for that instance's role:&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="c1"&gt;# app/healthz.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonify&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psutil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;START&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/healthz&lt;/span&gt;&lt;span class="sh"&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;healthz&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;checks&lt;/span&gt; &lt;span class="o"&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;uptime_ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;START&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cpu_ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;psutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cpu_percent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disk_ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;psutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disk_usage&lt;/span&gt;&lt;span class="p"&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="n"&gt;percent&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queue_lag_ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;get_queue_lag_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# app-specific
&lt;/span&gt;    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;healthy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;healthy&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reconciler treats a non-200 (or a timeout) as a strike. This separation matters: your LB decides routing on a fast, shallow check; the reconciler decides replacement on a slower, deeper one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Tag the pool
&lt;/h2&gt;

&lt;p&gt;Tag every instance in the pool so the reconciler can discover membership without a separate inventory file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.vultr.com/v2/instances"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$VULTR_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "region": "ewr", "plan": "vc2-2c-4gb",
    "snapshot_id": "'&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WEB_SNAPSHOT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'",
    "label": "web-pool-1",
    "tag": "pool:web"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;snapshot_id&lt;/code&gt; instead of &lt;code&gt;os_id&lt;/code&gt;. Booting from a pre-baked snapshot — your app, dependencies, and systemd units already installed — is the single biggest lever for fast replacement. A snapshot boot is typically running and passing health checks in 30-60 seconds; a fresh OS install plus cloud-init provisioning is 5-15 minutes. Bake a new snapshot as part of your deploy pipeline, not as an afterthought during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The reconciler loop
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="n"&gt;VULTR_API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.vultr.com/v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&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;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&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="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;VULTR_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&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;STRIKE_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;       &lt;span class="c1"&gt;# consecutive failures before replacement
&lt;/span&gt;&lt;span class="n"&gt;COOLDOWN_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;     &lt;span class="c1"&gt;# min gap between replacements per pool
&lt;/span&gt;&lt;span class="n"&gt;MAX_CONCURRENT_REPLACE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="c1"&gt;# never replace more than N at once
&lt;/span&gt;
&lt;span class="n"&gt;strikes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="n"&gt;last_replacement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_pool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pool:web&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;VULTR_API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/instances&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&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;tag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;json&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;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instances&lt;/span&gt;&lt;span class="sh"&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;is_healthy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance&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="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&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="s"&gt;http://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;main_ip&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:8080/healthz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestException&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;replace_instance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;lb_deregister&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;                 &lt;span class="c1"&gt;# step 4
&lt;/span&gt;    &lt;span class="n"&gt;snap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WEB_SNAPSHOT_ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;VULTR_API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/instances&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&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;region&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&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;plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan&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;snapshot_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&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;tag&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;pool:web&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;VULTR_API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/instances/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&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;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&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;reconcile&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;last_replacement&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_replacement&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;COOLDOWN_SECONDS&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;for&lt;/span&gt; &lt;span class="n"&gt;inst&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;list_pool&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;iid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_healthy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;strikes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;iid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;strikes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;iid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strikes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iid&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="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;strikes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;iid&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;STRIKE_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;replace_instance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;last_replacement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;strikes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;iid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;  &lt;span class="c1"&gt;# respects MAX_CONCURRENT_REPLACE=1
&lt;/span&gt;
&lt;span class="k"&gt;while&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;reconcile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this as a systemd unit on a small always-on control instance — not inside the pool it's watching, or a bad reconciler bug can strand the whole fleet with no one left to fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Deregister before you destroy
&lt;/h2&gt;

&lt;p&gt;The order matters. If you delete the instance before pulling it from the load balancer, in-flight requests get connection resets instead of a clean 503-and-retry. Deregister first, drain briefly, then delete:&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;lb_deregister&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;lb_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VULTR_LB_ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;lb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;VULTR_API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/load-balancers/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lb_id&lt;/span&gt;&lt;span class="si"&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;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;forwarding_rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;load_balancer&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;forwarding_rules&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;# Vultr LBs target instances by attachment, not per-instance toggling —
&lt;/span&gt;    &lt;span class="c1"&gt;# detach via the instance's load-balancer association endpoint
&lt;/span&gt;    &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;VULTR_API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/load-balancers/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lb_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/instances/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="si"&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;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# let in-flight connections finish draining
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: Guard against flapping and thundering herds
&lt;/h2&gt;

&lt;p&gt;Three failure modes will bite you if you skip them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flapping&lt;/strong&gt;: a transient blip (a slow GC pause, a deploy restart) trips the strike counter and you replace a perfectly good instance. The &lt;code&gt;STRIKE_THRESHOLD&lt;/code&gt; of 3 consecutive failures over 20-second polls (~60 seconds sustained) filters this out — tune it against your app's actual worst-case pause time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cascading replacement&lt;/strong&gt;: if a shared dependency (your database) goes down, every instance fails health checks simultaneously, and a naive reconciler tries to replace the entire pool at once — burning your Vultr API rate limit and your bill. &lt;code&gt;MAX_CONCURRENT_REPLACE&lt;/code&gt; and the global &lt;code&gt;COOLDOWN_SECONDS&lt;/code&gt; cap the blast radius.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split-brain reconcilers&lt;/strong&gt;: if you ever run more than one reconciler instance for redundancy, use a lease (a row in Postgres with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;, or a Vultr object storage object with a conditional PUT) so only one reconciler acts at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: Prove it works before you need it
&lt;/h2&gt;

&lt;p&gt;Don't wait for a real outage to find out your reconciler has a bug. Inject failure on purpose:&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;# SSH into a pool instance and simulate a wedge&lt;/span&gt;
ssh web-pool-1 &lt;span class="s1"&gt;'sudo iptables -A INPUT -p tcp --dport 8080 -j DROP'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch the reconciler logs: strikes should accumulate, a replacement instance should appear in the Vultr dashboard within a couple minutes, and the load balancer's target list should update without a gap. Then revert the iptables rule and confirm the old instance — now healthy again but no longer tagged into rotation — gets cleaned up rather than lingering as a zombie you're still paying for.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to stop doing this yourself
&lt;/h2&gt;

&lt;p&gt;This pattern comfortably handles pools of 2-15 instances with one workload type. Past that, or once you need bin-packing across heterogeneous workloads, rolling deploys with canary percentages, or multi-region failover orchestration, you've outgrown a hand-rolled reconciler and it's time for k3s or full Kubernetes. But for the common case — a solo developer or small team running a handful of Vultr boxes who just wants "if it breaks, it fixes itself" — this gets you there for the cost of one small control-plane instance and an afternoon of writing Python, instead of a cluster you now have to operate.&lt;/p&gt;

</description>
      <category>vultr</category>
      <category>infrastructure</category>
      <category>devops</category>
      <category>highavailability</category>
    </item>
    <item>
      <title>Smarter Test Splitting in CircleCI: Balancing Parallel Containers with Real Timing Data Instead of Guesswork</title>
      <dc:creator>Mukesh</dc:creator>
      <pubDate>Mon, 03 Aug 2026 08:19:16 +0000</pubDate>
      <link>https://dev.to/mukesh_13/smarter-test-splitting-in-circleci-balancing-parallel-containers-with-real-timing-data-instead-of-p7g</link>
      <guid>https://dev.to/mukesh_13/smarter-test-splitting-in-circleci-balancing-parallel-containers-with-real-timing-data-instead-of-p7g</guid>
      <description>&lt;p&gt;Most teams turn on &lt;code&gt;parallelism&lt;/code&gt; in CircleCI, add &lt;code&gt;circleci tests split&lt;/code&gt;, and assume the test-speed problem is solved. It usually isn't. The default split mode divides test files by name or count, which produces wildly uneven containers: one runs three seconds of smoke tests, another draws the 400-file integration suite that takes six minutes. Your slowest container sets the wall-clock time for the whole job — so an unbalanced split means you're paying for N containers but only getting the speedup of the busiest one.&lt;/p&gt;

&lt;p&gt;CircleCI actually ships a fix for this: &lt;code&gt;circleci tests split --split-by=timings&lt;/code&gt;, which balances containers by how long each test file actually took last time, not by file count or alphabetical order. Almost nobody uses it correctly, because the setup has a cold-start problem, a data-freshness problem, and a monorepo-namespacing problem the docs gloss over. Here's how to actually run it in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why file-count splitting lies to you
&lt;/h2&gt;

&lt;p&gt;Say you have &lt;code&gt;parallelism: 4&lt;/code&gt; and 200 test files, split alphabetically. Container 0 might get &lt;code&gt;test_a.py&lt;/code&gt; through &lt;code&gt;test_apple_pay.py&lt;/code&gt; — mostly checkout logic — while container 3 gets &lt;code&gt;test_webhook_replay.py&lt;/code&gt;, &lt;code&gt;test_worker_pool.py&lt;/code&gt;, and the rest of your slow integration coverage. CircleCI reports "4x parallelism," but your CI feedback loop is bottlenecked by whichever container drew the short straw. Teams "fix" this by manually moving files between glob patterns, which drifts out of sync within a month as the suite grows.&lt;/p&gt;

&lt;p&gt;Splitting by timing data solves this directly: CircleCI redistributes files so each container's total historical runtime is roughly equal, not its file count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feeding CircleCI real timing data
&lt;/h2&gt;

&lt;p&gt;Timing-based splitting isn't magic — it depends on JUnit XML test reports from a previous run of the same job name, uploaded via &lt;code&gt;store_test_results&lt;/code&gt;. Without that step, &lt;code&gt;--split-by=timings&lt;/code&gt; silently falls back to filename splitting, and you'll never know it happened.&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="c1"&gt;# .circleci/config.yml&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;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;docker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;cimg/node:20.11&lt;/span&gt;
    &lt;span class="na"&gt;parallelism&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8&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="s"&gt;checkout&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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests on this container's shard&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="s"&gt;TESTFILES=$(circleci tests glob "test/**/*.test.js" | \&lt;/span&gt;
              &lt;span class="s"&gt;circleci tests split --split-by=timings --timings-type=filename)&lt;/span&gt;
            &lt;span class="s"&gt;npx jest --ci --reporters=default --reporters=jest-junit $TESTFILES&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;store_test_results&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;reports/junit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters: &lt;code&gt;store_test_results&lt;/code&gt; has to run on every job execution, because CircleCI's Insights API is what &lt;code&gt;--split-by=timings&lt;/code&gt; queries under the hood. Each successful run feeds the next run's split decision — it's a rolling feedback loop, not a one-time calibration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cold-start problem
&lt;/h2&gt;

&lt;p&gt;The first time you turn this on, there's no timing history for the job name, so every file gets an equal default weight and you're back to a naive split. That's expected — don't panic and assume the flag is broken. What actually breaks things is doing this on a job you just renamed. CircleCI keys timing history by job name, so renaming &lt;code&gt;test&lt;/code&gt; to &lt;code&gt;test_unit&lt;/code&gt; resets your history to zero, and you'll silently get a bad split for a few runs while it re-learns.&lt;/p&gt;

&lt;p&gt;If you can't tolerate a few uneven runs (e.g., a very large monorepo suite), seed the history manually: run the full suite once locally or in a throwaway pipeline, collect the JUnit XML, and push it through &lt;code&gt;store_test_results&lt;/code&gt; on a dummy commit before switching real PRs onto the split job. It's a few minutes of setup that skips a week of your team wondering why "the fast CI feature" isn't fast yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  New test files get the average, not zero
&lt;/h2&gt;

&lt;p&gt;A file &lt;code&gt;store_test_results&lt;/code&gt; has never seen (a test you added this morning) doesn't get scheduled first-come-first-served — CircleCI assigns it the average duration of all known files via &lt;code&gt;--time-default=&amp;lt;seconds&amp;gt;&lt;/code&gt; (default: median of the observed set). This is usually fine, but it has a specific failure mode: if you add ten new, slow integration test files in one PR, they all land on the container with the most remaining capacity based on old data — and since CircleCI doesn't know they're slow yet, several new files can pile onto the same container and blow past your fastest container's time. This self-corrects after one run feeds the new timings back in, but if you're shipping a large batch of new slow tests, consider bumping &lt;code&gt;parallelism&lt;/code&gt; for that one PR or manually spreading the new files across job invocations until the timing data catches up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Namespacing timing data in a monorepo
&lt;/h2&gt;

&lt;p&gt;If you run the same job definition for multiple services — say a shared &lt;code&gt;test&lt;/code&gt; job template invoked once per service via matrix parameters — CircleCI's timing history is keyed by job name within the pipeline, not by which files you happened to pass in. Two services sharing a job name will pollute each other's timing data: the &lt;code&gt;billing&lt;/code&gt; service's genuinely slow tests will skew the "average" applied to &lt;code&gt;notifications&lt;/code&gt;' new files, because CircleCI can't tell the difference.&lt;/p&gt;

&lt;p&gt;The fix is to make the job name service-specific rather than relying on parameters alone:&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;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test-all&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;test&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;test-billing&lt;/span&gt;
          &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;billing&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;test&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;test-notifications&lt;/span&gt;
          &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;notifications&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;name:&lt;/code&gt; on the job invocation (not just passing &lt;code&gt;service&lt;/code&gt; as a parameter) gives each service its own timing bucket in Insights. This is a one-line change a lot of teams miss, and it's the difference between timing-based splitting actually working per-service versus quietly reverting to noisy, cross-contaminated averages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling flaky-test reruns skewing your data
&lt;/h2&gt;

&lt;p&gt;If you use CircleCI's automatic test re-run on failure, or a custom retry wrapper, be careful: a flaky test that gets retried three times before passing reports as three test executions in your JUnit XML, and depending on your test runner's reporter, that can inflate the recorded duration for that file by 3x. Over a few weeks, flaky tests silently become "slow" tests in the timing model and get over-weighted in the split, even though their real runtime (ignoring retries) is short. If your framework's JUnit reporter doesn't already dedupe reruns, filter the XML before &lt;code&gt;store_test_results&lt;/code&gt; runs, keeping only the final successful attempt's timing per test case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebalancing on a schedule, not just per-push
&lt;/h2&gt;

&lt;p&gt;Timing-based splitting adapts continuously, but only across pushes that actually run the job — a service with low commit frequency can carry stale timing data for weeks. If your team merges to a long-lived branch daily but only occasionally touches a given service, add a lightweight nightly job (&lt;code&gt;triggers: - schedule:&lt;/code&gt;) that runs the full suite for every service once a day purely to refresh the timing history, even if no one pushed code. It costs one extra full run a day and prevents the split from drifting badly out of balance for low-traffic services when a burst of commits finally does land.&lt;/p&gt;

&lt;p&gt;Timing-based splitting isn't a switch you flip once — it's a small feedback system you have to feed correctly (store results every run), name correctly (per-service job names), and monitor for drift (flaky rerun inflation, cold starts after renames). Get those three right and &lt;code&gt;parallelism: 8&lt;/code&gt; actually means "roughly 8x faster," not "8 containers, one of which decides how long you wait."&lt;/p&gt;

</description>
      <category>circleci</category>
      <category>cicd</category>
      <category>testing</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
