You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
RedisStreamBroker: autoclaim lock has no timeout, so a SIGKILLed worker permanently blocks message reclaim #131
RedisStreamBroker.listen() guards its XAUTOCLAIM with a Redis lock created without a timeout. redis-py's Lock defaults to timeout=None, so the key never expires. A worker killed (SIGKILL, OOM, container eviction) while holding that lock leaves it set permanently, and every subsequent worker takes the continue branch and never reclaims pending messages again.
The practical effect: crash recovery works after a graceful shutdown and fails silently after a hard kill — which is the case the pending-entries list exists for.
redis_conn.lock(name) is called with no timeout; redis.asyncio.Redis.lock has timeout: Optional[float] = None.
Reproduction
importasyncio, redis.asyncioasrasyncdefmain():
c=r.from_url("redis://localhost:6379", decode_responses=True)
awaitc.xadd("t:stream", {"data": "x"})
awaitc.xgroup_create("t:stream", "g", id="0", mkstream=True)
awaitc.xreadgroup("g", "dead-consumer", {"t:stream": ">"}, count=10)
print("pending:", (awaitc.xpending("t:stream", "g"))["pending"]) # 1# a worker SIGKILLed inside `async with lock:` leaves this behindlock=c.lock("autoclaim:g:t:stream") # timeout=None, as in listen()awaitlock.acquire()
print("lock TTL:", awaitc.ttl("autoclaim:g:t:stream")) # -1, never expires# a fresh worker follows the same guardfresh=c.lock("autoclaim:g:t:stream")
ifawaitfresh.locked():
print("fresh worker skips xautoclaim -> reclaimed 0")
awaitc.delete("autoclaim:g:t:stream")
print("after clearing the lock -> reclaimed",
len((awaitc.xautoclaim("t:stream", "g", "fresh", min_idle_time=0))[1])) # 1asyncio.run(main())
Output:
pending: 1
lock TTL: -1
fresh worker skips xautoclaim -> reclaimed 0
after clearing the lock -> reclaimed 1
Observed end to end as well: a worker running with --ack-type when_executed, SIGKILLed mid-task, leaves its message unacked; a replacement worker never picks it up.
Give the lock a bounded lifetime, e.g. redis_conn.lock(key, timeout=self.idle_timeout / 1000) or a small fixed value. Deleting a stale key from outside isn't safe for a consumer to do on its own — a live holder is byte-identical to an orphaned one — so the expiry needs to come from the construction site.
Activity