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:
+32
-3
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user