90 lines
4.0 KiB
Python
90 lines
4.0 KiB
Python
"""Durable Owner/GPT notification outbox with retry and acknowledgement."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Mapping
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OutboxEvent:
|
|
event_id: str
|
|
recipient: str
|
|
kind: str
|
|
payload: Mapping[str, Any]
|
|
attempts: int
|
|
available_at: float
|
|
acknowledged: bool
|
|
|
|
|
|
class DurableOutbox:
|
|
RECIPIENTS = ("owner", "gpt")
|
|
|
|
def __init__(self, path: str | Path) -> None:
|
|
self.path = str(path)
|
|
self.db = sqlite3.connect(self.path)
|
|
self.db.execute("PRAGMA journal_mode=WAL")
|
|
self.db.execute("CREATE TABLE IF NOT EXISTS outbox (event_id TEXT PRIMARY KEY, recipient TEXT NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, available_at REAL NOT NULL, delivered_at REAL, acknowledged INTEGER NOT NULL DEFAULT 0, last_error TEXT)")
|
|
self.db.commit()
|
|
|
|
def enqueue(self, kind: str, payload: Mapping[str, Any], *, event_id: str | None = None, recipients: tuple[str, ...] = RECIPIENTS) -> list[str]:
|
|
ids: list[str] = []
|
|
for recipient in recipients:
|
|
if recipient not in self.RECIPIENTS:
|
|
raise ValueError("recipient is outside the governed notification plane")
|
|
eid = event_id or str(uuid.uuid4())
|
|
if event_id is not None and len(recipients) > 1:
|
|
eid = f"{event_id}:{recipient}"
|
|
self.db.execute("INSERT OR IGNORE INTO outbox(event_id,recipient,kind,payload,available_at) VALUES(?,?,?,?,?)", (eid, recipient, kind, json.dumps(dict(payload), sort_keys=True), time.time()))
|
|
ids.append(eid)
|
|
self.db.commit()
|
|
return ids
|
|
|
|
def due(self, *, now: float | None = None, limit: int = 20) -> list[OutboxEvent]:
|
|
now = time.time() if now is None else now
|
|
rows = self.db.execute("SELECT event_id,recipient,kind,payload,attempts,available_at,acknowledged FROM outbox WHERE delivered_at IS NULL AND available_at<=? ORDER BY available_at,event_id LIMIT ?", (now, limit)).fetchall()
|
|
return [OutboxEvent(r[0], r[1], r[2], json.loads(r[3]), r[4], r[5], bool(r[6])) for r in rows]
|
|
|
|
def delivered(self, event_id: str, *, now: float | None = None) -> None:
|
|
self.db.execute("UPDATE outbox SET delivered_at=? WHERE event_id=?", (time.time() if now is None else now, event_id))
|
|
self.db.commit()
|
|
|
|
def failed(self, event_id: str, error: str, *, now: float | None = None, max_backoff: float = 300.0) -> None:
|
|
now = time.time() if now is None else now
|
|
row = self.db.execute("SELECT attempts FROM outbox WHERE event_id=?", (event_id,)).fetchone()
|
|
attempts = int(row[0]) + 1 if row else 1
|
|
delay = min(max_backoff, 2 ** min(attempts, 8))
|
|
self.db.execute("UPDATE outbox SET attempts=?,available_at=?,last_error=? WHERE event_id=?", (attempts, now + delay, error[:200], event_id))
|
|
self.db.commit()
|
|
|
|
def acknowledge(self, event_id: str) -> None:
|
|
self.db.execute("UPDATE outbox SET acknowledged=1 WHERE event_id=?", (event_id,))
|
|
self.db.commit()
|
|
|
|
def close(self) -> None:
|
|
self.db.close()
|
|
|
|
|
|
class NotificationPlane:
|
|
def __init__(self, outbox: DurableOutbox) -> None:
|
|
self.outbox = outbox
|
|
|
|
def emit(self, kind: str, payload: Mapping[str, Any], *, event_id: str | None = None) -> list[str]:
|
|
return self.outbox.enqueue(kind, payload, event_id=event_id)
|
|
|
|
def deliver_once(self, send: Callable[[OutboxEvent], None], *, now: float | None = None) -> list[str]:
|
|
delivered: list[str] = []
|
|
for event in self.outbox.due(now=now):
|
|
try:
|
|
send(event)
|
|
except Exception as exc:
|
|
self.outbox.failed(event.event_id, type(exc).__name__, now=now)
|
|
else:
|
|
self.outbox.delivered(event.event_id, now=now)
|
|
delivered.append(event.event_id)
|
|
return delivered
|