Skip to content

Commit 4460660

Browse files
committed
fix Redis stream reclaim edge cases
1 parent d8531fc commit 4460660

3 files changed

Lines changed: 206 additions & 97 deletions

File tree

‎README.md‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,6 @@ For a single-stream broker, `xread_count` also prevents one worker from hoarding
113113

114114
When a listener is closed with messages already fetched from redis but not yet yielded to taskiq, those buffered entries are claimed to an internal `abandoned` consumer and stamped as very idle. The next reclaim sweep can recover them immediately instead of waiting for `idle_timeout`.
115115

116-
If the consumer group is removed out of band, the broker recreates it on the next `XREADGROUP` so an in-use queue heals instead of spinning on errors.
117-
118116
The legacy `unacknowledged_lock_timeout` parameter is deprecated and ignored; reclaim correctness now relies on the `XCLAIM` min-idle-time check instead of a broker-side redis lock.
119117

120118
## RedisAsyncResultBackend configuration

‎taskiq_redis/redis_broker.py‎

Lines changed: 72 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313
cast,
1414
)
1515

16-
from redis.asyncio import BlockingConnectionPool, Connection, Redis, ResponseError
16+
from redis.asyncio import (
17+
BlockingConnectionPool,
18+
Connection,
19+
Redis,
20+
RedisError,
21+
ResponseError,
22+
)
1723
from taskiq import AckableMessage
1824
from taskiq.abc.broker import AsyncBroker
1925
from taskiq.abc.result_backend import AsyncResultBackend
@@ -327,16 +333,15 @@ def _ack_generator(
327333
async def _ack() -> None:
328334
nonlocal acked
329335
async with Redis(connection_pool=self.connection_pool) as redis_conn:
330-
try:
331-
await redis_conn.xack(
332-
queue_name,
333-
self.consumer_group_name,
334-
id,
335-
)
336-
finally:
337-
if not acked and on_ack is not None:
338-
acked = True
339-
on_ack()
336+
await redis_conn.xack(
337+
queue_name,
338+
self.consumer_group_name,
339+
id,
340+
)
341+
if not acked:
342+
acked = True
343+
if on_ack is not None:
344+
on_ack()
340345

341346
return _ack
342347

@@ -380,45 +385,39 @@ async def _claim_timed_out_messages(
380385
stream: str,
381386
count: int,
382387
protected_message_ids: set[tuple[str, str]],
383-
) -> list[tuple[str, dict[bytes, bytes]]]:
388+
pending_start: str,
389+
) -> tuple[list[tuple[str, dict[bytes, bytes]]], str]:
384390
"""Claim pending messages that exceeded their reclaim deadline.
385391
386392
protected_message_ids contains entries delivered by this listener
387393
instance and not acked yet. We skip those to avoid re-delivering our own
388394
in-flight work, while still allowing a restarted worker with the same
389395
Redis consumer name to recover messages left by its predecessor.
390396
"""
391-
try:
392-
pending = await redis_conn.xpending_range(
393-
stream,
394-
self.consumer_group_name,
395-
min="-",
396-
max="+",
397-
count=self.unacknowledged_batch_size,
398-
idle=0,
399-
)
400-
except ResponseError as exc:
401-
if "NOGROUP" not in str(exc):
402-
raise
403-
logger.info("Consumer group missing for %s, recreating", stream)
404-
await self._declare_consumer_group()
405-
return []
397+
pending = await redis_conn.xpending_range(
398+
stream,
399+
self.consumer_group_name,
400+
min=pending_start,
401+
max="+",
402+
count=self.unacknowledged_batch_size,
403+
idle=0,
404+
)
406405

407406
claimed: list[tuple[str, dict[bytes, bytes]]] = []
407+
last_checked_id: str | None = None
408408
for pending_message in pending:
409409
if len(claimed) >= count:
410410
break
411411

412-
message_id = pending_message["message_id"]
413-
if isinstance(message_id, bytes):
414-
message_id = message_id.decode()
412+
message_id = self._to_str(pending_message["message_id"])
413+
last_checked_id = message_id
415414
if self._message_key(stream, message_id) in protected_message_ids:
416415
continue
417416

418417
message = await self._get_pending_message(
419418
redis_conn,
420419
stream,
421-
cast(str, message_id),
420+
message_id,
422421
)
423422
if message is None:
424423
await redis_conn.xclaim(
@@ -448,26 +447,37 @@ async def _claim_timed_out_messages(
448447
message_ids=[message_id],
449448
)
450449
claimed.extend(cast("list[tuple[str, dict[bytes, bytes]]]", result))
451-
return claimed
450+
next_pending_start = "-"
451+
if (
452+
len(pending) == self.unacknowledged_batch_size
453+
and last_checked_id is not None
454+
):
455+
next_pending_start = f"({last_checked_id}"
456+
return claimed, next_pending_start
452457

453458
async def _claim_available_timed_out_messages(
454459
self,
455460
redis_conn: Redis,
456461
count: int,
457462
protected_message_ids: set[tuple[str, str]],
463+
pending_starts: dict[str, str],
458464
) -> list[tuple[str, dict[bytes, bytes], str]]:
459465
"""Claim overdue messages across all configured streams."""
460466
claimed: list[tuple[str, dict[bytes, bytes], str]] = []
461467
for stream in [self.queue_name, *self.additional_streams.keys()]:
462468
remaining_count = count - len(claimed)
463469
if remaining_count <= 0:
464470
break
465-
for msg_id, msg in await self._claim_timed_out_messages(
466-
redis_conn,
467-
stream,
468-
remaining_count,
469-
protected_message_ids,
470-
):
471+
claimed_messages, pending_starts[stream] = (
472+
await self._claim_timed_out_messages(
473+
redis_conn,
474+
stream,
475+
remaining_count,
476+
protected_message_ids,
477+
pending_starts.get(stream, "-"),
478+
)
479+
)
480+
for msg_id, msg in claimed_messages:
471481
claimed.append((msg_id, msg, stream))
472482
return claimed
473483

@@ -476,25 +486,18 @@ async def _read_new_messages(
476486
redis_conn: Redis,
477487
count: int | None,
478488
) -> Any:
479-
"""Read newly added stream messages, recreating a missing group once."""
480-
try:
481-
return await redis_conn.xreadgroup(
482-
self.consumer_group_name,
483-
self.consumer_name,
484-
{
485-
self.queue_name: ">",
486-
**self.additional_streams, # type: ignore[dict-item]
487-
},
488-
block=self.block,
489-
noack=False,
490-
count=count,
491-
)
492-
except ResponseError as exc:
493-
if "NOGROUP" not in str(exc):
494-
raise
495-
logger.info("Consumer group missing, recreating")
496-
await self._declare_consumer_group()
497-
return []
489+
"""Read newly added stream messages."""
490+
return await redis_conn.xreadgroup(
491+
self.consumer_group_name,
492+
self.consumer_name,
493+
{
494+
self.queue_name: ">",
495+
**self.additional_streams, # type: ignore[dict-item]
496+
},
497+
block=self.block,
498+
noack=False,
499+
count=count,
500+
)
498501

499502
async def _abandon_buffered_messages(
500503
self,
@@ -517,13 +520,12 @@ async def _abandon_buffered_messages(
517520
idle=ABANDONED_IDLE_MS,
518521
justid=True,
519522
)
520-
except ResponseError as exc:
521-
if "NOGROUP" not in str(exc):
522-
logger.warning(
523-
"Failed to abandon messages in stream %s",
524-
stream,
525-
exc_info=True,
526-
)
523+
except RedisError:
524+
logger.warning(
525+
"Failed to abandon messages in stream %s",
526+
stream,
527+
exc_info=True,
528+
)
527529

528530
def _build_ackable_message(
529531
self,
@@ -578,12 +580,14 @@ async def _build_reclaimed_ackable_messages(
578580
redis_conn: Redis,
579581
count: int,
580582
delivered: set[tuple[str, str]],
583+
pending_starts: dict[str, str],
581584
) -> list[tuple[str, dict[bytes, bytes], str]]:
582585
"""Claim overdue messages for taskiq's receiver."""
583586
return await self._claim_available_timed_out_messages(
584587
redis_conn,
585588
count or self.unacknowledged_batch_size,
586589
delivered,
590+
pending_starts,
587591
)
588592

589593
async def _build_due_reclaimed_messages(
@@ -592,6 +596,7 @@ async def _build_due_reclaimed_messages(
592596
count: int,
593597
delivered: set[tuple[str, str]],
594598
last_reclaim: float,
599+
pending_starts: dict[str, str],
595600
) -> tuple[float, list[tuple[str, dict[bytes, bytes], str]]]:
596601
"""Return overdue pending messages only when the reclaim interval elapsed."""
597602
if not self._should_reclaim(last_reclaim):
@@ -600,6 +605,7 @@ async def _build_due_reclaimed_messages(
600605
redis_conn,
601606
count,
602607
delivered,
608+
pending_starts,
603609
)
604610

605611
async def _build_new_ackable_messages(
@@ -637,6 +643,7 @@ async def listen(self) -> AsyncGenerator[AckableMessage, None]:
637643
buffered: list[tuple[str, dict[bytes, bytes], str]] = []
638644
slot_freed = asyncio.Event()
639645
last_reclaim = 0.0
646+
pending_starts: dict[str, str] = {}
640647

641648
def on_ack(message_key: tuple[str, str]) -> None:
642649
nonlocal unacked
@@ -665,6 +672,7 @@ def _on_ack() -> None:
665672
count or self.unacknowledged_batch_size,
666673
delivered,
667674
last_reclaim,
675+
pending_starts,
668676
)
669677
unacked += len(buffered)
670678
async for message in self._yield_buffered_messages(

0 commit comments

Comments
 (0)