orchestrata_ultimate.gateway.ring_buffer

Reference › orchestrata_ultimate › gateway › ring_buffer

ring_buffer keeps a bounded history of gateway events in Redis streams so that clients can replay what they missed (SPEC-014 US-008, REQ-014-125). RedisRingBuffer is built from config, which supplies the Redis connection and the RING_BUFFER_MAX_LEN cap. server owns it, rpc receives it, and the gateway handler and state handler read it for gateway.replay and state queries. See gateway.

connect opens an async Redis client through core.redis_factory. write flattens a dict with serialize_for_redis (dicts and lists become JSON, None becomes an empty string, other values use str) and appends it to a stream named with a fixed key prefix plus the stream name, with an exact maxlen cap, returning the entry id. read returns recent entries, and read_since returns entries from a millisecond timestamp, oldest first, each as a dict with an entry_id. A call before connect, or any Redis failure, raises RingBufferError. close releases the client.

At a glance

Source src/orchestrata_ultimate/gateway/ring_buffer.py
Lines of code 191
Classes 1
Functions 1
Imports (internal) orchestrata_ultimate.core.base.traces, orchestrata_ultimate.core.redis_factory, orchestrata_ultimate.gateway.config, orchestrata_ultimate.gateway.exceptions
Imported by orchestrata_ultimate.gateway.handlers.gateway, orchestrata_ultimate.gateway.handlers.state, orchestrata_ultimate.gateway.rpc, orchestrata_ultimate.gateway.server
Tests that import it tests/gateway/handlers/test_config.py, tests/gateway/handlers/test_cxp.py, tests/gateway/handlers/test_knowledge.py, tests/gateway/handlers/test_state.py, tests/gateway/handlers/test_workflow.py, tests/gateway/test_auth_handler_behavioral.py, tests/gateway/test_gateway_handler.py, tests/gateway/test_gateway_metrics.py, tests/gateway/test_handlers_behavioral.py, tests/gateway/test_memory_engine_wiring.py, tests/gateway/test_method_table.py, tests/gateway/test_orchestrator_evaluator_ingress.py, tests/gateway/test_protocol_behavioral.py, tests/gateway/test_protocol_dispatch.py, tests/gateway/test_publisher_wiring.py, tests/gateway/test_ring_buffer.py, tests/gateway/test_ring_buffer_behavioral.py, tests/gateway/test_ring_buffer_serialize.py, tests/gateway/test_rpc_behavioral.py, tests/gateway/test_server_coverage_wave.py, tests/gateway/test_subscription_filters.py, tests/generated_gateway/test_handlers_gateway_generated.py, tests/generated_gateway/test_handlers_state_generated.py, tests/generated_gateway/test_ring_buffer_generated.py, tests/generated_gateway/test_rpc_generated.py … and 1 more

Class diagram

Class diagram orchestrata_ultimate.gateway.ring_buffer

Dependency diagram

Dependency diagram orchestrata_ultimate.gateway.ring_buffer

API

Classes

RedisRingBuffer

Decorators: traces(spec='014', story='US-008', reqs=['REQ-014-121']) · Line 53

Read/write ring buffer backed by Redis streams.

Method Signature Summary
__init__ __init__(config: GatewayConfig) -> None Initialise from gateway configuration.
connect async connect() -> None Open connection to Redis and verify with PING.
write async write(stream_name: str, data: dict[str, Any]) -> str Append data to the capped stream.
read async read(stream_name: str, count: int = 100) -> list[dict[str, Any]] Read the last count events from a stream.
read_since async read_since(stream_name: str, timestamp_ms: int) -> list[dict[str, Any]] Read events since a Unix timestamp (milliseconds).
client property client() -> redis.Redis | None Expose the Redis client for direct operations.
close async close() -> None Close the Redis connection.

Functions

Function Signature Summary
serialize_for_redis traces serialize_for_redis(data: dict[str, Any]) -> dict[str, str] Flatten data values to strings for Redis hashes.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/gateway/ring_buffer.py

# mypy: ignore-errors
"""Redis ring buffer with read/write using streams."""

import json
import logging
from collections.abc import Awaitable
from typing import TYPE_CHECKING, Any, cast

import redis.asyncio as redis

from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.core.redis_factory import create_async_redis
from orchestrata_ultimate.gateway.config import GatewayConfig
from orchestrata_ultimate.gateway.exceptions import (
    RingBufferError,
)

if TYPE_CHECKING:
    from redis.typing import EncodableT, FieldT

logger = logging.getLogger(__name__)

STREAM_KEY_PREFIX = "gw:ringbuf:"


@traces(spec="014", story="US-008", reqs=["REQ-014-125"])
def serialize_for_redis(
    data: dict[str, Any],
) -> dict[str, str]:
    """Flatten *data* values to strings for Redis hashes.

    Dicts and lists become JSON strings; None becomes
    the empty string; everything else uses str().

    Args:
        data: Dictionary to serialize.

    Returns:
        Dictionary with all values as strings.
    """
    result: dict[str, str] = {}
    for key, value in data.items():
        if isinstance(value, (dict, list)):
            result[key] = json.dumps(value)
        elif value is None:
            result[key] = ""
        else:
            result[key] = str(value)
    return result


@traces(spec="014", story="US-008", reqs=["REQ-014-121"])
class RedisRingBuffer:
    """Read/write ring buffer backed by Redis streams."""

    def __init__(self, config: GatewayConfig) -> None:
        """Initialise from gateway configuration."""
        self._config = config
        self._max_len = config.ring_buffer_max_len
        self._client: redis.Redis | None = None

    async def connect(self) -> None:
        """Open connection to Redis and verify with PING."""
        self._client = create_async_redis(
            host=self._config.redis_host,
            port=self._config.redis_port,
            db=self._config.redis_db,
            decode_responses=True,
        )
        # redis-py's command mixins are shared between the sync and async
        # clients, so `ping()` is stub-typed `Awaitable[bool] | bool`;
        # `self._client` here is always the async client.
        await cast("Awaitable[bool]", self._client.ping())
        logger.info("Ring buffer connected to Redis")

    @traces(spec="014", story="US-008", reqs=["REQ-014-125"])
    async def write(
        self,
        stream_name: str,
        data: dict[str, Any],
    ) -> str:
        """Append *data* to the capped stream.

        Args:
            stream_name: Redis stream name suffix.
            data: Key-value pairs to write.

        Returns:
            Redis stream entry ID string.

        Raises:
            RingBufferError: If not connected or write fails.
        """
        if not self._client:
            raise RingBufferError("Not connected to Redis")
        key = f"{STREAM_KEY_PREFIX}{stream_name}"
        serialized = serialize_for_redis(data)
        try:
            # `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.
            entry_id: str = await self._client.xadd(
                key,
                cast("dict[FieldT, EncodableT]", serialized),
                maxlen=self._max_len,
                approximate=False,
            )
            return entry_id
        except Exception as exc:
            raise RingBufferError(f"Stream write failed: {exc}") from exc

    @traces(spec="014", story="US-008", reqs=["REQ-014-127", "REQ-014-128"])
    async def read(
        self,
        stream_name: str,
        count: int = 100,
    ) -> list[dict[str, Any]]:
        """Read the last *count* events from a stream.

        Args:
            stream_name: Redis stream name suffix.
            count: Maximum number of entries to return.

        Returns:
            List of event dictionaries, oldest first.

        Raises:
            RingBufferError: If not connected or read fails.
        """
        if not self._client:
            raise RingBufferError("Not connected to Redis")
        key = f"{STREAM_KEY_PREFIX}{stream_name}"
        try:
            entries = await self._client.xrevrange(key, count=count)
            result: list[dict[str, Any]] = []
            for entry_id, fields in reversed(entries):
                event: dict[str, Any] = {
                    "entry_id": entry_id,
                }
                event.update(fields)
                result.append(event)
            return result
        except Exception as exc:
            raise RingBufferError(f"Stream read failed: {exc}") from exc

    async def read_since(
        self,
        stream_name: str,
        timestamp_ms: int,
    ) -> list[dict[str, Any]]:
        """Read events since a Unix timestamp (milliseconds).

        Args:
            stream_name: Redis stream name suffix.
            timestamp_ms: Unix timestamp in ms to start from.

        Returns:
            List of event dictionaries, oldest first.

        Raises:
            RingBufferError: If not connected or read fails.
        """
        if not self._client:
            raise RingBufferError("Not connected to Redis")
        key = f"{STREAM_KEY_PREFIX}{stream_name}"
        min_id = f"{timestamp_ms}-0"
        try:
            entries = await self._client.xrange(key, min=min_id)
            result: list[dict[str, Any]] = []
            for entry_id, fields in entries:
                event: dict[str, Any] = {
                    "entry_id": entry_id,
                }
                event.update(fields)
                result.append(event)
            return result
        except Exception as exc:
            raise RingBufferError(f"Stream read failed: {exc}") from exc

    @property
    def client(self) -> redis.Redis | None:
        """Expose the Redis client for direct operations."""
        return self._client

    async def close(self) -> None:
        """Close the Redis connection."""
        if self._client:
            await self._client.aclose()
            self._client = None
            logger.info("Ring buffer disconnected")