<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Python on Krishnatejaswi S</title>
    <link>https://KTS-o7.github.io/bear/tags/python/</link>
    <description>Recent content in Python on Krishnatejaswi S</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en-us</language>
    <managingEditor>shentharkrishnatejaswi@gmail.com (Krishnatejaswi S)</managingEditor>
    <webMaster>shentharkrishnatejaswi@gmail.com (Krishnatejaswi S)</webMaster>
    <copyright>Krishnatejaswi S</copyright>
    <lastBuildDate>Mon, 16 Feb 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://KTS-o7.github.io/bear/tags/python/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Hunting OOMKilled in Kubernetes: Four Memory Leaks That Almost Took Down Our Platform</title>
      <link>https://KTS-o7.github.io/bear/infra/kubernetes_oom_memory_leaks/</link>
      <pubDate>Mon, 16 Feb 2026 00:00:00 +0000</pubDate><author>shentharkrishnatejaswi@gmail.com (Krishnatejaswi S)</author>
      <guid>https://KTS-o7.github.io/bear/infra/kubernetes_oom_memory_leaks/</guid>
      <description>&lt;p&gt;A pod restarts at 3 AM. Then again at 3:14. By 3:30 it&amp;rsquo;s in a &lt;code&gt;CrashLoopBackOff&lt;/code&gt;. The dashboard shows memory climbing in a clean line - no spikes, no sudden jumps - just a slow, relentless climb until the OOM killer steps in.&lt;/p&gt;&#xA;&lt;p&gt;This is the story of how I tracked down four distinct memory leaks across two Python services running on Kubernetes. Each one was a different flavor of &amp;ldquo;silent accumulation,&amp;rdquo; and each one taught me something about how easy it is to bleed memory in async Python without any obvious errors.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>A pod restarts at 3 AM. Then again at 3:14. By 3:30 it&rsquo;s in a <code>CrashLoopBackOff</code>. The dashboard shows memory climbing in a clean line - no spikes, no sudden jumps - just a slow, relentless climb until the OOM killer steps in.</p>
<p>This is the story of how I tracked down four distinct memory leaks across two Python services running on Kubernetes. Each one was a different flavor of &ldquo;silent accumulation,&rdquo; and each one taught me something about how easy it is to bleed memory in async Python without any obvious errors.</p>
<p>No names. No product details. Just the bugs, the fixes, and the lessons.</p>
<h2 id="the-setup">The setup</h2>
<p>We had a multi-service platform running on Kubernetes. Two services are relevant here:</p>
<ul>
<li>
<p><strong>Service A</strong>: A document management service. It handled CRUD operations, search indexing, audit logging, and authorization precomputation. Built with FastAPI and async Python, backed by MongoDB and a vector store.</p>
</li>
<li>
<p><strong>Service B</strong>: A PDF processing service. It parsed documents, extracted text via OCR, and broke content into structured clauses. Heavy on PyMuPDF (fitz) for PDF rendering and Tesseract for OCR.</p>
</li>
</ul>
<p>Both services ran as single-container pods with resource limits. Service A had a 2Gi memory limit. Service B had 4Gi.</p>
<p>Both were getting OOMKilled. Regularly.</p>
<h2 id="the-symptoms">The symptoms</h2>
<p>Service A&rsquo;s memory graph looked like this:</p>





<pre tabindex="0"><code>Memory (MB)
2048 |                                              ╱ OOMKilled
     |                                           ╱
1536 |                                        ╱
     |                                     ╱
1024 |                                  ╱
     |                               ╱
 512 |                            ╱
     |                         ╱
   0 |________________________╱
     0h        6h        12h        18h        24h</code></pre><p>A steady climb at roughly 200MB per hour. No spikes. No correlation with traffic. Just a ramp.</p>
<p>Service B was different - it&rsquo;d spike during document processing batches and never come back down:</p>





<pre tabindex="0"><code>Memory (MB)
4096 |          ╱╲        ╱╲
     |        ╱   ╲     ╱   ╲    ╱ OOMKilled
3072 |      ╱      ╲  ╱      ╲ ╱
     |    ╱         ╲╱        ╱
2048 |  ╱                   ╱
     |╱                   ╱
1024 |                  ╱
     |               ╱
   0 |______________╱
     0h     2h     4h     6h     8h     10h</code></pre><p>Every batch pushed memory up. It came down a little, but never to baseline. Classic leak with GC partially reclaiming some objects but not the real offender.</p>
<h2 id="leak-1-the-coroutines-that-never-ran">Leak #1: The coroutines that never ran</h2>
<p>This was the most insidious one. It looked like perfectly normal Python code:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">class</span> <span class="nc">BaseDoc</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">def</span> <span class="nf">_write_audit_log</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">action</span><span class="p">,</span> <span class="n">old_data</span><span class="p">,</span> <span class="n">new_data</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="n">enqueue_audit_create</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">action</span><span class="p">,</span> <span class="n">old_data</span><span class="p">,</span> <span class="n">new_data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">async</span> <span class="k">def</span> <span class="nf">save</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="n">old</span> <span class="o">=</span> <span class="k">await</span> <span class="bp">self</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="n">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_write_audit_log</span><span class="p">(</span><span class="s2">&#34;update&#34;</span><span class="p">,</span> <span class="n">old</span><span class="p">,</span> <span class="n">result</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">await</span> <span class="n">create_snapshots</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">await</span> <span class="n">insert_versions_docs</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="n">result</span></span></span></code></pre></div><p>Spot the bug?</p>
<p><code>_write_audit_log</code> calls <code>enqueue_audit_create()</code>, which is an <strong>async function</strong>. But <code>_write_audit_log</code> is not async, and it doesn&rsquo;t <code>await</code> the call. It just calls it.</p>
<p>When you call an async function without <code>await</code>, Python creates a coroutine object but never schedules it. The coroutine sits in memory, holding references to all its arguments - in this case, entire document objects with <code>old_data</code> and <code>new_data</code>.</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Coroutine Object (0x7f...)
</span></span><span class="line"><span class="ln">2</span><span class="cl">  ├── State: CREATED (never scheduled)
</span></span><span class="line"><span class="ln">3</span><span class="cl">  └── Frame Object
</span></span><span class="line"><span class="ln">4</span><span class="cl">       ├── Arg: self (BaseDoc instance, 2KB)
</span></span><span class="line"><span class="ln">5</span><span class="cl">       ├── Arg: old_data (Dict, 5MB)  &lt;-- LEAKED
</span></span><span class="line"><span class="ln">6</span><span class="cl">       └── Arg: new_data (Dict, 5MB)  &lt;-- LEAKED
</span></span><span class="line"><span class="ln">7</span><span class="cl">       └── Local Vars: (captured closure scope)</span></span></code></pre></div><p>Python will eventually warn you about this (<code>RuntimeWarning: coroutine was never awaited</code>), but only if you have warnings enabled and you&rsquo;re looking at the right log. In a noisy production service running dozens of requests per second, these warnings get buried.</p>
<p>The same pattern appeared elsewhere:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># In the CRUD endpoints</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">create_document</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="k">await</span> <span class="n">Document</span><span class="o">.</span><span class="n">create</span><span class="p">(</span><span class="n">request</span><span class="o">.</span><span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">enqueue_audit_create</span><span class="p">(</span><span class="n">doc</span><span class="p">)</span>       <span class="c1"># ← async, not awaited</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">qdrant_upserts</span><span class="p">(</span><span class="n">doc</span><span class="p">)</span>             <span class="c1"># ← async, not awaited</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">return</span> <span class="n">doc</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">update_document</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="k">await</span> <span class="n">Document</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="n">request</span><span class="o">.</span><span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">enqueue_audit_update</span><span class="p">(</span><span class="n">doc</span><span class="p">)</span>       <span class="c1"># ← async, not awaited</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">qdrant_upserts</span><span class="p">(</span><span class="n">doc</span><span class="p">)</span>             <span class="c1"># ← async, not awaited</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="n">doc</span></span></span></code></pre></div><p>Each unawaited call created a coroutine holding the full document. For a service processing hundreds of documents per hour, that&rsquo;s hundreds of document-sized objects pinned in memory with no way to be garbage collected.</p>
<h3 id="the-fix">The fix</h3>
<p>Straightforward once you see it:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">class</span> <span class="nc">BaseDoc</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">async</span> <span class="k">def</span> <span class="nf">_write_audit_log</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">action</span><span class="p">,</span> <span class="n">old_data</span><span class="p">,</span> <span class="n">new_data</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">await</span> <span class="n">enqueue_audit_create</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">action</span><span class="p">,</span> <span class="n">old_data</span><span class="p">,</span> <span class="n">new_data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">async</span> <span class="k">def</span> <span class="nf">save</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="n">old</span> <span class="o">=</span> <span class="k">await</span> <span class="bp">self</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="n">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">await</span> <span class="bp">self</span><span class="o">.</span><span class="n">_write_audit_log</span><span class="p">(</span><span class="s2">&#34;update&#34;</span><span class="p">,</span> <span class="n">old</span><span class="p">,</span> <span class="n">result</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">await</span> <span class="n">create_snapshots</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">await</span> <span class="n">insert_versions_docs</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="n">result</span></span></span></code></pre></div><p>And in the CRUD endpoints:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">create_document</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="k">await</span> <span class="n">Document</span><span class="o">.</span><span class="n">create</span><span class="p">(</span><span class="n">request</span><span class="o">.</span><span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">await</span> <span class="n">enqueue_audit_create</span><span class="p">(</span><span class="n">doc</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">await</span> <span class="n">qdrant_upserts</span><span class="p">(</span><span class="n">doc</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">return</span> <span class="n">doc</span></span></span></code></pre></div><h3 id="the-lesson">The lesson</h3>
<p><strong>Unawaited coroutines are silent memory leaks.</strong> They don&rsquo;t crash. They don&rsquo;t raise. They just accumulate. And because they hold references to their arguments, they can keep large objects alive long after the request that created them is done.</p>
<p>If you&rsquo;re running async Python, add this to your linting:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># In your pytest conftest.py or startup</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kn">import</span> <span class="nn">warnings</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">warnings</span><span class="o">.</span><span class="n">filterwarnings</span><span class="p">(</span><span class="s2">&#34;error&#34;</span><span class="p">,</span> <span class="n">category</span><span class="o">=</span><span class="ne">RuntimeWarning</span><span class="p">,</span> <span class="n">message</span><span class="o">=</span><span class="s2">&#34;coroutine.*was never awaited&#34;</span><span class="p">)</span></span></span></code></pre></div><p>This turns the warning into an exception, which makes it impossible to miss.</p>
<h2 id="leak-2-creating-a-new-mongoclient-on-every-authorization-check">Leak #2: Creating a new MongoClient on every authorization check</h2>
<p>The authorization layer needed to precompute user access contexts. To do this, it queried MongoDB directly (not through the async ODM) using PyMongo&rsquo;s synchronous <code>MongoClient</code>.</p>
<p>The code looked like this:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">class</span> <span class="nc">AuthorizationPrecompute</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">def</span> <span class="nf">get_user_permissions</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">user_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="n">client</span> <span class="o">=</span> <span class="n">MongoClient</span><span class="p">(</span><span class="n">MONGO_URI</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="n">db</span> <span class="o">=</span> <span class="n">client</span><span class="p">[</span><span class="s2">&#34;main_db&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="n">permissions</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">db</span><span class="o">.</span><span class="n">permissions</span><span class="o">.</span><span class="n">find</span><span class="p">({</span><span class="s2">&#34;user&#34;</span><span class="p">:</span> <span class="n">user_id</span><span class="p">}))</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="n">client</span><span class="o">.</span><span class="n">close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="n">permissions</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">def</span> <span class="nf">get_role_policies</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">role_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="n">client</span> <span class="o">=</span> <span class="n">MongoClient</span><span class="p">(</span><span class="n">MONGO_URI</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="n">db</span> <span class="o">=</span> <span class="n">client</span><span class="p">[</span><span class="s2">&#34;main_db&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="n">policies</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">db</span><span class="o">.</span><span class="n">policies</span><span class="o">.</span><span class="n">find</span><span class="p">({</span><span class="s2">&#34;role&#34;</span><span class="p">:</span> <span class="n">role_id</span><span class="p">}))</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="n">client</span><span class="o">.</span><span class="n">close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">return</span> <span class="n">policies</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">def</span> <span class="nf">get_org_settings</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">org_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="n">client</span> <span class="o">=</span> <span class="n">MongoClient</span><span class="p">(</span><span class="n">MONGO_URI</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="n">db</span> <span class="o">=</span> <span class="n">client</span><span class="p">[</span><span class="s2">&#34;main_db&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="n">settings</span> <span class="o">=</span> <span class="n">db</span><span class="o">.</span><span class="n">settings</span><span class="o">.</span><span class="n">find_one</span><span class="p">({</span><span class="s2">&#34;org&#34;</span><span class="p">:</span> <span class="n">org_id</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="n">client</span><span class="o">.</span><span class="n">close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</span> <span class="n">settings</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="c1"># ... 10 more methods, each creating its own MongoClient</span></span></span></code></pre></div><p>Thirteen methods. Thirteen <code>MongoClient()</code> instantiations per authorization check.</p>
<p>Each <code>MongoClient</code> creates a connection pool, background SDAM monitoring threads, and internal caches. Even with <code>client.close()</code>, the cleanup isn&rsquo;t instant - Python&rsquo;s garbage collector has to reclaim the thread stacks, socket buffers, and internal data structures.</p>
<p>And if an exception happens between <code>MongoClient()</code> and <code>client.close()</code>, the client never gets closed at all. The connection pool lives on, holding threads and sockets until the GC eventually (maybe) collects them.</p>
<p>At 50 requests/second, each triggering an auth check, we were creating <strong>650 MongoClient instances per second</strong>. Most were closed, but the transient memory pressure and leaked exceptions meant we were always carrying a few hundred orphaned connection pools.</p>
<h3 id="the-fix-1">The fix</h3>
<p>One shared client with a connection pool:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">class</span> <span class="nc">AuthorizationPrecompute</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">_client</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nd">@classmethod</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">def</span> <span class="nf">_get_client</span><span class="p">(</span><span class="bp">cls</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">if</span> <span class="bp">cls</span><span class="o">.</span><span class="n">_client</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="bp">cls</span><span class="o">.</span><span class="n">_client</span> <span class="o">=</span> <span class="n">MongoClient</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                <span class="n">MONGO_URI</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                <span class="n">maxPoolSize</span><span class="o">=</span><span class="mi">20</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">                <span class="n">serverSelectionTimeoutMS</span><span class="o">=</span><span class="mi">5000</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">_client</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">def</span> <span class="nf">get_user_permissions</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">user_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="n">db</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_get_client</span><span class="p">()[</span><span class="s2">&#34;main_db&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">return</span> <span class="nb">list</span><span class="p">(</span><span class="n">db</span><span class="o">.</span><span class="n">permissions</span><span class="o">.</span><span class="n">find</span><span class="p">({</span><span class="s2">&#34;user&#34;</span><span class="p">:</span> <span class="n">user_id</span><span class="p">}))</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">def</span> <span class="nf">get_role_policies</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">role_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="n">db</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_get_client</span><span class="p">()[</span><span class="s2">&#34;main_db&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="k">return</span> <span class="nb">list</span><span class="p">(</span><span class="n">db</span><span class="o">.</span><span class="n">policies</span><span class="o">.</span><span class="n">find</span><span class="p">({</span><span class="s2">&#34;role&#34;</span><span class="p">:</span> <span class="n">role_id</span><span class="p">}))</span></span></span></code></pre></div><p>Connection overhead went from ~50ms per call (TCP handshake + auth each time) to ~5ms (reused connection from pool).</p>
<h3 id="the-lesson-1">The lesson</h3>
<p><strong>Each <code>MongoClient</code> is an entire connection pool, not a single connection.</strong> Creating one per function call is like starting a new database server for every query. Always share a single client instance (or at most one per thread/worker). And if you can&rsquo;t guarantee <code>close()</code> will be called (i.e., exceptions exist), you definitely need a shared instance.</p>
<h2 id="leak-3-cache-entries-that-never-expire">Leak #3: Cache entries that never expire</h2>
<p>The authorization layer also had an in-memory cache for user access contexts:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">class</span> <span class="nc">UserAccessContextCache</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">ttl_seconds</span><span class="o">=</span><span class="mi">300</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_ttl</span> <span class="o">=</span> <span class="n">ttl_seconds</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">def</span> <span class="nf">get</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="n">entry</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">if</span> <span class="n">entry</span> <span class="ow">and</span> <span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">()</span> <span class="o">-</span> <span class="n">entry</span><span class="p">[</span><span class="s2">&#34;created&#34;</span><span class="p">]</span> <span class="o">&lt;</span> <span class="bp">self</span><span class="o">.</span><span class="n">_ttl</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="k">return</span> <span class="n">entry</span><span class="p">[</span><span class="s2">&#34;value&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">def</span> <span class="nf">set</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">,</span> <span class="n">value</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="s2">&#34;value&#34;</span><span class="p">:</span> <span class="n">value</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="s2">&#34;created&#34;</span><span class="p">:</span> <span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="p">}</span></span></span></code></pre></div><p>See the problem?</p>
<p>When <code>get()</code> finds an expired entry, it just returns <code>None</code>. It doesn&rsquo;t delete the entry. The stale data sits in the dict forever.</p>
<p>And <code>set()</code> only adds entries. Nothing ever removes them.</p>
<p>Over time, this cache grows without bound. Every unique user who hits the service gets a cache entry that is never cleaned up. The access context objects themselves can be large - they contain permission trees, role hierarchies, and org settings.</p>
<p>With 200MB of stale cache entries after a day of operation, this was a significant contributor to the memory ramp.</p>
<h3 id="the-fix-2">The fix</h3>
<p>Add proactive cleanup:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">class</span> <span class="nc">UserAccessContextCache</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">ttl_seconds</span><span class="o">=</span><span class="mi">300</span><span class="p">,</span> <span class="n">cleanup_interval</span><span class="o">=</span><span class="mi">60</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_ttl</span> <span class="o">=</span> <span class="n">ttl_seconds</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_cleanup_interval</span> <span class="o">=</span> <span class="n">cleanup_interval</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_last_cleanup</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">def</span> <span class="nf">_cleanup_expired</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">now</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">if</span> <span class="n">now</span> <span class="o">-</span> <span class="bp">self</span><span class="o">.</span><span class="n">_last_cleanup</span> <span class="o">&lt;</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cleanup_interval</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_last_cleanup</span> <span class="o">=</span> <span class="n">now</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="n">expired_keys</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="n">k</span> <span class="k">for</span> <span class="n">k</span><span class="p">,</span> <span class="n">v</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="o">.</span><span class="n">items</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="k">if</span> <span class="n">now</span> <span class="o">-</span> <span class="n">v</span><span class="p">[</span><span class="s2">&#34;created&#34;</span><span class="p">]</span> <span class="o">&gt;=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_ttl</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="p">]</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">for</span> <span class="n">k</span> <span class="ow">in</span> <span class="n">expired_keys</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="k">del</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="p">[</span><span class="n">k</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">def</span> <span class="nf">get</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_cleanup_expired</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="n">entry</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">if</span> <span class="n">entry</span> <span class="ow">and</span> <span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">()</span> <span class="o">-</span> <span class="n">entry</span><span class="p">[</span><span class="s2">&#34;created&#34;</span><span class="p">]</span> <span class="o">&lt;</span> <span class="bp">self</span><span class="o">.</span><span class="n">_ttl</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="k">return</span> <span class="n">entry</span><span class="p">[</span><span class="s2">&#34;value&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="c1"># Delete on read if expired</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="k">if</span> <span class="n">key</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">            <span class="k">del</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="p">[</span><span class="n">key</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="k">def</span> <span class="nf">set</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">,</span> <span class="n">value</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_cleanup_expired</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">            <span class="s2">&#34;value&#34;</span><span class="p">:</span> <span class="n">value</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">            <span class="s2">&#34;created&#34;</span><span class="p">:</span> <span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">        <span class="p">}</span></span></span></code></pre></div><p>Cleanup runs at most once per minute and removes all entries older than TTL.</p>
<h3 id="the-lesson-2">The lesson</h3>
<p><strong>Every in-memory cache needs an eviction strategy.</strong> If there&rsquo;s no mechanism to remove old entries, you have a memory leak with extra steps. &ldquo;TTL on read&rdquo; is not enough - if users stop requesting a key, that entry stays forever.</p>
<p>Use <code>cachetools.TTLCache</code> or similar if you don&rsquo;t want to roll your own. Better yet, if the cache is big enough to matter, put it in Redis where you can set <code>EXPIRE</code> and forget about it.</p>
<h2 id="leak-4-pymupdf-documents-that-never-closed">Leak #4: PyMuPDF documents that never closed</h2>
<p>Service B was the PDF processing service. It used PyMuPDF (<code>fitz</code>) to render pages and extract text. The endpoints looked like this:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nd">@app.post</span><span class="p">(</span><span class="s2">&#34;/parse_pdf&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">parse_pdf</span><span class="p">(</span><span class="n">file</span><span class="p">:</span> <span class="n">UploadFile</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">content</span> <span class="o">=</span> <span class="k">await</span> <span class="n">file</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="n">fitz</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="n">stream</span><span class="o">=</span><span class="n">content</span><span class="p">,</span> <span class="n">filetype</span><span class="o">=</span><span class="s2">&#34;pdf&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">pages</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">for</span> <span class="n">page_num</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">doc</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="n">page</span> <span class="o">=</span> <span class="n">doc</span><span class="p">[</span><span class="n">page_num</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">text</span> <span class="o">=</span> <span class="n">page</span><span class="o">.</span><span class="n">get_text</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="n">pix</span> <span class="o">=</span> <span class="n">page</span><span class="o">.</span><span class="n">get_pixmap</span><span class="p">(</span><span class="n">dpi</span><span class="o">=</span><span class="mi">300</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="n">image</span> <span class="o">=</span> <span class="n">pix</span><span class="o">.</span><span class="n">tobytes</span><span class="p">(</span><span class="s2">&#34;png&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="n">pages</span><span class="o">.</span><span class="n">append</span><span class="p">({</span><span class="s2">&#34;text&#34;</span><span class="p">:</span> <span class="n">text</span><span class="p">,</span> <span class="s2">&#34;image&#34;</span><span class="p">:</span> <span class="n">image</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</span> <span class="p">{</span><span class="s2">&#34;pages&#34;</span><span class="p">:</span> <span class="n">pages</span><span class="p">}</span></span></span></code></pre></div><p>No <code>doc.close()</code>. No context manager. No <code>try/finally</code>.</p>
<p>PyMuPDF is a C library wrapper. When you call <code>fitz.open()</code>, it allocates memory in C-land for the document structure, page data, fonts, images, and rendering buffers. Python&rsquo;s garbage collector can eventually reclaim the Python wrapper object, but the C-side memory is only freed when <code>doc.close()</code> is explicitly called.</p>
<p>While the wrapper theoretically has a <code>__del__</code> method, relying on it in a high-throughput async loop is dangerous. Python&rsquo;s GC is lazy and may not run fast enough to keep up with the rate of C-memory allocation, leading to OOMs before the GC even realizes it needs to clean up.</p>
<p>For a 50-page PDF at 300 DPI, the pixmap rendering alone can consume 200–500MB of memory. If an exception occurs mid-processing (corrupt page, unsupported font), the doc handle leaks entirely.</p>
<h3 id="the-fix-3">The fix</h3>
<p>Two changes: proper resource cleanup, and parallel OCR with controlled concurrency.</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nd">@app.post</span><span class="p">(</span><span class="s2">&#34;/parse_pdf&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">parse_pdf</span><span class="p">(</span><span class="n">file</span><span class="p">:</span> <span class="n">UploadFile</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">content</span> <span class="o">=</span> <span class="k">await</span> <span class="n">file</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="n">fitz</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="n">stream</span><span class="o">=</span><span class="n">content</span><span class="p">,</span> <span class="n">filetype</span><span class="o">=</span><span class="s2">&#34;pdf&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="n">pages</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">for</span> <span class="n">page_num</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">doc</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="n">page</span> <span class="o">=</span> <span class="n">doc</span><span class="p">[</span><span class="n">page_num</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="n">text</span> <span class="o">=</span> <span class="n">page</span><span class="o">.</span><span class="n">get_text</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="n">pix</span> <span class="o">=</span> <span class="n">page</span><span class="o">.</span><span class="n">get_pixmap</span><span class="p">(</span><span class="n">dpi</span><span class="o">=</span><span class="mi">300</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="n">image</span> <span class="o">=</span> <span class="n">pix</span><span class="o">.</span><span class="n">tobytes</span><span class="p">(</span><span class="s2">&#34;png&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="c1"># Explicitly free the pixmap</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="n">pix</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="n">pages</span><span class="o">.</span><span class="n">append</span><span class="p">({</span><span class="s2">&#34;text&#34;</span><span class="p">:</span> <span class="n">text</span><span class="p">,</span> <span class="s2">&#34;image&#34;</span><span class="p">:</span> <span class="n">image</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</span> <span class="p">{</span><span class="s2">&#34;pages&#34;</span><span class="p">:</span> <span class="n">pages</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">finally</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="n">doc</span><span class="o">.</span><span class="n">close</span><span class="p">()</span></span></span></code></pre></div><p>This was applied to all four endpoints in the service: <code>/parse_pdf</code>, <code>/process_clauses</code>, <code>/process_clauses_sync</code>, and <code>/extract_links</code>.</p>
<p>We also added parallel strip processing for OCR (controlled by feature flags):</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">from</span> <span class="nn">concurrent.futures</span> <span class="kn">import</span> <span class="n">ThreadPoolExecutor</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">ENABLE_PARALLEL_OCR</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;ENABLE_PARALLEL_OCR&#34;</span><span class="p">,</span> <span class="s2">&#34;false&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">lower</span><span class="p">()</span> <span class="o">==</span> <span class="s2">&#34;true&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">STRIP_OCR_WORKERS</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;STRIP_OCR_WORKERS&#34;</span><span class="p">,</span> <span class="s2">&#34;8&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">def</span> <span class="nf">process_page_strips</span><span class="p">(</span><span class="n">page</span><span class="p">,</span> <span class="n">strips</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">ENABLE_PARALLEL_OCR</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">return</span> <span class="p">[</span><span class="n">ocr_strip</span><span class="p">(</span><span class="n">page</span><span class="p">,</span> <span class="n">s</span><span class="p">)</span> <span class="k">for</span> <span class="n">s</span> <span class="ow">in</span> <span class="n">strips</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="n">STRIP_OCR_WORKERS</span><span class="p">)</span> <span class="k">as</span> <span class="n">pool</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="n">futures</span> <span class="o">=</span> <span class="p">[</span><span class="n">pool</span><span class="o">.</span><span class="n">submit</span><span class="p">(</span><span class="n">ocr_strip</span><span class="p">,</span> <span class="n">page</span><span class="p">,</span> <span class="n">s</span><span class="p">)</span> <span class="k">for</span> <span class="n">s</span> <span class="ow">in</span> <span class="n">strips</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">return</span> <span class="p">[</span><span class="n">f</span><span class="o">.</span><span class="n">result</span><span class="p">()</span> <span class="k">for</span> <span class="n">f</span> <span class="ow">in</span> <span class="n">futures</span><span class="p">]</span></span></span></code></pre></div><h3 id="the-deployment">The deployment</h3>
<p>We didn&rsquo;t flip everything on at once:</p>
<ol>
<li>Deploy with <code>ENABLE_PARALLEL_OCR=false</code> - just the <code>doc.close()</code> fixes</li>
<li>Monitor memory for 24 hours to confirm the leak is fixed</li>
<li>Bump resource limits from <code>4Gi</code> to <code>6Gi</code> to accommodate parallel processing headroom</li>
<li>Enable parallel OCR: <code>ENABLE_PARALLEL_OCR=true</code></li>
</ol>
<p>Memory dropped from a steady 4GB to a stable 1.8GB. That&rsquo;s a 55% reduction from just closing document handles properly.</p>
<h3 id="the-lesson-3">The lesson</h3>
<p><strong>C extension libraries manage their own memory.</strong> Python&rsquo;s GC has no visibility into <code>fitz</code>, <code>numpy</code>, <code>Pillow</code>, or any other C-backed library. If the library provides a <code>close()</code>, use it. If it supports context managers, use <code>with</code>. And always wrap C-heavy processing in <code>try/finally</code>.</p>
<h2 id="the-combined-impact">The combined impact</h2>
<p>Here&rsquo;s the before and after across both services:</p>
<table>
  <thead>
      <tr>
          <th>Issue</th>
          <th>Before</th>
          <th>After</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Unawaited coroutines</td>
          <td>~200MB/hour leak</td>
          <td>0</td>
      </tr>
      <tr>
          <td>MongoClient per call</td>
          <td>5–15 new connections per request</td>
          <td>1 pooled</td>
      </tr>
      <tr>
          <td>Stale cache entries</td>
          <td>Up to 200MB unbounded</td>
          <td>Cleaned every 60s</td>
      </tr>
      <tr>
          <td>PyMuPDF documents</td>
          <td>4GB+ with no release</td>
          <td>Stable at 1.8GB</td>
      </tr>
      <tr>
          <td>Embedding API calls</td>
          <td>2× per document save</td>
          <td>1× (cached concat)</td>
      </tr>
  </tbody>
</table>
<p>Service A went from daily OOMKills to zero restarts. Service B went from crashing every 8–10 hours to running for weeks without a restart.</p>
<h2 id="how-i-found-them">How I found them</h2>
<p>No fancy tooling. Here&rsquo;s the actual process:</p>
<ol>
<li>
<p><strong>Look at the memory graph.</strong> Linear growth = accumulation leak. Sawtooth that doesn&rsquo;t return to baseline = handle/resource leak.</p>
</li>
<li>
<p><strong>Read the code.</strong> Seriously. I read every function in the hot paths and asked &ldquo;what gets allocated here that doesn&rsquo;t get freed?&rdquo;</p>
</li>
<li>
<p><strong><code>grep</code> for patterns.</strong> Once I found one unawaited coroutine, I searched for the pattern across the entire codebase:</p>
</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Find async functions called without await</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">rg <span class="s2">&#34;^\s+[a-z_]+\(&#34;</span> --type py <span class="p">|</span> grep -v <span class="s2">&#34;await &#34;</span> <span class="p">|</span> grep -v <span class="s2">&#34;def &#34;</span> <span class="p">|</span> grep -v <span class="s2">&#34;#&#34;</span></span></span></code></pre></div><p>Not perfect, but it flagged the 13 MongoClient instantiations immediately.</p>
<ol start="4">
<li>
<p><strong>Check warnings.</strong> Enabled <code>RuntimeWarning</code> filtering and got 6 unawaited coroutine warnings within the first minute of startup.</p>
</li>
<li>
<p><strong>Kubernetes events.</strong> <code>kubectl describe pod</code> shows OOMKilled with the exact memory at death. Compare that to your resource limits and you know how fast it&rsquo;s growing.</p>
</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kubectl get events --field-selector <span class="nv">reason</span><span class="o">=</span>OOMKilling <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl">  --sort-by<span class="o">=</span><span class="s1">&#39;.lastTimestamp&#39;</span> -n your-namespace</span></span></code></pre></div><h2 id="a-checklist-for-your-services">A checklist for your services</h2>
<p>If you&rsquo;re running Python services on Kubernetes and seeing memory growth, check these in order:</p>
<ul>
<li><input disabled="" type="checkbox"> <strong>Unawaited coroutines.</strong> Search for <code>RuntimeWarning: coroutine.*was never awaited</code> in your logs. Better yet, make it an error.</li>
<li><input disabled="" type="checkbox"> <strong>MongoClient instantiation.</strong> <code>grep -rn &quot;MongoClient(&quot; your_code/</code> - if it appears in more than one place (especially inside functions, not module level), you probably have a leak.</li>
<li><input disabled="" type="checkbox"> <strong>In-memory caches without eviction.</strong> Any <code>dict</code> that grows with usage and has no <code>del</code> or size cap is a leak. <code>TTLCache</code>, <code>LRU</code>, or <code>maxsize</code> - pick one.</li>
<li><input disabled="" type="checkbox"> <strong>C extension resources.</strong> PyMuPDF, Pillow, lxml, pdfplumber - anything that wraps C needs explicit <code>close()</code> or <code>with</code> statements.</li>
<li><input disabled="" type="checkbox"> <strong>Large objects in exception handlers.</strong> If your <code>except</code> block captures the full traceback and logs the local variables, those locals (which may include entire documents) are pinned until the log entry is flushed.</li>
<li><input disabled="" type="checkbox"> <strong>Background tasks holding request data.</strong> If <code>asyncio.create_task()</code> captures a closure over request objects, those objects live until the task completes.</li>
</ul>
<h2 id="final-thought">Final thought</h2>
<p>None of these bugs produced an error. No stack trace. No warning (well, one did, but it was buried). The services ran <em>fine</em> - they just got slower and slower until Kubernetes killed them, then they restarted and the cycle began again.</p>
<p>Memory leaks in garbage-collected languages are not the &ldquo;forgot to call <code>free()</code>&rdquo; kind. They&rsquo;re the &ldquo;I&rsquo;m holding a reference I didn&rsquo;t know about&rdquo; kind. The GC is doing its job perfectly - it&rsquo;s keeping alive exactly what you told it to.</p>
<p>You just told it wrong.</p>
]]></content:encoded>
    </item>
    <item>
      <title>SSE in FastAPI: The Tab Closed and My “Done” Code Never Ran</title>
      <link>https://KTS-o7.github.io/bear/infra/sse_disconnect_handling/</link>
      <pubDate>Fri, 23 Jan 2026 00:00:00 +0000</pubDate><author>shentharkrishnatejaswi@gmail.com (Krishnatejaswi S)</author>
      <guid>https://KTS-o7.github.io/bear/infra/sse_disconnect_handling/</guid>
      <description>&lt;p&gt;I hit this bug the annoying way.&lt;/p&gt;&#xA;&lt;p&gt;A coworker said: “I ran task generation, closed the tab, and the status never moved.”&lt;/p&gt;&#xA;&lt;p&gt;My first reaction was “no way.” The backend is doing the work, not the browser.&lt;/p&gt;&#xA;&lt;p&gt;Turns out… yeah. It was right. And once you see why, you stop trusting “code after the stream” forever.&lt;/p&gt;&#xA;&lt;p&gt;This post is about a very specific trap:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;you stream progress with SSE from a FastAPI endpoint&lt;/li&gt;&#xA;&lt;li&gt;you put important side effects &lt;em&gt;after&lt;/em&gt; the streaming loop (DB status updates, poller kickoff, Slack notifications)&lt;/li&gt;&#xA;&lt;li&gt;a client disconnect cancels the stream&lt;/li&gt;&#xA;&lt;li&gt;your “after the loop” code never runs&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;It doesn’t always fail. It fails just enough to make you doubt your own system.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>I hit this bug the annoying way.</p>
<p>A coworker said: “I ran task generation, closed the tab, and the status never moved.”</p>
<p>My first reaction was “no way.” The backend is doing the work, not the browser.</p>
<p>Turns out… yeah. It was right. And once you see why, you stop trusting “code after the stream” forever.</p>
<p>This post is about a very specific trap:</p>
<ul>
<li>you stream progress with SSE from a FastAPI endpoint</li>
<li>you put important side effects <em>after</em> the streaming loop (DB status updates, poller kickoff, Slack notifications)</li>
<li>a client disconnect cancels the stream</li>
<li>your “after the loop” code never runs</li>
</ul>
<p>It doesn’t always fail. It fails just enough to make you doubt your own system.</p>
<h2 id="what-the-system-looked-like-so-you-dont-think-this-is-a-demo-problem">What the system looked like (so you don’t think this is a demo problem)</h2>
<p>We had a step-based pipeline. Each step was triggered by an HTTP call from the frontend and did a few things:</p>
<ol>
<li>Receive a request like <code>POST /process-timeline-done</code> (or similar)</li>
<li>Start a long-ish worker (in our case an AI agent, but it could be anything)</li>
<li>Stream progress back to the UI via <strong>Server‑Sent Events</strong></li>
<li>Update state in MongoDB (advance a status field)</li>
<li>Submit an external job</li>
<li>Start a long poller to watch that job (hours sometimes)</li>
<li>Send a Slack notification</li>
</ol>
<p>Only (3) needs a live HTTP connection. The rest should be able to finish even if the user refreshes or closes the tab.</p>
<p>We had code split into things like:</p>
<table>
  <thead>
      <tr>
          <th>Component</th>
          <th>What it does</th>
          <th>Should it depend on HTTP?</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>CircularProcessorService</code></td>
          <td>Orchestrates the step</td>
          <td>Only for streaming</td>
      </tr>
      <tr>
          <td><code>JobPollingService</code></td>
          <td>Poll external job completion</td>
          <td>No</td>
      </tr>
      <tr>
          <td><code>ComplianceOSClient</code></td>
          <td>DB/API writes</td>
          <td>No</td>
      </tr>
      <tr>
          <td><code>SlackAlerts</code></td>
          <td>Notify humans</td>
          <td>No</td>
      </tr>
  </tbody>
</table>
<p>The bug was that our implementation accidentally made a bunch of “No” things depend on HTTP anyway.</p>
<h2 id="the-bug-the-exact-pattern">The bug (the exact pattern)</h2>
<p>This is the shape of the broken code:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># ❌ Broken: critical work is after streaming.</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">process_step</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">async</span> <span class="k">for</span> <span class="n">update</span> <span class="ow">in</span> <span class="n">agent</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="k">yield</span> <span class="n">sse</span><span class="p">(</span><span class="n">update</span><span class="p">)</span>  <span class="c1"># disconnect can cancel/raise here</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="c1"># If the client disconnects, this may never run.</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">await</span> <span class="n">db</span><span class="o">.</span><span class="n">update_status</span><span class="p">(</span><span class="s2">&#34;tasks_done&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="n">asyncio</span><span class="o">.</span><span class="n">create_task</span><span class="p">(</span><span class="n">start_polling</span><span class="p">(</span><span class="n">job_id</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">    <span class="k">await</span> <span class="n">slack</span><span class="o">.</span><span class="n">notify</span><span class="p">(</span><span class="s2">&#34;done&#34;</span><span class="p">)</span></span></span></code></pre></div><p>It feels reasonable because it matches how we think:</p>
<p>“Stream updates while running… then when the loop ends, do the completion stuff.”</p>
<p>But in a streaming response, <em>the loop ending is not guaranteed</em>. The client can just leave.</p>
<h2 id="what-actually-happens-when-the-client-disconnects">What actually happens when the client disconnects</h2>
<p>Let’s make it concrete with a timeline.</p>
<p>Normal happy path:</p>





<pre tabindex="0"><code>Browser                  FastAPI/Starlette           MongoDB / External
   |                           |                          |
   | POST /process-step        |                          |
   |--------------------------&gt;|                          |
   | 200 OK (SSE stream)       |                          |
   |&lt;--------------------------|                          |
   | data: update #1           |                          |
   |&lt;--------------------------|                          |
   | data: update #2           |                          |
   |&lt;--------------------------|                          |
   | ...                       |                          |
   | data: final               |                          |
   |&lt;--------------------------|                          |
   | stream ends               |                          |
   |                           | update status            |
   |                           |-------------------------&gt;|
   |                           | start poller             |
   |                           |-------------------------&gt;|
   |                           | Slack notify             |
   |                           |-------------------------&gt;|</code></pre><p>Disconnect path:</p>





<pre tabindex="0"><code>Browser                  FastAPI/Starlette           MongoDB / External
   |                           |                          |
   | POST /process-step        |                          |
   |--------------------------&gt;|                          |
   | 200 OK (SSE stream)       |                          |
   |&lt;--------------------------|                          |
   | data: update #1           |                          |
   |&lt;--------------------------|                          |
   | data: update #2           |                          |
   |&lt;--------------------------|                          |
   | [tab closed]              |                          |
   X                           |                          |
                               | stream task canceled     |
                               | (right on a yield/send)  |
                               |                          |
                               | ❌ status update skipped  |
                               | ❌ poller never started   |
                               | ❌ Slack never sent       |</code></pre><p>The punchline is simple: <strong>SSE streaming is not “just a loop.” It’s a loop being consumed by a response writer that is allowed to stop at any time.</strong></p>
<h2 id="under-the-hood-why-starlette-cancels-your-generator">Under the hood (why Starlette cancels your generator)</h2>
<p>This is not the exact Starlette source code, but it matches the design.</p>
<p>Streaming responses typically run two things concurrently:</p>
<ul>
<li>a task that iterates your generator and sends chunks (<code>http.response.body</code>)</li>
<li>a task that listens for <code>http.disconnect</code></li>
</ul>
<p>When the disconnect happens, the server cancels the streaming task. That cancellation can hit while the framework is trying to send your last chunk… which was produced by your generator… which means your generator gets torn down mid-flight.</p>
<p>Two practical details that matter a lot:</p>
<ol>
<li>Cancellation shows up as <code>asyncio.CancelledError</code> (or sometimes as “write failed” type errors depending on stack).</li>
<li>In modern Python, <code>asyncio.CancelledError</code> is a <code>BaseException</code>, not an <code>Exception</code>. So <code>except Exception:</code> won’t catch it.</li>
</ol>
<p>That second one is why “but I wrapped it in try/except” doesn’t save you.</p>
<h2 id="a-minimal-repro-you-can-run-in-two-minutes">A minimal repro you can run in two minutes</h2>
<p>Here’s a tiny app that demonstrates the exact failure mode. The <code>print()</code> after the loop is your “update MongoDB status” in disguise.</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">asyncio</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">from</span> <span class="nn">fastapi</span> <span class="kn">import</span> <span class="n">FastAPI</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">from</span> <span class="nn">starlette.responses</span> <span class="kn">import</span> <span class="n">StreamingResponse</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">app</span> <span class="o">=</span> <span class="n">FastAPI</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">gen</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">1000</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">yield</span> <span class="sa">f</span><span class="s2">&#34;data: </span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="se">\n\n</span><span class="s2">&#34;</span><span class="o">.</span><span class="n">encode</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">await</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="mf">0.2</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;stream ended; now do side effects&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="nd">@app.get</span><span class="p">(</span><span class="s2">&#34;/sse&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">sse</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">return</span> <span class="n">StreamingResponse</span><span class="p">(</span><span class="n">gen</span><span class="p">(),</span> <span class="n">media_type</span><span class="o">=</span><span class="s2">&#34;text/event-stream&#34;</span><span class="p">)</span></span></span></code></pre></div><p>Run it, then:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl -N http://localhost:8000/sse</span></span></code></pre></div><p>Wait for a few numbers, hit <code>ctrl+c</code> in the <code>curl</code> terminal.</p>
<p>You will not see the “now do side effects” line.</p>
<p>That’s the bug.</p>
<h2 id="the-real-fix-stop-making-your-job-depend-on-the-http-stream">The real fix: stop making your job depend on the HTTP stream</h2>
<p>Once you accept “the client can vanish at any time”, the design gets boring (in a good way):</p>
<ul>
<li>The job runs to completion in a background task.</li>
<li>Streaming is just a best-effort view of progress.</li>
</ul>
<p>I like to think in terms of ownership:</p>
<ul>
<li>The worker owns side effects (DB writes, poller, Slack).</li>
<li>The request handler owns only streaming bytes to the client.</li>
</ul>
<h3 id="what-i-tried-first-and-why-it-didnt-help-backgroundtasks">What I tried first (and why it didn’t help): <code>BackgroundTasks</code></h3>
<p>FastAPI has <code>BackgroundTasks</code>. It’s useful, but it’s not a magic “disconnect-proof” button.</p>
<p>The catch is simple: background tasks are executed <em>after</em> the response is done.</p>
<p>For a normal JSON response, “response is done” means “we returned a body, the server wrote it, done.”</p>
<p>For an SSE response, “response is done” means “the stream ended.”</p>
<p>Which is… exactly the thing we can’t rely on.</p>
<p>If the client disconnects and the streaming response gets canceled, your “run this after the response” work is still hanging off the same lifecycle. It’s not the separation you actually want.</p>
<h3 id="cant-i-just-move-the-status-update-into-finally">“Can’t I just move the status update into <code>finally</code>?”</h3>
<p>This is another tempting fix:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">gen</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">async</span> <span class="k">for</span> <span class="n">update</span> <span class="ow">in</span> <span class="n">agent</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">            <span class="k">yield</span> <span class="n">sse</span><span class="p">(</span><span class="n">update</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">finally</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="k">await</span> <span class="n">db</span><span class="o">.</span><span class="n">update_status</span><span class="p">(</span><span class="s2">&#34;tasks_done&#34;</span><span class="p">)</span></span></span></code></pre></div><p>Sometimes it works. Sometimes it doesn’t. It depends on where cancellation lands.</p>
<p>Cancellation can interrupt awaits inside <code>finally</code> too. So you end up with a fun situation where you <em>thought</em> you made it reliable, but you just changed the probability of it failing.</p>
<p>If you really want to run cleanup under cancellation, you need to start thinking about shielding and cancel scopes. At that point you’re already halfway to the “worker owns completion” design anyway, so I’d rather just do it properly.</p>
<h3 id="the-queue-pattern-with-one-important-correction">The queue pattern (with one important correction)</h3>
<p>The common implementation is “worker pushes updates into a queue, streamer drains it.”</p>
<p>That’s fine, but there’s a nasty gotcha:</p>
<p>If the client disconnects and the streamer stops draining, <strong>a bounded queue can block your worker forever</strong>.</p>
<p>So don’t do this in your worker:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">await</span> <span class="n">queue</span><span class="o">.</span><span class="n">put</span><span class="p">(</span><span class="n">update</span><span class="p">)</span>  <span class="c1"># ❌ can hang if consumer is gone</span></span></span></code></pre></div><p>If you do, you just recreated the original bug: completion is again tied to streaming, just indirectly.</p>
<p>What I do instead:</p>
<ul>
<li>Keep the queue bounded (to protect memory)</li>
<li>Put updates in a non-blocking way and drop when full</li>
<li>Stop queueing progress once the client is gone</li>
</ul>
<p>Here’s a practical sketch:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">asyncio</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">import</span> <span class="nn">json</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">import</span> <span class="nn">logging</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">logging</span><span class="o">.</span><span class="n">getLogger</span><span class="p">(</span><span class="vm">__name__</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">def</span> <span class="nf">sse</span><span class="p">(</span><span class="n">data</span><span class="p">:</span> <span class="nb">dict</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bytes</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;data: </span><span class="si">{</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">data</span><span class="p">)</span><span class="si">}</span><span class="se">\n\n</span><span class="s2">&#34;</span><span class="o">.</span><span class="n">encode</span><span class="p">(</span><span class="s2">&#34;utf-8&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">def</span> <span class="nf">try_put</span><span class="p">(</span><span class="n">queue</span><span class="p">:</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Queue</span><span class="p">,</span> <span class="n">item</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="n">queue</span><span class="o">.</span><span class="n">put_nowait</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">except</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">QueueFull</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="c1"># progress is optional; completion isn&#39;t</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">pass</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="k">def</span> <span class="nf">log_task_result</span><span class="p">(</span><span class="n">task</span><span class="p">:</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Task</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="n">task</span><span class="o">.</span><span class="n">result</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">except</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">CancelledError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">pass</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="n">logger</span><span class="o">.</span><span class="n">exception</span><span class="p">(</span><span class="s2">&#34;background worker crashed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">run_to_completion</span><span class="p">(</span><span class="n">queue</span><span class="p">:</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Queue</span><span class="p">,</span> <span class="n">client_gone</span><span class="p">:</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Event</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="n">job_id</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="k">async</span> <span class="k">for</span> <span class="n">update</span> <span class="ow">in</span> <span class="n">agent</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">            <span class="n">job_id</span> <span class="o">=</span> <span class="n">job_id</span> <span class="ow">or</span> <span class="n">update</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;job_id&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">            <span class="k">if</span> <span class="ow">not</span> <span class="n">client_gone</span><span class="o">.</span><span class="n">is_set</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">                <span class="n">try_put</span><span class="p">(</span><span class="n">queue</span><span class="p">,</span> <span class="n">update</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="c1"># ✅ These must run even if client disconnects.</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">        <span class="k">await</span> <span class="n">db</span><span class="o">.</span><span class="n">update_status</span><span class="p">(</span><span class="s2">&#34;tasks_done&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">        <span class="n">asyncio</span><span class="o">.</span><span class="n">create_task</span><span class="p">(</span><span class="n">start_polling</span><span class="p">(</span><span class="n">job_id</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="k">await</span> <span class="n">slack</span><span class="o">.</span><span class="n">notify</span><span class="p">(</span><span class="s2">&#34;done&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">client_gone</span><span class="o">.</span><span class="n">is_set</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">            <span class="n">try_put</span><span class="p">(</span><span class="n">queue</span><span class="p">,</span> <span class="p">{</span><span class="s2">&#34;final&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">,</span> <span class="s2">&#34;success&#34;</span><span class="p">:</span> <span class="kc">False</span><span class="p">,</span> <span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">e</span><span class="p">)})</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">        <span class="k">raise</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">    <span class="k">finally</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">client_gone</span><span class="o">.</span><span class="n">is_set</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">            <span class="n">try_put</span><span class="p">(</span><span class="n">queue</span><span class="p">,</span> <span class="kc">None</span><span class="p">)</span>  <span class="c1"># sentinel for streamer</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">
</span></span><span class="line"><span class="ln">45</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">stream</span><span class="p">(</span><span class="n">queue</span><span class="p">:</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Queue</span><span class="p">,</span> <span class="n">client_gone</span><span class="p">:</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Event</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">        <span class="k">while</span> <span class="kc">True</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">            <span class="n">item</span> <span class="o">=</span> <span class="k">await</span> <span class="n">queue</span><span class="o">.</span><span class="n">get</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">            <span class="k">if</span> <span class="n">item</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">                <span class="k">break</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">            <span class="k">yield</span> <span class="n">sse</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">    <span class="k">except</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">CancelledError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">53</span><span class="cl">        <span class="c1"># disconnected: stop streaming, signal worker to stop enqueueing</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">        <span class="n">client_gone</span><span class="o">.</span><span class="n">set</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">55</span><span class="cl">        <span class="k">raise</span>
</span></span><span class="line"><span class="ln">56</span><span class="cl">    <span class="k">finally</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">57</span><span class="cl">        <span class="n">client_gone</span><span class="o">.</span><span class="n">set</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">58</span><span class="cl">
</span></span><span class="line"><span class="ln">59</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">handler</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">60</span><span class="cl">    <span class="n">queue</span> <span class="o">=</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Queue</span><span class="p">(</span><span class="n">maxsize</span><span class="o">=</span><span class="mi">200</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">61</span><span class="cl">    <span class="n">client_gone</span> <span class="o">=</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Event</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">62</span><span class="cl">
</span></span><span class="line"><span class="ln">63</span><span class="cl">    <span class="n">task</span> <span class="o">=</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">create_task</span><span class="p">(</span><span class="n">run_to_completion</span><span class="p">(</span><span class="n">queue</span><span class="p">,</span> <span class="n">client_gone</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">64</span><span class="cl">    <span class="n">task</span><span class="o">.</span><span class="n">add_done_callback</span><span class="p">(</span><span class="n">log_task_result</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">65</span><span class="cl">
</span></span><span class="line"><span class="ln">66</span><span class="cl">    <span class="k">return</span> <span class="n">StreamingResponse</span><span class="p">(</span><span class="n">stream</span><span class="p">(</span><span class="n">queue</span><span class="p">,</span> <span class="n">client_gone</span><span class="p">),</span> <span class="n">media_type</span><span class="o">=</span><span class="s2">&#34;text/event-stream&#34;</span><span class="p">)</span></span></span></code></pre></div><p>Is it perfect? No. But it has the right shape:</p>
<ul>
<li>worker can finish without a consumer</li>
<li>progress streaming is best-effort</li>
<li>failures are logged instead of silently disappearing</li>
</ul>
<p>If you want something more durable (client reconnects and can replay progress), you need to store progress somewhere real (Redis/pubsub, DB, etc.). In-memory queues won’t do that. But that’s a different problem.</p>
<h2 id="the-architecture-i-like-better-when-this-gets-serious">The architecture I like better (when this gets serious)</h2>
<p>The queue pattern is fine when:</p>
<ul>
<li>you mostly care about “don’t lose completion when the client disconnects”</li>
<li>you don’t care about reconnecting and replaying a progress history</li>
</ul>
<p>But once you ship this to real users, you’ll hit two common problems:</p>
<ol>
<li>Browsers reconnect SSE automatically (especially <code>EventSource</code>), and you don’t want a reconnect to re-run the job.</li>
<li>People refresh and expect to see progress again (or at least not lose the final result).</li>
</ol>
<p>So the more robust shape is:</p>
<ol>
<li><code>POST /steps/{step}/start</code> → returns a <code>run_id</code></li>
<li>Worker runs independent of any SSE connection and persists state (status + maybe progress)</li>
<li><code>GET /runs/{run_id}/events</code> → SSE stream that is purely “subscribe”, not “start”</li>
</ol>
<p>Now reconnecting is safe. You might lose some live updates, but you can always read the current status from MongoDB, and you can keep polling running even if nobody is watching.</p>
<p>If you want a really clean UX, you can also persist event ids and support <code>Last-Event-ID</code>, but that’s optional. The big win is separating “start work” from “watch work.”</p>
<h2 id="sse-details-that-are-boring-but-matter">SSE details that are boring but matter</h2>
<p>SSE is “simple” in the same way DNS is “simple.”</p>
<p>It works great until you put it behind real infrastructure.</p>
<h3 id="event-formatting-is-strict">Event formatting is strict</h3>
<p>Each event ends with a blank line. If you forget the blank line, the browser buffers and you’ll think streaming is broken.</p>
<p>Simplest possible event:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">data: {&#34;message&#34;:&#34;hi&#34;}</span></span></code></pre></div><p>(Yes, that extra newline is part of it.)</p>
<h3 id="keepalives">Keepalives</h3>
<p>If your worker can go quiet for a while, send a comment every ~15–30 seconds so proxies don’t decide the connection is “idle”:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">yield</span> <span class="sa">b</span><span class="s2">&#34;: keep-alive</span><span class="se">\n\n</span><span class="s2">&#34;</span></span></span></code></pre></div><h3 id="nginx-buffering">Nginx buffering</h3>
<p>If your stream arrives in one big chunk at the end, it’s usually Nginx buffering.</p>
<p>You don’t fix buffering with Python. You fix it with config.</p>
<p>At minimum, you want buffering off for the SSE location and timeouts that won’t murder long streams.</p>
<p>Something like this (not gospel, but you get the idea):</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">location</span> <span class="s">/runs/</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kn">proxy_http_version</span> <span class="mi">1</span><span class="s">.1</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kn">proxy_set_header</span> <span class="s">Connection</span> <span class="s">&#34;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kn">proxy_buffering</span> <span class="no">off</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kn">proxy_cache</span> <span class="no">off</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="kn">proxy_read_timeout</span> <span class="s">3600s</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="kn">add_header</span> <span class="s">X-Accel-Buffering</span> <span class="s">no</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h2 id="the-other-gotchas-the-stuff-that-actually-bites-later">The other gotchas (the stuff that actually bites later)</h2>
<h3 id="dont-use-request-scoped-dependencies-in-your-background-worker">Don’t use request-scoped dependencies in your background worker</h3>
<p>If you have <code>Depends(get_db)</code> where <code>get_db</code> is a <code>yield</code> dependency, it gets cleaned up when the request is done.</p>
<p>If your worker is still running, it may now be holding a dead session/client.</p>
<p>Solution: the worker should acquire its own long-lived clients (Mongo, HTTP client, etc.) that are not tied to the request lifecycle.</p>
<h3 id="treat-status-updates-like-a-state-machine">Treat status updates like a state machine</h3>
<p>If you do <code>timeline_done</code> → <code>tasks_done</code> → <code>final_done</code>, don’t just write <code>tasks_done</code> unconditionally.</p>
<p>Do “only update if current status is timeline_done” (compare-and-set). Otherwise retries and races will eventually mess up your state.</p>
<h3 id="dont-confuse-survives-client-disconnect-with-durable">Don’t confuse “survives client disconnect” with “durable”</h3>
<p>This pattern survives a client disconnect. It does not survive:</p>
<ul>
<li>the process dying</li>
<li>the pod being evicted</li>
<li>a deploy that SIGTERMs the worker mid-step</li>
</ul>
<p>If the job truly must finish no matter what, use a real job system. SSE should subscribe to a job id, not be the job.</p>
<h3 id="decide-if-disconnect-should-cancel-the-job-explicitly">Decide if disconnect should cancel the job (explicitly)</h3>
<p>Some people will ask for “closing the tab cancels the job.”</p>
<p>That’s a separate feature. Don’t accidentally implement it via “oops, the client disconnected so the worker got canceled.”</p>
<p>If you want cancellation, do it explicitly:</p>
<ul>
<li>create a job id</li>
<li>store cancel intent</li>
<li>have the worker check it and stop cleanly</li>
</ul>
<p>Client disconnect is not a reliable cancel signal. It’s just the network being the network.</p>
<h2 id="a-boring-checklist-the-stuff-i-verify-before-i-trust-the-fix">A boring checklist (the stuff I verify before I trust the fix)</h2>
<ul>
<li>Start a step, then close the tab mid-stream → status still advances later</li>
<li>Start a step, refresh rapidly → no duplicate runs / no broken state transitions</li>
<li>Simulate a proxy timeout → completion still happens</li>
<li>Kill the SSE client (<code>curl -N ...</code> then <code>ctrl+c</code>) → completion still happens</li>
<li>Worker crashes mid-way → status reflects failure, and it doesn’t get stuck “in progress” forever</li>
</ul>
<h2 id="performance-notes-because-someone-will-ask">Performance notes (because someone will ask)</h2>
<p>The queue approach is usually cheap, but it’s not free.</p>
<ul>
<li>Memory: <code>queue_size * avg_event_size</code> per in-flight stream. Keep event payloads small.</li>
<li>CPU: queue ops are basically noise; the expensive bits are your DB/network calls.</li>
<li>DB load: if you add read-after-write verification (I sometimes do), it’s extra reads. Worth it if your system lies to you otherwise.</li>
</ul>
<p>The bigger risk isn’t CPU. It’s “we launched 500 background tasks because 500 people clicked the button.” Put a cap somewhere.</p>
<h2 id="deployment-strategy-how-i-roll-it-out-without-getting-yelled-at">Deployment strategy (how I roll it out without getting yelled at)</h2>
<ol>
<li>Staging: disconnect tests, refresh tests, and “kill the client” tests.</li>
<li>Canary: small percentage of traffic. Watch for stuck statuses.</li>
<li>Full rollout: watch completion rate for a day. If it drops, roll back fast.</li>
</ol>
<h2 id="how-i-tested-it-and-how-id-recommend-you-test-it">How I tested it (and how I’d recommend you test it)</h2>
<p>Nothing fancy. I just did the thing that used to break it:</p>
<ul>
<li>start the step</li>
<li>wait for a couple SSE updates</li>
<li>close the tab / refresh / kill the connection</li>
<li>watch MongoDB and server logs</li>
</ul>
<p>What I wanted to see is boring:</p>
<ul>
<li>status advances anyway</li>
<li>poller starts anyway</li>
<li>Slack fires anyway</li>
</ul>
<p>Once you watch the system finish without a client attached, you stop trusting “after the stream” code forever.</p>
<p>And honestly you should. It’s a trap.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
