Files
2026-05-27 23:29:53 +01:00

6.9 KiB

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 <channelPrefix><topic> 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: <keyPrefix>buffer:<tenant>:<topic>, 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"