# Capability Spec: api-ws-broker-redis **Package**: `@suaveplan/api-ws-broker-redis` **Status**: SHIPPED > Every requirement carries a `**Verified by:**` line. ## ADDED Requirements ### Requirement: Implements the WsBroker contract The package SHALL export `createRedisWsBroker(opts)` producing a broker value implementing the `WsBroker` contract from `@suaveplan/api-ws`. The conformance suite from `@suaveplan/api-ws/testing` MUST pass against this broker. #### Scenario: conformance suite passes - **WHEN** the conformance suite runs against `createRedisWsBroker(opts)` backed by a real Redis container - **THEN** every invariant passes **Verified by:** `src/broker/redis-broker.spec-conformance.test.ts::"createRedisWsBroker passes WsBroker conformance against real Redis"` --- ### Requirement: Cross-host broadcast via Redis pub/sub `broker.broadcast(topic, { tenant, payload })` SHALL publish to `` on Redis. Every host subscribed to the channel pattern MUST receive the message and deliver it to its locally-connected subscribers via the local `ConnectionRegistry`. Connections are NOT migrated across hosts — the broker only makes broadcasts visible everywhere, and each host independently delivers to its own subscribers. #### Scenario: broadcast from host A is delivered to subscribers on host B - **WHEN** two `api-ws-bun` engines run on different processes pointed at the same Redis AND a client C subscribed on host B AND host A issues `broker.broadcast(topic, { tenant: T, payload: ... })` with C's tenant - **THEN** C receives the payload #### Scenario: sticky sessions are NOT required - **WHEN** a client connects to host A AND later reconnects and lands on host B - **THEN** subscriptions resume on host B from the event buffer with no requirement on routing back to host A **Verified by:** `tests/integration/two-host-broadcast.test.ts::"broadcast from host A delivered to subscriber on host B"` AND `tests/integration/two-host-broadcast.test.ts::"reconnect lands on different host and resumes from event buffer"` --- ### Requirement: Reconnect event-buffer storage in Redis sorted-sets The broker SHALL maintain a per-`(tenant, topic)` event buffer in Redis sorted-sets: `buffer::`, scored by `lastEventId`. Each entry SHALL be capped at `eventBufferMaxEvents` (default 256, oldest evicted on overflow) AND TTL'd at `eventBufferTtlSec` (default 300s). On reconnect, `api-ws-bun` calls `broker.readBuffer(tenant, topic, sinceEventId)` to replay events; SSE calls the same surface for `Last-Event-Id` resume. #### Scenario: buffer cap evicts oldest entries on overflow - **WHEN** `eventBufferMaxEvents: 4` AND 5 events are written for the same `(tenant, topic)` - **THEN** the buffer holds the 4 most recent events AND the oldest was evicted #### Scenario: TTL expires the buffer - **WHEN** no writes for `eventBufferTtlSec + 1` - **THEN** the buffer key has been removed from Redis #### Scenario: replay returns events with id > sinceEventId - **WHEN** the buffer holds events 1..10 AND `readBuffer(tenant, topic, 7)` is called - **THEN** events 8, 9, 10 are returned in that order **Verified by:** `src/event-buffer/event-buffer.test.ts::"buffer cap evicts oldest on overflow"` AND `src/event-buffer/event-buffer.test.ts::"buffer TTL expires keys"` AND `src/event-buffer/event-buffer.test.ts::"readBuffer returns events with id greater than sinceEventId"` --- ### Requirement: Payload compression above a configurable threshold When a payload exceeds `compressionThresholdBytes` (default 1024), the broker SHALL gzip the payload before publishing. Subscribers SHALL gunzip on receive transparently. A payload that exceeds `4_194_304` bytes (4 MiB) hard cap MUST be refused with `WS_BROKER_REDIS_PAYLOAD_TOO_LARGE` — operators see the misuse instead of silently flooding Redis. #### Scenario: payload above threshold is compressed - **WHEN** a 2 KiB payload is broadcast - **THEN** the bytes published to Redis are gzip-prefixed AND the subscriber receives the decompressed payload identical to input #### Scenario: payload above 4 MiB hard cap is refused - **WHEN** a 5 MiB payload is broadcast - **THEN** the call fails with `WS_BROKER_REDIS_PAYLOAD_TOO_LARGE` AND nothing is published **Verified by:** `src/publish/publish.test.ts::"payload above threshold is gzip-compressed end-to-end"` AND `src/publish/publish.test.ts::"payload above hard cap refused with WS_BROKER_REDIS_PAYLOAD_TOO_LARGE"` --- ### Requirement: Reconnect after Redis bounce If the underlying Redis connection drops, the broker SHALL attempt auto-reconnect per `reconnectStrategy` (exponential backoff default). During the outage, `healthz()` returns `degraded`; in-flight publishes either queue (for short outages, configurable) or fail with `WS_BROKER_REDIS_CONNECTION_LOST`. On reconnect, pattern subscriptions MUST be re-established AND `ws.broker.connection_lost_total` reflects the outage count. #### Scenario: short Redis bounce recovers transparently - **WHEN** Redis is restarted (under 5 seconds) AND publishes continue - **THEN** publishes resume after reconnect AND `ws.broker.connection_lost_total` increments by 1 #### Scenario: healthz reflects connection state - **WHEN** Redis is unreachable - **THEN** `broker.healthz()` returns `{ status: "degraded" | "down" }` **Verified by:** `src/reconnect/reconnect.test.ts::"short Redis bounce recovers transparently and counter increments"` AND `src/health/health.test.ts::"healthz reflects Redis connection state"` --- ### Requirement: Same broker serves SSE and WebSocket The broker SHALL be consumable by both `api-ws-bun` (WebSocket) and `api-sse-bun` (SSE). Both engines use `broker.broadcast` for fan-out AND `broker.readBuffer` for reconnect resume. The broker MUST NOT assume one transport over the other. #### Scenario: SSE engine and WS engine share the same broker - **WHEN** an integration test boots `api-sse-bun` and `api-ws-bun` against the same broker AND a broadcast fires - **THEN** both engines deliver to their respective subscribers **Verified by:** `tests/integration/two-host-broadcast.test.ts::"single broker serves both WS and SSE engines"` --- ### Requirement: OTel surface The broker SHALL emit: - `ws.broker.publishes_total` counter `{ topic, outcome }`. - `ws.broker.publish_duration_ms` histogram. - `ws.broker.receives_total` counter `{ topic }`. - `ws.broker.buffer_writes_total` counter. - `ws.broker.buffer_reads_total` counter `{ hit }` (`"hit"` or `"miss"` based on whether events were returned). - `ws.broker.connection_lost_total` counter. #### Scenario: successful publish emits counter + histogram - **WHEN** a publish completes successfully - **THEN** `ws.broker.publishes_total{outcome="ok"}` increments AND one observation lands in `ws.broker.publish_duration_ms` **Verified by:** `src/instruments/instruments.test.ts::"successful publish emits publishes_total + publish_duration_ms"`