orchestrata_ultimate.kernel.event_bus
Reference › orchestrata_ultimate › kernel › event_bus
The system event bus of the kernel. EventBus delivers a SystemEvent to handlers in the same process and, in the same call, publishes it to Redis so other processes see it and so it can be replayed later. kernel owns one and creates it lazily; the gateway server also imports it. The module sits next to the curated kernel narrative.
emit calls _call_in_process_handlers first and then _publish_to_redis. subscribe registers a handler against a topic pattern with an optional subscriber id, a time-to-live and a filter function, and returns an EventSubscription; unsubscribe removes it by id. _find_matching_handlers skips expired subscriptions, matches the event type to the pattern with fnmatch, applies the filter, and handlers run under a per-handler timeout (5 seconds by default). _publish_to_redis publishes the serialised event to a channel named from the prefix orchestrata:events and the event type, and appends the fields to a Redis stream with an approximate length cap (100000 entries by default). replay reads the stream with an optional time window and count, filters by topic pattern and calls the handler for each match, returning the number delivered.
Redis host and port come from REDIS_HOST and REDIS_PORT at first use when not passed in, with no hardcoded default, and the client comes from the shared core.redis_factory. A Redis error during publish is logged and swallowed, so in-process delivery still happens; a Redis error during replay is logged and re-raised.
At a glance
| Source | src/orchestrata_ultimate/kernel/event_bus.py |
| Lines of code | 353 |
| Classes | 1 |
| Functions | 0 |
| Imports (internal) | orchestrata_ultimate.core.base.traces, orchestrata_ultimate.core.protocols, orchestrata_ultimate.core.redis_factory |
| Imported by | orchestrata_ultimate.gateway.server, orchestrata_ultimate.kernel, orchestrata_ultimate.kernel.kernel |
| Tests that import it | tests/gateway/test_kernel_event_forwarder.py, tests/gateway/test_volk_status_event_broadcast.py, tests/kernel/test_cli_main_kernel_events.py, tests/kernel/test_event_bus.py, tests/kernel/test_event_bus_extended.py |
Class diagram
Dependency diagram
API
Classes
EventBus
Line 35
Dual-layer EventBus with in-process handlers and Redis pub/sub.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(redis_host: str | None = None, redis_port: int | None = None, redis_password: str | None = None, redis_db: int = 0, stream_key: str = EVENTS_STREAM_KEY, channel_prefix: str = 'orchestrata:events', handler_timeout: float = 5.0, stream_maxlen: int = 100000, redis_client: redis.Redis | None =… |
Initialize EventBus with Redis connection parameters. |
_get_redis |
async _get_redis() -> redis.Redis |
Lazy Redis client initialization. |
close |
async close() -> None |
Close the Redis connection. |
emit |
async emit(event: SystemEvent) -> None |
Emit an event to in-process handlers and Redis. |
subscribe |
async subscribe(topic_pattern: str, handler: Callable[[SystemEvent], Awaitable[None]], *, subscriber_id: str | None = None, ttl_seconds: int | None = None, filter_fn: Callable[[SystemEvent], bool] | None = None) -> EventSubscription |
Subscribe to events matching a topic pattern. |
unsubscribe |
async unsubscribe(subscription_id: str) -> bool |
Unsubscribe by subscription ID. |
replay |
async replay(topic_pattern: str, handler: Callable[[SystemEvent], Awaitable[None]], *, since: datetime | None = None, until: datetime | None = None, count: int | None = None) -> int |
Replay historical events matching a topic pattern from Redis stream. |
subscriber_count |
subscriber_count(topic_pattern: str | None = None) -> int |
Count subscribers matching the pattern, or all if None. |
_call_in_process_handlers |
async _call_in_process_handlers(event: SystemEvent) -> None |
Call matching in-process handlers with timeout. |
_find_matching_handlers |
async _find_matching_handlers(event: SystemEvent) -> list[dict] |
Find all subscription handlers that match the event topic. |
_publish_to_redis |
async _publish_to_redis(event: SystemEvent) -> None |
Publish event to Redis channel and stream. |
_serialize_event |
_serialize_event(event: SystemEvent) -> str |
Serialize SystemEvent to JSON string. |
_deserialize_event |
_deserialize_event(data: str) -> SystemEvent |
Deserialize JSON string to SystemEvent. |
_deserialize_stream_entry |
_deserialize_stream_entry(data: dict[str, Any]) -> SystemEvent |
Deserialize a Redis stream entry to SystemEvent. |
Source
# mypy: ignore-errors
"""Dual-layer (in-process + Redis) EventBus implementation.
Spec: 001-kernel (addendum) — EventBus
"""
import asyncio
import fnmatch
import json
import logging
import os
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
import redis.asyncio as redis
from redis.exceptions import RedisError
if TYPE_CHECKING:
from redis.typing import EncodableT, FieldT
from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.core.protocols import EventSubscription, SystemEvent
logger = logging.getLogger(__name__)
# Redis stream that persists every emitted SystemEvent (XADD'd by
# _publish_to_redis). Exposed as a module constant so consumers outside this
# module (e.g. the gateway's kernel-events forwarder) never duplicate the
# literal string.
EVENTS_STREAM_KEY = "orchestrata:stream:events"
class EventBus:
"""Dual-layer EventBus with in-process handlers and Redis pub/sub.
Implements IEventBus protocol. Events are delivered to in-process
subscribers via asyncio.gather, then published to Redis channels
and streams for cross-process distribution and persistence.
"""
def __init__(
self,
redis_host: str | None = None,
redis_port: int | None = None,
redis_password: str | None = None,
redis_db: int = 0,
stream_key: str = EVENTS_STREAM_KEY,
channel_prefix: str = "orchestrata:events",
handler_timeout: float = 5.0,
stream_maxlen: int = 100000,
redis_client: redis.Redis | None = None,
) -> None:
"""Initialize EventBus with Redis connection parameters.
Args:
redis_host: Redis host; ``None`` resolves ``REDIS_HOST`` from the
environment at first use — no hardcoded fallback (infra
endpoints come from ``.env``, never silent defaults).
redis_port: Redis port; ``None`` resolves ``REDIS_PORT`` likewise.
redis_client: Pre-built async client injected directly (the
production default still lazily builds one from
``redis_host``/``redis_port`` on first use when this is
omitted). Exposed so tests can point the EventBus at a
fail-fast client without paying redis-py's default
connection-retry backoff.
"""
self._redis_host = redis_host
self._redis_port = redis_port
self._redis_password = redis_password
self._redis_db = redis_db
self._stream_key = stream_key
self._channel_prefix = channel_prefix
self._handler_timeout = handler_timeout
self._stream_maxlen = stream_maxlen
self._redis: redis.Redis | None = redis_client
self._subscriptions: dict[str, dict] = {}
self._lock = asyncio.Lock()
@traces(spec="001", story="US-010", reqs=["REQ-001-US-010-012"])
async def _get_redis(self) -> redis.Redis:
"""Lazy Redis client initialization."""
if self._redis is None:
from orchestrata_ultimate.core.redis_factory import create_async_redis
# Resolve infra endpoints at call time, no hardcoded fallback:
# REDIS_HOST/REDIS_PORT come from .env (validate_env.py preflights
# them), and a silent localhost default would point at nothing.
# os.environ.get(...) (not the bare subscript) + an explicit
# raise keeps the "fail loudly when unset" intent without a
# KeyError whose message doesn't say which var or why.
if self._redis_host is not None:
host = self._redis_host
else:
env_host = os.environ.get("REDIS_HOST")
if env_host is None:
raise RuntimeError(
"REDIS_HOST is not set (.env) and no explicit host "
"was passed to EventBus"
)
host = env_host
if self._redis_port is not None:
port = self._redis_port
else:
env_port = os.environ.get("REDIS_PORT")
if env_port is None:
raise RuntimeError(
"REDIS_PORT is not set (.env) and no explicit port "
"was passed to EventBus"
)
port = int(env_port)
self._redis = create_async_redis(
host=host,
port=port,
password=self._redis_password,
db=self._redis_db,
)
return self._redis
async def close(self) -> None:
"""Close the Redis connection."""
if self._redis is not None:
await self._redis.aclose()
self._redis = None
async def emit(self, event: SystemEvent) -> None:
"""Emit an event to in-process handlers and Redis.
Delivers to matching in-process subscribers first, then publishes
to Redis for cross-process distribution and persistence.
"""
await self._call_in_process_handlers(event)
await self._publish_to_redis(event)
@traces(spec="015", story="US-011", reqs=["REQ-015-149"])
async def subscribe(
self,
topic_pattern: str,
handler: Callable[[SystemEvent], Awaitable[None]],
*,
subscriber_id: str | None = None,
ttl_seconds: int | None = None,
filter_fn: Callable[[SystemEvent], bool] | None = None,
) -> EventSubscription:
"""Subscribe to events matching a topic pattern."""
if subscriber_id is None:
subscriber_id = str(uuid4())
subscription_id = str(uuid4())
created_at = datetime.now(UTC)
expires_at = None
if ttl_seconds is not None:
expires_at = created_at + timedelta(seconds=ttl_seconds)
async with self._lock:
self._subscriptions[subscription_id] = {
"subscription_id": subscription_id,
"topic_pattern": topic_pattern,
"subscriber_id": subscriber_id,
"handler": handler,
"filter_fn": filter_fn,
"created_at": created_at,
"expires_at": expires_at,
"event_count": 0,
}
return EventSubscription(
id=subscription_id,
topic_pattern=topic_pattern,
subscriber_id=subscriber_id,
created_at=created_at,
expires_at=expires_at,
event_count=0,
)
async def unsubscribe(self, subscription_id: str) -> bool:
"""Unsubscribe by subscription ID."""
async with self._lock:
if subscription_id in self._subscriptions:
del self._subscriptions[subscription_id]
return True
return False
async def replay(
self,
topic_pattern: str,
handler: Callable[[SystemEvent], Awaitable[None]],
*,
since: datetime | None = None,
until: datetime | None = None,
count: int | None = None,
) -> int:
"""Replay historical events matching a topic pattern from Redis stream."""
try:
redis_client = await self._get_redis()
start = "-" if since is None else str(int(since.timestamp() * 1000))
end = "+" if until is None else str(int(until.timestamp() * 1000))
entries = await redis_client.xrange(
self._stream_key, min=start, max=end, count=count
)
delivered = 0
for _entry_id, data in entries:
event = self._deserialize_stream_entry(data)
if fnmatch.fnmatch(event.type, topic_pattern):
await handler(event)
delivered += 1
return delivered
except RedisError:
logger.error("Redis replay failed", exc_info=True)
raise
def subscriber_count(self, topic_pattern: str | None = None) -> int:
"""Count subscribers matching the pattern, or all if None."""
count = 0
current_time = datetime.now(UTC)
for sub_data in self._subscriptions.values():
expires_at = sub_data["expires_at"]
if expires_at is not None and current_time >= expires_at:
continue
if topic_pattern is None:
count += 1
elif fnmatch.fnmatch(sub_data["topic_pattern"], topic_pattern):
count += 1
return count
# ------------------------------------------------------------------
# Internal methods
# ------------------------------------------------------------------
@traces(spec="015", story="US-011", reqs=["REQ-015-155"])
async def _call_in_process_handlers(self, event: SystemEvent) -> None:
"""Call matching in-process handlers with timeout."""
matching = await self._find_matching_handlers(event)
if not matching:
return
tasks = [sub_data["handler"](event) for sub_data in matching]
try:
results = await asyncio.wait_for(
asyncio.gather(*tasks, return_exceptions=True),
timeout=self._handler_timeout,
)
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.warning(
"Subscriber %s failed: %s",
matching[i]["subscriber_id"],
result,
)
else:
async with self._lock:
sub_id = matching[i]["subscription_id"]
if sub_id in self._subscriptions:
self._subscriptions[sub_id]["event_count"] += 1
except TimeoutError:
logger.warning(
"Timeout (%ss) delivering event %s to %d handlers",
self._handler_timeout,
event.type,
len(matching),
)
@traces(spec="015", story="US-011", reqs=["REQ-015-147"])
async def _find_matching_handlers(self, event: SystemEvent) -> list[dict]:
"""Find all subscription handlers that match the event topic."""
matching: list[dict] = []
current_time = datetime.now(UTC)
async with self._lock:
for sub_data in self._subscriptions.values():
expires_at = sub_data["expires_at"]
if expires_at is not None and current_time >= expires_at:
continue
if not fnmatch.fnmatch(event.type, sub_data["topic_pattern"]):
continue
filter_fn = sub_data["filter_fn"]
if filter_fn is not None and not filter_fn(event):
continue
matching.append(sub_data)
return matching
async def _publish_to_redis(self, event: SystemEvent) -> None:
"""Publish event to Redis channel and stream."""
try:
redis_client = await self._get_redis()
channel = f"{self._channel_prefix}:{event.type}"
event_json = self._serialize_event(event)
await redis_client.publish(channel, event_json)
stream_data = {
"id": event.id,
"type": event.type,
"source": event.source,
"timestamp": event.timestamp.isoformat(),
"payload": json.dumps(event.payload),
"correlation_id": event.correlation_id or "",
"severity": event.severity,
}
# `dict[str, str]` isn't assignable to `Dict[FieldT, EncodableT]`
# despite `str` being a member of both unions: `Dict`'s type
# parameters are invariant, so pyright requires an exact type
# match rather than accepting a narrower concrete dict
# (precedent: gateway/ring_buffer.py).
await redis_client.xadd(
self._stream_key,
cast("dict[FieldT, EncodableT]", stream_data),
maxlen=self._stream_maxlen,
approximate=True,
)
except RedisError:
logger.error("Redis publish failed for event %s", event.type, exc_info=True)
def _serialize_event(self, event: SystemEvent) -> str:
"""Serialize SystemEvent to JSON string."""
return json.dumps(event.to_dict())
def _deserialize_event(self, data: str) -> SystemEvent:
"""Deserialize JSON string to SystemEvent."""
return SystemEvent.from_dict(json.loads(data))
def _deserialize_stream_entry(self, data: dict[str, Any]) -> SystemEvent:
"""Deserialize a Redis stream entry to SystemEvent."""
try:
payload = json.loads(data.get("payload", "{}"))
except json.JSONDecodeError, TypeError:
payload = {}
try:
timestamp = datetime.fromisoformat(data["timestamp"])
except ValueError, KeyError:
timestamp = datetime.now(UTC)
return SystemEvent(
id=data.get("id", str(uuid4())),
type=data.get("type", ""),
source=data.get("source", ""),
timestamp=timestamp,
payload=payload,
correlation_id=data.get("correlation_id") or None,
severity=data.get("severity", "info"),
)