<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Weishu</title>
    <description>The latest articles on DEV Community by Weishu (@weishu_ppceye).</description>
    <link>https://dev.to/weishu_ppceye</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4193730%2F080c8079-da15-488c-bb6f-a745de191fa8.png</url>
      <title>DEV Community: Weishu</title>
      <link>https://dev.to/weishu_ppceye</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/weishu_ppceye"/>
    <language>en</language>
    <item>
      <title>One Long Job Took Down Our Whole Redis Streams Worker Pool</title>
      <dc:creator>Weishu</dc:creator>
      <pubDate>Mon, 19 Oct 2026 09:31:51 +0000</pubDate>
      <link>https://dev.to/weishu_ppceye/one-long-job-took-down-our-whole-redis-streams-worker-pool-2pg4</link>
      <guid>https://dev.to/weishu_ppceye/one-long-job-took-down-our-whole-redis-streams-worker-pool-2pg4</guid>
      <description>&lt;p&gt;We run a small fleet of Python workers on top of Redis Streams. One night, a single long-running job quietly took the entire interactive pool offline for 14 hours. Nothing crashed, nothing logged an error, and every worker was "busy". This post is about why, and the two-line idea that fixed it.&lt;/p&gt;

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

&lt;p&gt;At &lt;a href="https://ppceye.com" rel="noopener noreferrer"&gt;PPCEye&lt;/a&gt; we collect Google search ads from Google's public ad archive and let people look up which advertisers run ads for a given domain. When a logged-in user opens a domain page with stale data, we enqueue a fetch job. Workers pick jobs up from Redis Streams with consumer groups:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xreadgroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GROUP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;STREAM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# ... run the handler ...
&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STREAM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GROUP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Workers die sometimes (deploys, OOM, a bad proxy). So, like most Redis Streams tutorials recommend, every worker also periodically reclaims messages that have been pending for too long:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xautoclaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STREAM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GROUP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_idle_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;RUNNING_TIMEOUT_MS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0-0&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RUNNING_TIMEOUT&lt;/code&gt; was 15 minutes. If a worker took a message and never acked it within 15 minutes, we assumed it was dead and let someone else take over.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;Most fetch jobs take seconds. Then a user opened a page for a very large domain, one where the archive window held almost 2 million ads. That fetch was going to run for hours.&lt;/p&gt;

&lt;p&gt;Here is what the logs showed, one line per worker picking up the same message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;23:50:17  worker-4  claims fetch:&amp;lt;big-domain&amp;gt;
00:05:27  worker-6  claims fetch:&amp;lt;big-domain&amp;gt;
00:20:41  worker-3  claims fetch:&amp;lt;big-domain&amp;gt;
00:36:08  worker-1  claims fetch:&amp;lt;big-domain&amp;gt;
00:51:35  worker-5  claims fetch:&amp;lt;big-domain&amp;gt;
01:07:09  worker-2  claims fetch:&amp;lt;big-domain&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exactly 15 minutes apart. &lt;code&gt;XPENDING&lt;/code&gt; showed &lt;code&gt;times_delivered=6&lt;/code&gt; for that message. All six interactive workers were now running the same multi-hour job in parallel, and nothing else in the queue moved until morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why: idle time only resets on delivery
&lt;/h2&gt;

&lt;p&gt;The idle time of a pending message in Redis Streams is measured from the &lt;strong&gt;last time it was delivered&lt;/strong&gt;. It does not care that your handler is still running. Redis has no way to know your worker is alive; it only knows nobody has touched the message for 15 minutes.&lt;/p&gt;

&lt;p&gt;So "reclaim anything idle for more than N minutes" really means "re-run any job that takes longer than N minutes". With N = 15 and a job that runs for hours, every worker in the pool eventually joins in.&lt;/p&gt;

&lt;p&gt;The tempting fix is to raise the timeout to 2 hours, or 12. That only moves the cliff. The next job that runs longer than your new number does the same thing, and in the meantime, genuinely dead workers keep their messages for hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: lease renewal with XCLAIM
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;XCLAIM&lt;/code&gt; with &lt;code&gt;min_idle_time=0&lt;/code&gt; on a message you already own resets its idle time to zero. With &lt;code&gt;JUSTID&lt;/code&gt; it does not re-deliver the payload or bump the delivery counter. In effect, it is a heartbeat: "I still have this, I'm still working."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;heartbeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xclaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="n"&gt;min_idle_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_ids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;justid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We run it from a background thread for as long as the handler runs:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;HEARTBEAT_INTERVAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;  &lt;span class="c1"&gt;# seconds, well under the 15-minute reclaim timeout
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TaskHeartbeat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_stop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__enter__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_stop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HEARTBEAT_INTERVAL&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;heartbeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lease renewal failed for %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__exit__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_stop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;TaskHeartbeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;STREAM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GROUP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STREAM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GROUP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The thread is a daemon.&lt;/strong&gt; If the worker process really dies, the heartbeat dies with it, the idle time starts growing again, and &lt;code&gt;XAUTOCLAIM&lt;/code&gt; takes over exactly as before. We did not trade away crash recovery to get this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The interval is far below the timeout.&lt;/strong&gt; 2 minutes against 15 means a few missed heartbeats (a Redis blip, a GC pause) still don't trigger a reclaim.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A second safety net
&lt;/h2&gt;

&lt;p&gt;We also keep job state in a database table (&lt;code&gt;queued&lt;/code&gt; / &lt;code&gt;running&lt;/code&gt; / &lt;code&gt;done&lt;/code&gt;, plus &lt;code&gt;updated_at&lt;/code&gt;). When a worker reclaims a stale message, it now checks that row first. If the job is still &lt;code&gt;running&lt;/code&gt; and was updated recently, the original worker is probably alive, so it skips the job without acking it. The message now belongs to the new consumer with a fresh idle time, so it won't come back for another 15 minutes, and when the original worker finishes, its &lt;code&gt;XACK&lt;/code&gt; still works because acks don't check ownership.&lt;/p&gt;

&lt;p&gt;With the heartbeat in place this branch almost never fires. It is there in case the heartbeat thread itself breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  A related trap: "self-healing" that amplifies itself
&lt;/h2&gt;

&lt;p&gt;While cleaning up we found a second, similar bug. Another job type had a healer: if a job had been &lt;code&gt;queued&lt;/code&gt; for more than 2 minutes, it re-enqueued it, bypassing the dedupe check, in case the message had been lost.&lt;/p&gt;

&lt;p&gt;When the queue got deep, every job waited more than 2 minutes, so every job got re-enqueued, which made the queue deeper. We found &lt;strong&gt;20,669 messages representing 103 real jobs&lt;/strong&gt;, one of them re-enqueued 230 times in 10 hours.&lt;/p&gt;

&lt;p&gt;The fix was to stop judging &lt;code&gt;queued&lt;/code&gt; by the clock. Before re-enqueuing, check whether the stream still has unconsumed messages (&lt;code&gt;XLEN &amp;gt; 0&lt;/code&gt; or the group has pending entries). If it does, workers are alive and the job is just waiting its turn. Only when the queue has fully drained is a missing job actually lost. On any error, the check answers "don't re-enqueue". Doing one job too few is cheap; adding one more copy is what caused the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;In Redis Streams, pending idle time means "time since delivery", not "time since the worker last showed signs of life".&lt;/li&gt;
&lt;li&gt;A reclaim timeout without lease renewal is really a maximum job duration. Anything longer gets run again by every worker in turn.&lt;/li&gt;
&lt;li&gt;Renew leases with &lt;code&gt;XCLAIM min_idle_time=0 JUSTID&lt;/code&gt; from a daemon thread. Keep crash recovery, lose the duplicates.&lt;/li&gt;
&lt;li&gt;Never decide a &lt;code&gt;queued&lt;/code&gt; job is lost just because it has waited a while. Deep queues are normal; check whether the queue is actually moving.&lt;/li&gt;
&lt;li&gt;"The scheduler says the queue is full" and "the queue is actually moving" are different facts. Ours reported a full queue for 14 hours because the stuck jobs never finished.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're curious what the workers actually fetch, it's Google search ad data. You can look up any domain on &lt;a href="https://ppceye.com" rel="noopener noreferrer"&gt;ppceye.com&lt;/a&gt; to see which advertisers send ads to it.&lt;/p&gt;

&lt;p&gt;Update: I've packaged the lease as a small library, &lt;a href="https://github.com/goodluck8866/redis-streams-lease" rel="noopener noreferrer"&gt;redis-streams-lease on GitHub&lt;/a&gt;. It's one file under the MIT license, with tests that run against a real Redis.&lt;/p&gt;

</description>
      <category>redis</category>
      <category>python</category>
      <category>backend</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
