Phase 3.1: Type Safety - Add bridge_types.py for shared type aliases (EventDict, PowerWatts, Timestamp, DeviceID) - Define protocols for callbacks and message parsers - Strict type annotations on all core modules (session_detector, event_queue, device_manager) - Fix Optional handling and type guards throughout codebase - Achieve full mypy compliance: 0 errors across 47 source files Phase 3.2: Logging Unification - Migrate from stdlib logging to pure structlog across all runtime modules - Convert all logs to structured event+fields format (snake_case event names) - Remove f-string and printf-style logger calls - Add contextvars support for per-request correlation - Implement FastAPI middleware to bind request_id, http_method, http_path - Propagate X-Request-ID header in responses - Remove stdlib logging imports except setup layer (utils/logging.py) - Ensure log-level consistency across all modules Files Modified: - iot_bridge/bridge_types.py (new) - Central type definitions - iot_bridge/core/* - Type safety and logging unification - iot_bridge/clients/* - Structured logging with request context - iot_bridge/parsers/* - Type-safe parsing with structured logs - iot_bridge/utils/logging.py - Pure structlog setup with contextvars - iot_bridge/api/server.py - Added request correlation middleware - iot_bridge/tests/* - Test fixtures updated for type safety - iot_bridge/OPTIMIZATION_PLAN.md - Phase 3 status updated Validation: - mypy . → 0 errors (47 files) - All unit tests pass - Runtime behavior unchanged - API response headers include X-Request-ID
221 lines
7.4 KiB
Python
221 lines
7.4 KiB
Python
"""
|
|
Event Queue with Retry Logic for IoT Bridge
|
|
|
|
Handles queuing and retry of events sent to Odoo with exponential backoff.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections import deque
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
import structlog
|
|
|
|
|
|
@dataclass
|
|
class QueuedEvent:
|
|
"""Represents an event in the queue with retry metadata."""
|
|
|
|
event_uid: str
|
|
event_type: str
|
|
device_id: str
|
|
payload: dict[str, Any]
|
|
created_at: datetime
|
|
retry_count: int = 0
|
|
next_retry_time: datetime | None = None
|
|
max_retries: int = 10
|
|
|
|
def should_retry(self, current_time: datetime) -> bool:
|
|
"""Check if event should be retried now."""
|
|
if self.retry_count >= self.max_retries:
|
|
return False
|
|
|
|
if self.next_retry_time is None:
|
|
return True
|
|
|
|
return current_time >= self.next_retry_time
|
|
|
|
def calculate_next_retry(self) -> datetime:
|
|
"""Calculate next retry time with exponential backoff."""
|
|
# Exponential backoff: 1s, 2s, 4s, 8s, 16s, ..., max 60s
|
|
delay_seconds = min(2**self.retry_count, 60)
|
|
return datetime.utcnow() + timedelta(seconds=delay_seconds)
|
|
|
|
def increment_retry(self):
|
|
"""Increment retry counter and set next retry time."""
|
|
self.retry_count += 1
|
|
self.next_retry_time = self.calculate_next_retry()
|
|
|
|
|
|
class EventQueue:
|
|
"""Thread-safe event queue with retry logic."""
|
|
|
|
def __init__(
|
|
self,
|
|
send_callback: Callable[[dict[str, Any]], bool],
|
|
logger=None,
|
|
):
|
|
"""
|
|
Initialize event queue.
|
|
|
|
Args:
|
|
send_callback: Function to send event to Odoo. Returns True on success, False on failure.
|
|
logger: Logger instance for queue operations.
|
|
"""
|
|
self.queue: deque[QueuedEvent] = deque()
|
|
self.lock = threading.Lock()
|
|
self.send_callback = send_callback
|
|
self.logger = logger or structlog.get_logger()
|
|
|
|
# Statistics
|
|
self.stats = {"pending_count": 0, "sent_count": 0, "failed_count": 0, "retry_count": 0}
|
|
|
|
# Background processing
|
|
self.running = False
|
|
self.process_thread: threading.Thread | None = None
|
|
|
|
def enqueue(self, event: dict[str, Any]) -> str:
|
|
"""
|
|
Add event to queue.
|
|
|
|
Args:
|
|
event: Event dictionary with event_type, device_id, payload
|
|
|
|
Returns:
|
|
event_uid: Unique identifier for the event
|
|
"""
|
|
# Generate UID if not present
|
|
event_uid_raw = event.get("event_uid")
|
|
event_uid = event_uid_raw if isinstance(event_uid_raw, str) else str(uuid.uuid4())
|
|
|
|
queued_event = QueuedEvent(
|
|
event_uid=event_uid,
|
|
event_type=event["event_type"],
|
|
device_id=event["device_id"],
|
|
payload=event,
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
|
|
with self.lock:
|
|
self.queue.append(queued_event)
|
|
self.stats["pending_count"] = len(self.queue)
|
|
|
|
self.logger.debug(
|
|
f"event_enqueued uid={event_uid[:8]} type={event['event_type']} queue_size={self.stats['pending_count']}"
|
|
)
|
|
|
|
return event_uid
|
|
|
|
def process_queue(self) -> None:
|
|
"""Process events in queue (runs in background thread)."""
|
|
while self.running:
|
|
try:
|
|
self._process_next_event()
|
|
time.sleep(0.1) # Small delay between processing attempts
|
|
except Exception as e:
|
|
self.logger.error("queue_processing_error", error=str(e))
|
|
|
|
def _process_next_event(self) -> None:
|
|
"""Process next event in queue that's ready for (re)try."""
|
|
with self.lock:
|
|
if not self.queue:
|
|
return
|
|
|
|
# Find first event ready for retry
|
|
current_time = datetime.utcnow()
|
|
event_to_process = None
|
|
|
|
for i, event in enumerate(self.queue):
|
|
if event.should_retry(current_time):
|
|
event_to_process = event
|
|
# Remove from queue for processing
|
|
del self.queue[i]
|
|
self.stats["pending_count"] = len(self.queue)
|
|
break
|
|
|
|
if not event_to_process:
|
|
return
|
|
|
|
# Send event (outside lock to avoid blocking queue)
|
|
success = False
|
|
try:
|
|
success = self.send_callback(event_to_process.payload)
|
|
except Exception as e:
|
|
self.logger.error(
|
|
f"event_send_exception uid={event_to_process.event_uid[:8]} error={str(e)}"
|
|
)
|
|
success = False
|
|
|
|
with self.lock:
|
|
if success:
|
|
# Event sent successfully
|
|
self.stats["sent_count"] += 1
|
|
self.logger.info(
|
|
f"event_sent_success uid={event_to_process.event_uid[:8]} type={event_to_process.event_type} attempts={event_to_process.retry_count + 1}"
|
|
)
|
|
else:
|
|
# Send failed
|
|
if event_to_process.retry_count >= event_to_process.max_retries:
|
|
# Max retries exceeded
|
|
self.stats["failed_count"] += 1
|
|
self.logger.error(
|
|
f"event_send_failed_permanently uid={event_to_process.event_uid[:8]} type={event_to_process.event_type} max_retries={event_to_process.max_retries}"
|
|
)
|
|
else:
|
|
# Re-queue with backoff
|
|
event_to_process.increment_retry()
|
|
self.queue.append(event_to_process)
|
|
self.stats["pending_count"] = len(self.queue)
|
|
self.stats["retry_count"] += 1
|
|
|
|
next_retry_time = event_to_process.next_retry_time
|
|
if next_retry_time is None:
|
|
next_retry_delay = 0.0
|
|
else:
|
|
next_retry_delay = (next_retry_time - datetime.utcnow()).total_seconds()
|
|
self.logger.warning(
|
|
f"event_send_failed_retry uid={event_to_process.event_uid[:8]} type={event_to_process.event_type} retry_count={event_to_process.retry_count} next_retry_in={next_retry_delay:.1f}s"
|
|
)
|
|
|
|
def start(self):
|
|
"""Start background queue processing."""
|
|
if self.running:
|
|
self.logger.warning("queue_already_running")
|
|
return
|
|
|
|
self.running = True
|
|
self.process_thread = threading.Thread(
|
|
target=self.process_queue, daemon=True, name="EventQueueProcessor"
|
|
)
|
|
self.process_thread.start()
|
|
self.logger.info("queue_processor_started")
|
|
|
|
def stop(self):
|
|
"""Stop background queue processing."""
|
|
if not self.running:
|
|
return
|
|
|
|
self.running = False
|
|
if self.process_thread:
|
|
self.process_thread.join(timeout=5)
|
|
|
|
self.logger.info(
|
|
f"queue_processor_stopped pending={self.stats['pending_count']} sent={self.stats['sent_count']} failed={self.stats['failed_count']}"
|
|
)
|
|
|
|
def get_stats(self) -> dict[str, int]:
|
|
"""Get queue statistics."""
|
|
with self.lock:
|
|
return self.stats.copy()
|
|
|
|
def get_queue_size(self) -> int:
|
|
"""Get current queue size."""
|
|
with self.lock:
|
|
return len(self.queue)
|