Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-workers/src/lexigram/ai/workers/dlq/worker.py: 16%
172 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
1"""
2Dead Letter Queue (DLQ) worker for failed task recovery.
4Handles failed task recovery including:
5- Retry mechanisms with exponential backoff
6- Error classification and routing
7- Failure analysis and reporting
8- Manual retry triggers
9- Permanent failure handling
11Uses Dependency Injection - TaskQueueProtocol is injected, not imported.
12"""
14from __future__ import annotations
16import asyncio
17import contextlib
18from datetime import UTC, datetime, timedelta
19from typing import TYPE_CHECKING, Any
21if TYPE_CHECKING:
22 from collections.abc import Callable
24 from lexigram.contracts import JobProtocol, TaskQueueProtocol
26from lexigram.ai.workers.types import (
27 DLQItem,
28 DLQStats,
29 FailureCategory,
30)
31from lexigram.contracts.core.health import HealthCheckResult, HealthStatus
32from lexigram.logging import (
33 get_logger,
34)
35from lexigram.result import Result
37logger = get_logger(__name__)
40class ErrorClassifier:
41 """Classify errors into failure categories."""
43 @staticmethod
44 def classify(error: str, _job: JobProtocol) -> FailureCategory:
45 """
46 Classify error into a failure category.
48 Args:
49 error: Error message
50 job: Original job
52 Returns:
53 Failure category
54 """
55 error_lower = error.lower()
57 # Permanent failures
58 if any(
59 term in error_lower
60 for term in [
61 "not found",
62 "does not exist",
63 "invalid",
64 "malformed",
65 "syntax error",
66 ]
67 ):
68 return FailureCategory.PERMANENT
70 # Throttling
71 if any(
72 term in error_lower
73 for term in [
74 "rate limit",
75 "too many requests",
76 "throttled",
77 "quota exceeded",
78 ]
79 ):
80 return FailureCategory.THROTTLED
82 # Invalid input
83 if any(
84 term in error_lower
85 for term in [
86 "validation error",
87 "invalid input",
88 "bad request",
89 ]
90 ):
91 return FailureCategory.INVALID_INPUT
93 # Transient failures
94 if any(
95 term in error_lower
96 for term in [
97 "timeout",
98 "connection",
99 "network",
100 "temporary",
101 "unavailable",
102 "503",
103 "502",
104 ]
105 ):
106 return FailureCategory.TRANSIENT
108 return FailureCategory.UNKNOWN
111class DeadLetterQueueWorker:
112 """
113 Worker for handling failed tasks and retry logic.
115 Monitors failed jobs and implements intelligent retry strategies
116 based on failure categorization.
118 Example:
119 ```python
120 from lexigram.ai.workers import DeadLetterQueueWorker
121 from lexigram.contracts.infra.tasks import TaskQueueProtocol
123 # Setup
124 main_queue = TaskQueueProtocol()
125 dlq_queue = TaskQueueProtocol() # Separate queue for DLQ items
127 worker = DeadLetterQueueWorker(
128 main_queue=main_queue,
129 dlq_queue=dlq_queue,
130 worker_id="dlq",
131 check_interval=60, # Check every minute
132 )
134 # Register error notification handler
135 async def notify_on_permanent_failure(item: DLQItem):
136 await send_alert(f"Permanent failure: {item.job_id}")
138 worker.set_notification_handler(notify_on_permanent_failure)
140 # Start worker
141 await worker.start()
143 # Manually retry a failed job
144 await worker.retry_item(job_id="abc123")
146 # Get DLQ statistics
147 stats = await worker.get_stats()
148 logger.info(f"Total failed items: {stats.total_items}")
150 # Stop worker
151 await worker.stop()
152 ```
153 """
155 def __init__(
156 self,
157 main_queue: TaskQueueProtocol,
158 dlq_queue: TaskQueueProtocol | None = None,
159 worker_id: str = "dlq",
160 check_interval: int = 60,
161 max_retries: int = 5,
162 base_backoff: int = 60,
163 ):
164 """
165 Initialize DLQ worker.
167 Args:
168 main_queue: Main task queue to monitor
169 dlq_queue: Optional separate queue for DLQ items
170 worker_id: Unique worker identifier
171 check_interval: Interval in seconds to check for failed jobs
172 max_retries: Maximum retry attempts per job
173 base_backoff: Base delay in seconds for exponential backoff
174 """
175 self.main_queue = main_queue
176 self.dlq_queue = dlq_queue or main_queue
177 self.worker_id = worker_id
178 self.check_interval = check_interval
179 self.max_retries = max_retries
180 self.base_backoff = base_backoff
182 # DLQ storage
183 self._items: dict[str, DLQItem] = {}
184 self._items_lock = asyncio.Lock()
185 self.dead_letter_count = 0
187 # Error classifier
188 self._classifier = ErrorClassifier()
190 # Notification handler
191 self._notification_handler: Callable[[DLQItem], Any] | None = None
193 # Worker state
194 self._running = False
195 self._worker_task: asyncio.Task | None = None
197 async def start(self) -> None:
198 """Start the DLQ worker."""
199 if self._running:
200 logger.warning("Worker %s already running", self.worker_id)
201 return
203 self._running = True
204 self._worker_task = asyncio.create_task(self._dlq_loop())
206 logger.info(
207 "Started DLQ worker",
208 worker_id=self.worker_id,
209 check_interval=self.check_interval,
210 max_retries=self.max_retries,
211 )
213 async def stop(self) -> None:
214 """Stop the DLQ worker."""
215 if not self._running:
216 return
218 self._running = False
220 if self._worker_task:
221 self._worker_task.cancel()
222 with contextlib.suppress(asyncio.CancelledError):
223 await self._worker_task
225 logger.info("Stopped DLQ worker", worker_id=self.worker_id)
227 def set_notification_handler(
228 self,
229 handler: Callable[[DLQItem], Any],
230 ) -> None:
231 """
232 Set notification handler for permanent failures.
234 Args:
235 handler: Async callable that receives DLQItem
236 """
237 self._notification_handler = handler
238 logger.info("Set DLQ notification handler")
240 async def add_failed_job(self, job: JobProtocol, error: str) -> None:
241 """
242 Add a failed job to the DLQ.
244 Args:
245 job: Failed job
246 error: Error message
247 """
248 async with self._items_lock:
249 if job.id in self._items:
250 # Update existing item
251 item = self._items[job.id]
252 item.failure_count += 1
253 item.last_failure = datetime.now(UTC)
254 item.last_error = error
255 else:
256 # Create new DLQ item
257 now = datetime.now(UTC)
258 failure_category = self._classifier.classify(error, job)
260 item = DLQItem(
261 job_id=job.id,
262 original_job=job,
263 failure_count=1,
264 first_failure=now,
265 last_failure=now,
266 last_error=error,
267 failure_category=failure_category,
268 max_retries=self.max_retries,
269 )
271 self._items[job.id] = item
272 self.dead_letter_count += 1
274 logger.info(
275 "Added job to DLQ",
276 job_id=job.id,
277 failure_count=item.failure_count,
278 category=item.failure_category.value,
279 )
281 async def retry_item(self, job_id: str) -> bool:
282 """
283 Manually retry a DLQ item.
285 Args:
286 job_id: JobProtocol ID to retry
288 Returns:
289 True if retry was initiated, False otherwise
290 """
291 async with self._items_lock:
292 if job_id not in self._items:
293 logger.warning("JobProtocol not found in DLQ", job_id=job_id)
294 return False
296 item = self._items[job_id]
298 if not item.can_retry():
299 logger.warning(
300 "JobProtocol cannot be retried",
301 job_id=job_id,
302 retry_count=item.retry_count,
303 max_retries=item.max_retries,
304 )
305 return False
307 # Retry the job
308 await self._retry_job(item)
309 return True
311 async def remove_item(self, job_id: str) -> bool:
312 """
313 Remove item from DLQ (permanent deletion).
315 Args:
316 job_id: JobProtocol ID to remove
318 Returns:
319 True if removed, False if not found
320 """
321 async with self._items_lock:
322 if job_id in self._items:
323 del self._items[job_id]
324 logger.info("Removed job from DLQ", job_id=job_id)
325 return True
326 return False
328 async def archive_item(self, job_id: str) -> bool:
329 """
330 Archive item (mark as permanently failed).
332 Args:
333 job_id: JobProtocol ID to archive
335 Returns:
336 True if archived, False if not found
337 """
338 async with self._items_lock:
339 if job_id in self._items:
340 item = self._items[job_id]
341 item.failure_category = FailureCategory.PERMANENT
342 item.metadata["archived"] = True
343 item.metadata["archived_at"] = datetime.now(UTC).isoformat()
345 logger.info("Archived job in DLQ", job_id=job_id)
346 return True
347 return False
349 async def get_stats(self) -> DLQStats:
350 """Get DLQ statistics."""
351 async with self._items_lock:
352 stats = DLQStats(total_items=len(self._items))
354 for item in self._items.values():
355 category = item.failure_category.value
356 stats.by_category[category] = stats.by_category.get(category, 0) + 1
358 if item.retry_count > 0:
359 stats.retried_count += 1
361 if item.failure_category == FailureCategory.PERMANENT:
362 stats.permanent_failures += 1
364 if item.metadata.get("archived"):
365 stats.archived_count += 1
367 return stats
369 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult:
370 """Report the health of this worker.
372 Args:
373 timeout: Unused; present for protocol conformance.
375 Returns:
376 HEALTHY when the worker is running, UNHEALTHY otherwise.
377 """
378 status = HealthStatus.HEALTHY if self._running else HealthStatus.UNHEALTHY
379 stats = await self.get_stats()
380 return HealthCheckResult(
381 component=f"worker.dlq.{self.worker_id}",
382 status=status,
383 details=stats.to_dict(),
384 )
386 async def get_items(
387 self,
388 category: FailureCategory | None = None,
389 limit: int = 100,
390 ) -> list[dict[str, Any]]:
391 """
392 Get DLQ items.
394 Args:
395 category: Optional filter by failure category
396 limit: Maximum items to return
398 Returns:
399 List of DLQ items as dictionaries
400 """
401 async with self._items_lock:
402 items = list(self._items.values())
404 if category:
405 items = list(
406 filter(lambda item: item.failure_category == category, items),
407 )
409 # Sort by last failure (most recent first)
410 items.sort(key=lambda x: x.last_failure, reverse=True)
412 return [item.to_dict() for item in items[:limit]]
414 async def _dlq_loop(self) -> None:
415 """Main DLQ processing loop."""
416 while self._running:
417 try:
418 # Check for items ready to retry
419 async with self._items_lock:
420 items_to_retry = [
421 item for item in self._items.values() if item.can_retry()
422 ]
424 # Retry eligible items
425 for item in items_to_retry:
426 try:
427 await self._retry_job(item)
428 except Exception as e:
429 logger.exception(
430 "Error retrying job",
431 job_id=item.job_id,
432 error=str(e),
433 )
435 # Check for permanent failures to notify
436 async with self._items_lock:
437 permanent_failures = [
438 item
439 for item in self._items.values()
440 if (
441 item.failure_category == FailureCategory.PERMANENT
442 and not item.metadata.get("notified")
443 )
444 ]
446 # Send notifications
447 for item in permanent_failures:
448 try:
449 await self._notify_permanent_failure(item)
450 item.metadata["notified"] = True
451 except Exception as e:
452 logger.exception(
453 "Error sending notification",
454 job_id=item.job_id,
455 error=str(e),
456 )
458 # Wait for next check
459 await asyncio.sleep(self.check_interval)
461 except asyncio.CancelledError:
462 break
463 except Exception as e:
464 logger.exception(
465 "Error in DLQ loop",
466 error=str(e),
467 )
468 await asyncio.sleep(self.check_interval)
470 async def _retry_job(self, item: DLQItem) -> None:
471 """Retry a failed job."""
472 # Calculate backoff delay
473 backoff = item.calculate_backoff(self.base_backoff)
474 item.next_retry = datetime.now(UTC) + timedelta(seconds=backoff)
475 item.retry_count += 1
477 logger.info(
478 "Retrying job",
479 job_id=item.job_id,
480 retry_count=item.retry_count,
481 next_retry=item.next_retry.isoformat(),
482 )
484 # Re-enqueue the job using queue's enqueue(job_type, data, priority) API
485 job_name = item.original_job.name
486 if job_name is None:
487 logger.error("Original job name missing; cannot retry", job_id=item.job_id)
488 return
490 # Narrow type for mypy: job_name is Optional[str] on JobProtocol, assert non-None
491 assert job_name is not None # noqa: S101 # type-narrowing only; log branch returned above
493 priority = (
494 item.original_job.priority if item.original_job.priority is not None else 0
495 )
497 new_job_data: dict[str, Any] = {
498 "name": job_name,
499 "args": item.original_job.args or [],
500 "kwargs": item.original_job.kwargs or {},
501 "priority": priority,
502 "max_retries": 1, # Only one retry per DLQ attempt
503 "timeout": item.original_job.timeout,
504 }
506 enqueue_result = await self.dlq_queue.enqueue(
507 {
508 "name": job_name,
509 "args": tuple(item.original_job.args or []),
510 "kwargs": new_job_data,
511 "priority": priority,
512 }
513 )
514 if isinstance(enqueue_result, Result) and enqueue_result.is_err():
515 logger.warning(
516 "dlq_retry_enqueue_failed",
517 job_id=item.job_id,
518 error=str(enqueue_result.unwrap_err()),
519 )
520 return
522 if not item.metadata.get("replayed"):
523 item.metadata["replayed"] = True
524 self.dead_letter_count -= 1
526 async def _notify_permanent_failure(self, item: DLQItem) -> None:
527 """Notify about permanent failure."""
528 if not self._notification_handler:
529 return
531 logger.info(
532 "Notifying permanent failure",
533 job_id=item.job_id,
534 error=item.last_error,
535 )
537 if asyncio.iscoroutinefunction(self._notification_handler):
538 await self._notification_handler(item)
539 else:
540 await asyncio.to_thread(self._notification_handler, item)