-
Notifications
You must be signed in to change notification settings - Fork 15
fix: notify Jira after queue retries exhaust #155
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
base: main
Are you sure you want to change the base?
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 |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
|
|
||
| import asyncio | ||
| import logging | ||
| import uuid | ||
| from collections import defaultdict | ||
| from collections.abc import Callable, Coroutine | ||
| from typing import Any | ||
|
|
@@ -24,8 +25,15 @@ | |
| # How often (seconds) the retry-queue poller wakes up | ||
| POLL_INTERVAL_SECONDS = 10 | ||
|
|
||
| # Terminal-failure callbacks are claimed across workers because every worker | ||
| # polls the shared retry sorted set. The short lease recovers if a worker dies | ||
| # while posting the notification; completed claims are retained for idempotency. | ||
| TERMINAL_FAILURE_CLAIM_PREFIX = "forge:queue:terminal-failure:" | ||
| TERMINAL_FAILURE_CLAIM_TTL_SECONDS = 300 | ||
|
|
||
| # Handler type for message processing | ||
| MessageHandler = Callable[[QueueMessage], Coroutine[Any, Any, None]] | ||
| TerminalFailureHandler = Callable[[QueueMessage, str], Coroutine[Any, Any, None]] | ||
|
|
||
|
|
||
| class QueueConsumer: | ||
|
|
@@ -41,6 +49,7 @@ def __init__( | |
| redis_client: redis.Redis | None = None, | ||
| jira_client: JiraClient | None = None, | ||
| max_concurrent_tasks: int | None = None, | ||
| terminal_failure_handler: TerminalFailureHandler | None = None, | ||
| ): | ||
| """Initialize the queue consumer. | ||
|
|
||
|
|
@@ -50,6 +59,8 @@ def __init__( | |
| jira_client: Optional Jira client for freshness checks. | ||
| max_concurrent_tasks: Maximum concurrent in-flight tasks. Defaults | ||
| to ``settings.queue_max_concurrent_tasks`` when not provided. | ||
| terminal_failure_handler: Optional callback invoked after a message | ||
| is moved to the dead-letter queue. | ||
| """ | ||
| self.consumer_name = consumer_name | ||
| self._redis = redis_client | ||
|
|
@@ -65,6 +76,7 @@ def __init__( | |
| self._semaphore = asyncio.Semaphore(concurrency) | ||
| self._active_tasks: set[asyncio.Task[None]] = set() | ||
| self._retry_queue = RetryQueue() | ||
| self._terminal_failure_handler = terminal_failure_handler | ||
|
|
||
| async def _get_redis(self) -> redis.Redis: | ||
| """Get or create Redis client.""" | ||
|
|
@@ -134,6 +146,59 @@ async def _ack(self, stream: str, message_id: str) -> None: | |
| redis_client = await self._get_redis() | ||
| await redis_client.xack(stream, CONSUMER_GROUP, message_id) | ||
|
|
||
| async def _notify_terminal_failure(self, message: QueueMessage, error: str) -> None: | ||
| """Invoke the terminal callback once per event without blocking DLQ handling.""" | ||
| if self._terminal_failure_handler is None: | ||
| return | ||
|
|
||
| claim_key = f"{TERMINAL_FAILURE_CLAIM_PREFIX}{message.event_id}" | ||
| claim_token = uuid.uuid4().hex | ||
| try: | ||
| redis_client = await self._get_redis() | ||
| claimed = await redis_client.set( | ||
| claim_key, | ||
| claim_token, | ||
| ex=TERMINAL_FAILURE_CLAIM_TTL_SECONDS, | ||
| nx=True, | ||
| ) | ||
| except Exception as claim_error: | ||
| logger.error( | ||
| f"Could not claim terminal failure callback for {message.event_id}: {claim_error}" | ||
| ) | ||
| return | ||
|
|
||
| if not claimed: | ||
| logger.info(f"Terminal failure already handled for event {message.event_id}") | ||
| return | ||
|
|
||
| try: | ||
| await self._terminal_failure_handler(message, error) | ||
| except Exception as callback_error: | ||
| logger.error( | ||
| f"Terminal failure callback failed for {message.event_id}: {callback_error}" | ||
| ) | ||
| return | ||
|
|
||
| try: | ||
| # A completed claim intentionally has no expiry: DLQ entries are | ||
| # retained too, and replaying one must not create another comment. | ||
| await redis_client.set(claim_key, "completed") | ||
| except Exception as completion_error: | ||
| logger.error( | ||
| f"Could not complete terminal failure claim for " | ||
| f"{message.event_id}: {completion_error}" | ||
| ) | ||
|
|
||
| async def _ack_terminal_message(self, message: QueueMessage, stream: str) -> None: | ||
| """Best-effort acknowledge a message after it has reached the DLQ.""" | ||
| try: | ||
| await self._ack(stream, message.message_id) | ||
| except Exception as ack_error: | ||
| logger.warning( | ||
| f"xack failed for terminal event {message.event_id}; " | ||
| f"PEL entry may linger: {ack_error}" | ||
| ) | ||
|
|
||
| async def _process_message( | ||
| self, | ||
| message: QueueMessage, | ||
|
|
@@ -193,7 +258,8 @@ async def _process_message( | |
| try: | ||
| moved_to_dlq = not await self._retry_queue.enqueue_for_retry(message, str(e)) | ||
| if moved_to_dlq: | ||
| await self._ack(stream, message.message_id) | ||
| await self._notify_terminal_failure(message, str(e)) | ||
| await self._ack_terminal_message(message, stream) | ||
| except Exception as retry_err: | ||
| logger.error( | ||
| f"Failed to enqueue {message.event_id} for retry: {retry_err}. " | ||
|
|
@@ -249,7 +315,7 @@ async def _process_retry_queue(self) -> None: | |
| """ | ||
| while self._running: | ||
| try: | ||
| entries = await self._retry_queue.get_due_messages() | ||
| entries = await self._retry_queue.claim_due_messages() | ||
| for entry in entries: | ||
| retry_stream = ( | ||
| JIRA_STREAM if entry.message.source == EventSource.JIRA else GITHUB_STREAM | ||
|
|
@@ -268,7 +334,12 @@ async def _process_retry_queue(self) -> None: | |
| # the counter keeps accumulating and the message can eventually | ||
| # reach the dead-letter queue. | ||
| await self._retry_queue.remove_from_retry_without_counter_reset(entry) | ||
| await self._retry_queue.enqueue_for_retry(entry.message, str(e)) | ||
| queued_for_retry = await self._retry_queue.enqueue_for_retry( | ||
| entry.message, str(e) | ||
| ) | ||
| if not queued_for_retry: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice job keeping notification failures from interrupting DLQ handling. Could we also acknowledge the original stream entry when
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 3f3036b. The retry-poller terminal path now performs a best-effort xack after the callback attempt, including when the callback fails. Added focused tests for both callback success and failure; the full unit suite and PR CI pass. |
||
| await self._notify_terminal_failure(entry.message, str(e)) | ||
| await self._ack_terminal_message(entry.message, retry_stream) | ||
| continue | ||
|
|
||
| # Message processing succeeded — clean up retry state and | ||
|
|
||
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.
Thanks for adding the cross-worker notification claim here. One concern: this claims only the callback; the retry entry itself is still read non-atomically by every worker via
get_due_messages(), so two pollers can process the same event concurrently. If one succeeds while another exhausts the counter, we could move a successfully processed event to the DLQ and post a misleading terminal-failure comment; concurrent failures can also advance the retry counter more quickly than intended. Could we atomically claim/lease the retry entry before dispatching it (for example, with a Redis transaction or Lua operation), and add a multi-worker test?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.
Addressed in 3f3036b. Due retry entries are now atomically claimed with a Redis Lua operation that advances their score to a 15-minute visibility lease before dispatch. A crashed worker’s entry becomes claimable again after the lease. Added both a concurrent-poller unit test and a real-Redis integration test; the full unit suite and PR CI pass.