odoo_mqtt/iot_bridge/core/event_queue.py
matthias.lotz ff4ef2f563 Phase 3: Complete type safety & logging unification (3.1-3.2)
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
2026-02-18 23:54:27 +01:00

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)