feat(live): task 1.5.4 — broadcast consumer group and fan-out

Adds the per-instance Redis Stream consumer group (live-broadcast-{instance_id})
that reads the telemetry stream and fans out each position to subscribed
WebSocket connections without affecting the durable-write consumer path.

Key changes:
- src/shared/codec.ts: moved decodePosition/CodecError out of src/core/ so
  src/live/broadcast.ts can decode positions without crossing the enforced
  src/core/ ↔ src/live/ boundary; src/core/codec.ts now re-exports from there
- src/shared/types.ts: added Position and AttributeValue (same move, same reason);
  src/core/types.ts re-exports both to preserve existing import paths
- src/live/broadcast.ts: createBroadcastConsumer factory — XREADGROUP loop,
  immediate ACK semantics, toPositionMessage mapper, fanOut per event/topic
- src/live/device-event-map.ts: createDeviceEventMap factory — in-memory cache
  of entry_devices × entries join, refreshed every LIVE_DEVICE_EVENT_REFRESH_MS
- src/db/migrations/0002_positions_faulty.sql: adds faulty boolean column and
  positions_device_ts_idx for snapshot-on-subscribe query (task 1.5.5)
- src/main.ts: wired authClient, authzClient, registry, liveServer,
  deviceEventMap, broadcastConsumer; shutdown chain: liveServer → deviceEventMap
  + broadcastConsumer → durable-write consumer → metricsServer → Redis → Postgres
- test/live-broadcast.test.ts: 4 unit tests covering single subscriber, multiple
  subscribers, orphan device, and multi-event device fan-out
This commit is contained in:
2026-05-02 17:52:33 +02:00
parent 90605614f6
commit fbb1f34e9a
9 changed files with 1130 additions and 258 deletions
+32 -3
View File
@@ -22,6 +22,8 @@ import type { InboundMessage } from './live/protocol.js';
import { createAuthClient } from './live/auth.js';
import { createAuthzClient } from './live/authz.js';
import { createSubscriptionRegistry } from './live/registry.js';
import { createBroadcastConsumer } from './live/broadcast.js';
import { createDeviceEventMap } from './live/device-event-map.js';
// -------------------------------------------------------------------------
// Startup: validate config (fail fast on bad env), build logger
@@ -158,19 +160,36 @@ async function main(): Promise<void> {
(conn) => registry.onConnectionClose(conn),
authClient,
);
// 10b. Build the device-event map (Postgres-backed, periodic refresh).
const deviceEventMap = createDeviceEventMap(pool, config, logger, metrics);
// 10c. Build the broadcast consumer (per-instance consumer group fan-out).
const broadcastConsumer = createBroadcastConsumer(
redis,
registry,
deviceEventMap,
config,
logger,
metrics,
);
await liveServer.start();
await deviceEventMap.start();
await broadcastConsumer.start();
// 11. Build and start the durable-write consumer
const consumer = createConsumer(redis, config, logger, metrics, sink);
await consumer.start();
// 12. Install graceful shutdown.
// Shutdown order: live server first (no new connections), then
// broadcast consumer (task 1.5.4 adds this), then durable-write consumer.
// Shutdown order: live server first (no new connections),
// then broadcast consumer, then durable-write consumer last.
installGracefulShutdown({
redis,
pool,
consumer,
broadcastConsumer,
deviceEventMap,
liveServer,
metricsServer,
pgHealth,
@@ -198,6 +217,8 @@ type ShutdownDeps = {
readonly redis: Redis;
readonly pool: pg.Pool;
readonly consumer: { stop: () => Promise<void> };
readonly broadcastConsumer: { stop: () => Promise<void> };
readonly deviceEventMap: { stop: () => void };
readonly liveServer: LiveServer;
readonly metricsServer: http.Server;
readonly pgHealth: { stop: () => void };
@@ -206,7 +227,10 @@ type ShutdownDeps = {
};
function installGracefulShutdown(deps: ShutdownDeps): void {
const { redis, pool, consumer, liveServer, metricsServer, pgHealth, lagSampler, logger: log } = deps;
const {
redis, pool, consumer, broadcastConsumer, deviceEventMap,
liveServer, metricsServer, pgHealth, lagSampler, logger: log,
} = deps;
let shuttingDown = false;
@@ -232,6 +256,11 @@ function installGracefulShutdown(deps: ShutdownDeps): void {
.stop()
.then(() => {
log.info('live server stopped');
deviceEventMap.stop();
return broadcastConsumer.stop();
})
.then(() => {
log.info('broadcast consumer stopped');
return consumer.stop();
})
.then(() => {