<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[PPCEye Engineering]]></title><description><![CDATA[Engineering notes from PPCEye (ppceye.com), a Google Ads competitor research tool built on Google's public ad archive: Redis Streams job queues, Python workers,]]></description><link>https://ppceye.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ad968a58dc099e1457a7e2d/a4c9fb09-88b0-4f85-b505-2f8e18e5df16.png</url><title>PPCEye Engineering</title><link>https://ppceye.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 24 Oct 2026 23:09:28 GMT</lastBuildDate><atom:link href="https://ppceye.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Long-Running Jobs on Redis Streams: Stop XAUTOCLAIM From Running Them Twice]]></title><description><![CDATA[Why XAUTOCLAIM re-runs long Redis Streams jobs, and how to fix it with an XCLAIM JUSTID lease that keeps crash recovery. Runnable Python included.]]></description><link>https://ppceye.hashnode.dev/long-running-jobs-on-redis-streams-stop-xautoclaim-from-running-them-twice</link><guid isPermaLink="true">https://ppceye.hashnode.dev/long-running-jobs-on-redis-streams-stop-xautoclaim-from-running-them-twice</guid><category><![CDATA[Redis]]></category><category><![CDATA[Python]]></category><category><![CDATA[backend]]></category><category><![CDATA[distributed systems]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Weishu]]></dc:creator><pubDate>Thu, 22 Oct 2026 01:45:42 GMT</pubDate><content:encoded><![CDATA[<p>If your Redis Streams workers use <code>XAUTOCLAIM</code> to recover jobs from dead consumers, every job that runs longer than your <code>min_idle_time</code> will be picked up by a second worker while the first one is still working on it. Then by a third. This tutorial reproduces the problem in about 40 lines of Python, explains the one Redis rule behind it, and fixes it with a lease that keeps crash recovery intact.</p>
<p>We learned this the hard way: one multi-hour fetch job got reclaimed every 15 minutes until all six of our workers were running it at once. That story is in <a href="https://dev.to/weishu_ppceye/one-long-job-took-down-our-whole-redis-streams-worker-pool-2pg4">One long job took down our whole Redis Streams worker pool</a>. This post is the how-to.</p>
<h2>The rule: idle time counts from delivery, not from activity</h2>
<p>A pending message's idle time is the time since it was last <strong>delivered</strong> to a consumer. Your handler can be busy, healthy and logging away; Redis doesn't know and doesn't care. All it sees is a message that nobody has touched.</p>
<p>So this common reclaim loop:</p>
<pre><code class="language-python">r.xautoclaim(STREAM, GROUP, me, min_idle_time=15 * 60 * 1000, start_id="0-0")
</code></pre>
<p>doesn't mean "take over jobs whose worker died". It means "take over any job that has been running for more than 15 minutes". For short jobs those are the same thing. For long ones they are not.</p>
<h2>Reproduce it</h2>
<p>Two consumers on one stream. <code>worker-a</code> takes a job that runs for 6 seconds. <code>worker-b</code> runs the standard reclaim loop with a 2-second threshold (scaled down from 15 minutes so the demo is quick):</p>
<pre><code class="language-python">import threading, time, redis

r = redis.Redis(db=15)
STREAM, GROUP = "demo:jobs", "workers"
RECLAIM_MS = 2000
r.xgroup_create(STREAM, GROUP, id="0", mkstream=True)

r.xadd(STREAM, {"job": "big"})
[[_, [(mid, _)]]] = r.xreadgroup(GROUP, "worker-a", {STREAM: "&gt;"}, count=1)

stolen = []
def reclaimer():
    t0, end = time.time(), time.time() + 6
    while time.time() &lt; end:
        _, batch, *_ = r.xautoclaim(STREAM, GROUP, "worker-b",
                                    min_idle_time=RECLAIM_MS, start_id="0-0", count=1)
        if batch:
            stolen.append(round(time.time() - t0, 1))
        time.sleep(0.2)

t = threading.Thread(target=reclaimer); t.start()
time.sleep(6)          # worker-a is "working"
t.join()

p = r.xpending_range(STREAM, GROUP, min=mid, max=mid, count=1)[0]
print("claimed by worker-b at", stolen, "| owner", p["consumer"],
      "| times_delivered", p["times_delivered"])
</code></pre>
<p>Output:</p>
<pre><code class="language-plaintext">claimed by worker-b at [2.0, 4.0] | owner b'worker-b' | times_delivered 3
</code></pre>
<p><code>worker-a</code> never stopped working, yet the job was handed out twice more. In a real pool, each of those claims is a different worker starting the same job from scratch.</p>
<h2>Why raising the timeout doesn't fix it</h2>
<p>The obvious patch is <code>min_idle_time=2 hours</code>. That just moves the cliff: the next job that runs for 2 hours and 1 minute does the same thing. Meanwhile, a worker that really did crash keeps its job locked for 2 hours before anyone picks it up. You've made recovery slower without making long jobs safe.</p>
<p>What you want is for "idle" to mean "nobody has heard from this worker lately". That means the worker has to say something.</p>
<h2>The fix: renew the lease with <code>XCLAIM ... JUSTID</code></h2>
<p><code>XCLAIM</code> with <code>min_idle_time=0</code> on a message resets its idle time to zero. With <code>JUSTID</code> it doesn't return the payload and <strong>doesn't increment the delivery counter</strong>, so it's a pure "I'm still here".</p>
<p>Run that from a background thread every couple of minutes while the handler works, and the reclaim loop never sees the job as idle. Stop it when the job finishes.</p>
<h3>The check most heartbeats forget</h3>
<p>A plain <code>XCLAIM &lt;id&gt; 0</code> succeeds <strong>no matter who currently owns the message</strong>. Picture this:</p>
<ol>
<li><p><code>worker-a</code> stalls for 20 minutes (a long GC pause, a frozen VM, a network partition).</p>
</li>
<li><p><code>worker-b</code> reclaims the job, correctly, and starts it.</p>
</li>
<li><p><code>worker-a</code> wakes up and its heartbeat runs <code>XCLAIM &lt;id&gt; 0</code> again.</p>
</li>
</ol>
<p>Now the message belongs to <code>worker-a</code> again, and both workers are running the job. So before renewing, check that you still own it:</p>
<pre><code class="language-python">def renew(r, stream, group, consumer, message_id):
    pending = r.xpending_range(stream, group, min=message_id, max=message_id, count=1)
    if not pending:
        raise LeaseLost("acked elsewhere")
    if pending[0]["consumer"].decode() != consumer:
        raise LeaseLost("now owned by another worker")
    r.xclaim(stream, group, consumer, min_idle_time=0,
             message_ids=[message_id], justid=True)
</code></pre>
<p>(There is a tiny window between the two calls. It's acceptable here because the alternative failure, losing ownership for good, only happens after a stall far longer than the renewal interval.)</p>
<h2>Using the library</h2>
<p>We packaged that pattern, the thread and the ownership check into <a href="https://github.com/goodluck8866/redis-streams-lease">redis-streams-lease</a>, one MIT-licensed file with no dependencies besides <code>redis</code>:</p>
<pre><code class="language-bash">pip install git+https://github.com/goodluck8866/redis-streams-lease
</code></pre>
<p>A complete worker:</p>
<pre><code class="language-python">import socket, redis
from redis_streams_lease import Lease

STREAM, GROUP = "jobs", "workers"
ME = socket.gethostname()
RECLAIM_IDLE_MS = 15 * 60 * 1000   # a worker silent for 15 min is presumed dead
LEASE_INTERVAL = 120               # renew every 2 min

r = redis.Redis()

while True:
    # First, jobs whose worker really died (its lease stopped renewing).
    _, batch, *_ = r.xautoclaim(STREAM, GROUP, ME, min_idle_time=RECLAIM_IDLE_MS,
                                start_id="0-0", count=1)
    if not batch:
        resp = r.xreadgroup(GROUP, ME, {STREAM: "&gt;"}, count=1, block=4000)
        batch = resp[0][1] if resp else []

    for message_id, fields in batch:
        with Lease(r, STREAM, GROUP, ME, message_id, interval=LEASE_INTERVAL) as lease:
            handle(fields)                     # may take hours
        if not lease.lost:
            r.xack(STREAM, GROUP, message_id)
</code></pre>
<p>Rerun the reproduction with the handler wrapped in <code>Lease(..., interval=0.5)</code>:</p>
<pre><code class="language-plaintext">lease renewals 11 lost False
claimed by worker-b at [] | owner b'worker-a' | times_delivered 1
</code></pre>
<p>No reclaims, and the delivery counter never moved.</p>
<h2>Crash recovery still works</h2>
<p>The renewal thread is a <strong>daemon thread</strong>. If the worker process dies, the thread dies with it, idle time starts growing again, and the ordinary <code>XAUTOCLAIM</code> loop takes over. Simulating that by stopping a lease without acking:</p>
<pre><code class="language-plaintext">after crash, reclaimed in 1.5 s
</code></pre>
<p>That's the 2-second threshold minus the half-second since the last renewal, which is exactly the behaviour you want: a dead worker's job is recovered on the normal schedule, and a live worker's job is left alone.</p>
<p><code>lease.lost</code> covers the remaining case. If the lease was lost (another worker took over after a long stall), the handler finishes but does <strong>not</strong> ack, so it doesn't acknowledge a message it no longer owns. Pass <code>on_lost=</code> if you want to cancel the work early instead.</p>
<h2>Picking the numbers</h2>
<p>The renewal interval has to be well below the reclaim threshold, so a few missed renewals don't hand the job away.</p>
<table>
<thead>
<tr>
<th>Reclaim <code>min_idle_time</code></th>
<th>Renewal <code>interval</code></th>
<th>Missed renewals tolerated</th>
</tr>
</thead>
<tbody><tr>
<td>15 min</td>
<td>2 min</td>
<td>about 6</td>
</tr>
<tr>
<td>5 min</td>
<td>1 min</td>
<td>about 4</td>
</tr>
<tr>
<td>60 s</td>
<td>10 s</td>
<td>about 5</td>
</tr>
</tbody></table>
<p>The reclaim threshold now only has one job, deciding how long a <strong>silent</strong> worker is given before it's presumed dead. It no longer caps how long a job may run, so you can keep it short.</p>
<h2>Other approaches, compared</h2>
<table>
<thead>
<tr>
<th>Approach</th>
<th>Long jobs safe?</th>
<th>Dead-worker recovery</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td>Raise <code>min_idle_time</code></td>
<td>Only up to the new limit</td>
<td>Slower, by the same amount</td>
<td>One number</td>
</tr>
<tr>
<td>Split work into short jobs</td>
<td>Yes</td>
<td>Fast</td>
<td>Redesign every handler; some work doesn't split cleanly</td>
</tr>
<tr>
<td>Separate lock key (<code>SET NX PX</code> + renew)</td>
<td>Yes</td>
<td>Fast</td>
<td>A second piece of state that can drift from the stream's pending list</td>
</tr>
<tr>
<td>Check a DB status row before running a reclaimed job</td>
<td>Mostly</td>
<td>Fast</td>
<td>Extra query; still needs a "fresh enough" rule</td>
</tr>
<tr>
<td><strong>Lease via</strong> <code>XCLAIM JUSTID</code></td>
<td><strong>Yes</strong></td>
<td><strong>Fast</strong></td>
<td><strong>One background thread per running job</strong></td>
</tr>
</tbody></table>
<p>Splitting jobs is still worth doing when it's natural, and a status check makes a good second line of defence (we run one). But the lease is the only option that fixes the root cause in the stream itself, without new state to keep in sync.</p>
<h2>FAQ</h2>
<p><strong>Does</strong> <code>XCLAIM ... JUSTID</code> <strong>increment the delivery counter?</strong> No. <code>JUSTID</code> claims without re-delivering, so <code>times_delivered</code> stays where it was. The reproduction above shows it staying at 1 through 11 renewals.</p>
<p><strong>Which Redis version do I need?</strong><code>XAUTOCLAIM</code> needs Redis 6.2+. The lease itself only uses <code>XPENDING</code> and <code>XCLAIM</code>, which exist since 5.0. We tested on Redis 7.0 with redis-py 8.1.</p>
<p><strong>What if Redis restarts while a job is running?</strong> The renewal fails, gets logged, and is retried on the next tick. If Redis stays down long enough for the job to look idle, it gets reclaimed, which is the right outcome when a worker genuinely can't be reached.</p>
<p><strong>Should I use threads or asyncio?</strong> The library uses a thread because most queue workers are synchronous, and a thread keeps renewing even while the handler blocks the event loop. In an asyncio worker, call <code>renew()</code> from a task on a timer instead.</p>
<p><strong>What about jobs that were already duplicated?</strong> The lease prevents new duplicates. If your handlers aren't idempotent yet, make the writes upserts too: even with a lease, a worker that stalls past the threshold will lose its job to another one.</p>
<hr />
<p>The workers this came from fetch Google search ad data for <a href="https://ppceye.com">PPCEye</a>, which shows which advertisers run search ads for any domain. The pattern itself has nothing to do with ads: if you run long jobs on Redis Streams, you probably want it. Issues and PRs are welcome on <a href="https://github.com/goodluck8866/redis-streams-lease">GitHub</a>.</p>
]]></content:encoded></item></channel></rss>