from __future__ import annotations import json import os import sqlite3 import threading from datetime import datetime, timedelta, timezone from typing import Optional from app.config import settings from app.models.event import EventStatus, EventType, OutboundEvent class EventStore: def __init__(self, db_path: str): self.db_path = db_path os.makedirs(os.path.dirname(os.path.abspath(db_path)), exist_ok=True) self._local = threading.local() self._init_db() def _now(self) -> datetime: return datetime.now(timezone.utc).replace(tzinfo=None) def _connection(self) -> sqlite3.Connection: if not hasattr(self._local, "conn") or self._local.conn is None: self._local.conn = sqlite3.connect(self.db_path, check_same_thread=False) self._local.conn.row_factory = sqlite3.Row return self._local.conn def _init_db(self) -> None: with sqlite3.connect(self.db_path) as conn: conn.execute( """ CREATE TABLE IF NOT EXISTS outbound_events ( id TEXT PRIMARY KEY, event_type TEXT NOT NULL, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', attempt_count INTEGER NOT NULL DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_error TEXT ) """ ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_status_next_retry ON outbound_events(status, next_retry_at)" ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_created_at ON outbound_events(created_at)" ) def save(self, event: OutboundEvent) -> None: now = self._now().isoformat() with self._connection() as conn: conn.execute( """ INSERT INTO outbound_events (id, event_type, payload, status, attempt_count, next_retry_at, created_at, updated_at, last_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( event.event_id, event.event_type.value, json.dumps(event.to_payload(), default=str), EventStatus.PENDING.value, 0, now, now, now, None, ), ) def fetch_pending(self, limit: int = 100) -> list[OutboundEvent]: now = self._now().isoformat() cursor = self._connection().execute( """ SELECT id, event_type, payload, status, attempt_count FROM outbound_events WHERE status IN ('pending', 'retrying') AND next_retry_at <= ? ORDER BY next_retry_at ASC LIMIT ? """, (now, limit), ) return [self._row_to_event(row) for row in cursor.fetchall()] def _row_to_event(self, row: sqlite3.Row) -> OutboundEvent: payload = json.loads(row["payload"]) return OutboundEvent( event_id=payload["event_id"], event_type=EventType(payload["event_type"]), account_id=payload["account_id"], tenant_code=payload["tenant_code"], data=payload["data"], status=EventStatus(row["status"]), attempt_count=row["attempt_count"], ) def get(self, event_id: str) -> Optional[OutboundEvent]: row = self._connection().execute( "SELECT id, event_type, payload, status, attempt_count FROM outbound_events WHERE id = ?", (event_id,), ).fetchone() if row is None: return None return self._row_to_event(row) def mark_delivered(self, event_id: str) -> None: now = self._now().isoformat() with self._connection() as conn: conn.execute( "UPDATE outbound_events SET status = 'delivered', updated_at = ? WHERE id = ?", (now, event_id), ) def mark_retry(self, event_id: str, status: EventStatus, attempt_count: int, next_retry_at: datetime, last_error: Optional[str]) -> None: now = self._now().isoformat() with self._connection() as conn: conn.execute( """ UPDATE outbound_events SET status = ?, attempt_count = ?, next_retry_at = ?, updated_at = ?, last_error = ? WHERE id = ? """, (status.value, attempt_count, next_retry_at.isoformat(), now, last_error, event_id), ) def cleanup(self, retention_days: int) -> int: threshold = (self._now() - timedelta(days=retention_days)).isoformat() with self._connection() as conn: cursor = conn.execute( """ DELETE FROM outbound_events WHERE status IN ('delivered', 'dead_letter') AND created_at < ? """, (threshold,), ) return cursor.rowcount event_store = EventStore(settings.event_store_path)