Files
webmail-client/backend/app/events.py
T

97 lines
3.4 KiB
Python
Raw Normal View History

2026-07-17 23:20:34 +02:00
# INTERFACE VERSION: 1
"""EventBus — Redis pub/sub fan-out + in-process durable consumers (SPEC §5.3, §8.3).
``publish`` fans out to the Redis channel ``evt:{event}`` (the WS hub relays these
to the owning user) and invokes any in-process subscribers. Payloads always
include ``user_id`` (SPEC §5.3). Only the event names in SPEC §8.3 are allowed.
NOTE(spec): the frozen contract also enqueues a durable arq job ``on_{event}`` for
out-of-process consumers (plugins). In M0 the only durable consumers (search index,
thread denormalization) run inline in the indexer per FR-30, so cross-process arq
fan-out is deferred to the plugin milestone (M3). The signatures here are frozen.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Awaitable, Callable
from typing import Protocol
from app.redis_client import get_redis
logger = logging.getLogger(__name__)
Handler = Callable[[dict[str, object]], Awaitable[None]]
# The complete, allowed event catalog (SPEC §8.3). Plugin events (plugin.{name}.*)
# are validated separately by the plugin loader (M3).
ALLOWED_EVENTS: frozenset[str] = frozenset(
{
"message.new",
"message.changed",
"message.deleted",
"message.sent",
"flags.changed",
"thread.updated",
"sync.progress",
"account.state_changed",
"draft.saved",
"outbox.state_changed",
# Sync v2 §3: published on login and every WS connect; drives tier
# promotion in the sync workers (§5). Payload: {user_id}.
"user.active",
# The user's sessions were revoked by an administrator (blocked or
# deleted): open tabs drop to the sign-in screen. Payload: {user_id}.
"user.signed_out",
2026-07-17 23:20:34 +02:00
}
)
def channel_for(event: str) -> str:
"""Return the Redis pub/sub channel name for an event."""
return f"evt:{event}"
class EventBusProtocol(Protocol):
async def publish(self, event: str, payload: dict[str, object]) -> None: ...
def subscribe(self, event: str, handler: Handler) -> None: ...
class EventBus:
"""Concrete EventBus backed by Redis pub/sub with in-process subscribers."""
def __init__(self) -> None:
self._handlers: dict[str, list[Handler]] = {}
def subscribe(self, event: str, handler: Handler) -> None:
"""Register an in-process durable consumer for ``event``."""
self._handlers.setdefault(event, []).append(handler)
async def publish(self, event: str, payload: dict[str, object]) -> None:
"""Fan out ``event`` to Redis subscribers and in-process handlers."""
if event not in ALLOWED_EVENTS and not event.startswith("plugin."):
raise ValueError(f"unknown event {event!r} (see SPEC §8.3)")
if "user_id" not in payload:
raise ValueError("event payload must include user_id (SPEC §5.3)")
message = json.dumps({"event": event, "payload": payload}, default=str)
await get_redis().publish(channel_for(event), message)
for handler in self._handlers.get(event, ()):
try:
await handler(payload)
except Exception: # a failing consumer must not break the publisher
logger.exception("event handler failed for %s", event)
_bus: EventBus | None = None
def get_event_bus() -> EventBus:
"""Return the process-wide EventBus singleton."""
global _bus
if _bus is None:
_bus = EventBus()
return _bus