-
Notifications
You must be signed in to change notification settings - Fork 164
feat(BA-4127): Add exponential backoff for Redis consumer reconnection #8387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Add exponential backoff for Redis consumer reconnection |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,7 +3,9 @@ | |||||||||||||||||||||||||||||||||||||||||||||
| import asyncio | ||||||||||||||||||||||||||||||||||||||||||||||
| import hashlib | ||||||||||||||||||||||||||||||||||||||||||||||
| import logging | ||||||||||||||||||||||||||||||||||||||||||||||
| import random | ||||||||||||||||||||||||||||||||||||||||||||||
| import socket | ||||||||||||||||||||||||||||||||||||||||||||||
| import time | ||||||||||||||||||||||||||||||||||||||||||||||
| from collections.abc import AsyncGenerator, Iterable | ||||||||||||||||||||||||||||||||||||||||||||||
| from dataclasses import dataclass | ||||||||||||||||||||||||||||||||||||||||||||||
| from typing import Optional, Self, override | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -28,6 +30,24 @@ | |||||||||||||||||||||||||||||||||||||||||||||
| _DEFAULT_READ_COUNT = 64 | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| @dataclass | ||||||||||||||||||||||||||||||||||||||||||||||
| class _BackoffState: | ||||||||||||||||||||||||||||||||||||||||||||||
| """Tracks exponential backoff state for a stream.""" | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| attempt: int = 0 | ||||||||||||||||||||||||||||||||||||||||||||||
| last_error_time: float = 0.0 | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| def increment(self) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||
| """Increment attempt counter and record error time.""" | ||||||||||||||||||||||||||||||||||||||||||||||
| self.attempt += 1 | ||||||||||||||||||||||||||||||||||||||||||||||
| self.last_error_time = time.monotonic() | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| def reset(self) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||
| """Reset backoff state after successful operation.""" | ||||||||||||||||||||||||||||||||||||||||||||||
| self.attempt = 0 | ||||||||||||||||||||||||||||||||||||||||||||||
| self.last_error_time = 0.0 | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| @dataclass | ||||||||||||||||||||||||||||||||||||||||||||||
| class RedisConsumerArgs: | ||||||||||||||||||||||||||||||||||||||||||||||
| stream_keys: Iterable[str] | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -36,6 +56,9 @@ class RedisConsumerArgs: | |||||||||||||||||||||||||||||||||||||||||||||
| db: int = 0 | ||||||||||||||||||||||||||||||||||||||||||||||
| autoclaim_idle_timeout: int = _DEFAULT_AUTOCLAIM_IDLE_TIMEOUT | ||||||||||||||||||||||||||||||||||||||||||||||
| autoclaim_start_id: Optional[str] = None | ||||||||||||||||||||||||||||||||||||||||||||||
| backoff_initial_delay: float = 0.1 # 100ms first retry | ||||||||||||||||||||||||||||||||||||||||||||||
| backoff_max_delay: float = 30.0 # cap at 30 seconds | ||||||||||||||||||||||||||||||||||||||||||||||
| backoff_max_attempts: Optional[int] = None # None = infinite retry | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| class RedisConsumer(AbstractConsumer): | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -55,6 +78,10 @@ class RedisConsumer(AbstractConsumer): | |||||||||||||||||||||||||||||||||||||||||||||
| _autoclaim_idle_timeout: int | ||||||||||||||||||||||||||||||||||||||||||||||
| _closed: bool | ||||||||||||||||||||||||||||||||||||||||||||||
| _loop_tasks: list[asyncio.Task] | ||||||||||||||||||||||||||||||||||||||||||||||
| _backoff_initial_delay: float | ||||||||||||||||||||||||||||||||||||||||||||||
| _backoff_max_delay: float | ||||||||||||||||||||||||||||||||||||||||||||||
| _backoff_max_attempts: Optional[int] | ||||||||||||||||||||||||||||||||||||||||||||||
| _backoff_state: dict[str, _BackoffState] | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| def __init__( | ||||||||||||||||||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -83,6 +110,12 @@ def __init__( | |||||||||||||||||||||||||||||||||||||||||||||
| self._autoclaim_idle_timeout = args.autoclaim_idle_timeout | ||||||||||||||||||||||||||||||||||||||||||||||
| self._closed = False | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| # Backoff configuration | ||||||||||||||||||||||||||||||||||||||||||||||
| self._backoff_initial_delay = args.backoff_initial_delay | ||||||||||||||||||||||||||||||||||||||||||||||
| self._backoff_max_delay = args.backoff_max_delay | ||||||||||||||||||||||||||||||||||||||||||||||
| self._backoff_max_attempts = args.backoff_max_attempts | ||||||||||||||||||||||||||||||||||||||||||||||
| self._backoff_state = {} | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| start_id = args.autoclaim_start_id or "0-0" | ||||||||||||||||||||||||||||||||||||||||||||||
| self._loop_tasks = [] | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -221,6 +254,7 @@ async def _read_messages_loop(self, stream_key: str) -> None: | |||||||||||||||||||||||||||||||||||||||||||||
| while not self._closed: | ||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||
| await self._read_messages(client, stream_key) | ||||||||||||||||||||||||||||||||||||||||||||||
| self._reset_backoff(stream_key) | ||||||||||||||||||||||||||||||||||||||||||||||
| except glide.ClosingError: | ||||||||||||||||||||||||||||||||||||||||||||||
| log.info( | ||||||||||||||||||||||||||||||||||||||||||||||
| "Client connection closed, stopping read messages loop for stream {}", | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -229,8 +263,10 @@ async def _read_messages_loop(self, stream_key: str) -> None: | |||||||||||||||||||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||||||||||||||||||||
| except glide.GlideError as e: | ||||||||||||||||||||||||||||||||||||||||||||||
| await self._failover_consumer(stream_key, e) | ||||||||||||||||||||||||||||||||||||||||||||||
| await self._handle_backoff(stream_key) | ||||||||||||||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||||||||||||||
| log.error("Error while reading messages from stream {}: {}", stream_key, e) | ||||||||||||||||||||||||||||||||||||||||||||||
| await self._handle_backoff(stream_key) | ||||||||||||||||||||||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||||||||||||||||||||||
| await client.close() | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -277,6 +313,7 @@ async def _auto_claim_loop( | |||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
| if claimed: | ||||||||||||||||||||||||||||||||||||||||||||||
| autoclaim_start_id = next_start_id | ||||||||||||||||||||||||||||||||||||||||||||||
| self._reset_backoff(stream_key) | ||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+316
to
317
|
||||||||||||||||||||||||||||||||||||||||||||||
| self._reset_backoff(stream_key) | |
| continue | |
| self._reset_backoff(stream_key) |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PR adds backoff_max_attempts to RedisConsumerArgs, but _handle_backoff() never enforces it. This makes the configuration misleading and prevents callers from bounding retries. Consider checking self._backoff_max_attempts after state.increment() and, when exceeded, either raise (to stop the loop), close the consumer, or log and rethrow the last exception so the task fails deterministically.
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PR adds backoff_max_attempts to RedisConsumerArgs, but _handle_backoff() never enforces it. This makes the configuration misleading and prevents callers from bounding retries. Consider checking self._backoff_max_attempts after state.increment() and, when exceeded, either raise (to stop the loop), close the consumer, or log and rethrow the last exception so the task fails deterministically.
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Backoff sleep does not account for shutdown. If self._closed becomes true while sleeping, the task won’t observe it until the sleep completes, which can delay shutdown by up to backoff_max_delay. Consider short-circuiting before sleeping when closed, or sleeping in a way that can be interrupted (e.g., waiting on a shutdown event / task cancellation-aware wait).
| await asyncio.sleep(actual_delay) | |
| # Sleep in small chunks so that shutdown (_closed) can be observed promptly. | |
| if getattr(self, "_closed", False): | |
| return | |
| loop = asyncio.get_running_loop() | |
| deadline = loop.time() + actual_delay | |
| while True: | |
| if getattr(self, "_closed", False): | |
| return | |
| remaining = deadline - loop.time() | |
| if remaining <= 0: | |
| break | |
| # Sleep for at most 1 second at a time to re-check shutdown state. | |
| try: | |
| await asyncio.sleep(min(1.0, remaining)) | |
| except asyncio.CancelledError: | |
| # Allow task cancellation to propagate for cooperative shutdown. | |
| raise |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
last_error_timeis written but (in this diff) never read, which adds state and an extra dependency (time.monotonic()) without affecting behavior. Either removelast_error_timeuntil it’s needed, or use it (e.g., for logging, metrics, or to avoid backoff increments within a small time window).