feat(scale): swappable pub/sub layer + fix chat.js missed awaits (Phase 6)
Two things: 1. FIX a live regression the async conversion missed: chat.js calls repos via the lazy repos() helper (not the R. prefix), so my sweep skipped it — effectiveStatus / broadcastPresence read `repos().users.byId(userId)` synchronously, but that's a Promise now, so presence broadcasts always reported status 'active' and dropped last_seen. Now awaited (effectiveStatus/broadcastPresence async); touchSeen is a fire-and-forget UPDATE with .catch. Audited all non-R. repo calls — only chat.js was affected (media.js backfill was already awaited). 2. Swappable pub/sub for multi-instance real-time fan-out (the actual blocker to running >1 instance — not the DB). server/pubsub.js picks a backend by PUBSUB_BACKEND (default 'memory'). Local socket delivery is UNCHANGED; publish is additive — memory = no-op (zero hot-path cost, identical single-instance behaviour), redis = fan-out to other instances with a self-echo guard. chat.js pushToUser/broadcastPresence now also publish; each instance subscribes to deliver remote events to its local sockets. Interface is tiny so Redis is one swappable file (Postgres LISTEN/NOTIFY or NATS could drop in the same way — never hardwired, as requested). Dormant redis service added to compose behind the 'scale' profile; redis dep added; PUBSUB_BACKEND/REDIS_URL documented. Validated: smoke 22/22 (memory), e2e chat delivery green. NOTE: full multi-instance also needs distributed presence (isOnline is per-process) + meeting-signaling sharing — chat/presence fan out via this layer; those are follow-ups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
// Redis pub/sub backend — enables running MULTIPLE app instances. Each instance publishes every local
|
||||
// real-time event; every other instance receives it and delivers to ITS local sockets. Selected by
|
||||
// PUBSUB_BACKEND=redis; connection from REDIS_URL (default redis://bizgaze-redis:6379).
|
||||
//
|
||||
// Self-echo guard: Redis delivers a publish to ALL subscribers including the publisher, but the publishing
|
||||
// instance ALREADY delivered locally — so every message is tagged with this instance's id and ignored on
|
||||
// the way back in. Subscriptions made before connect are buffered and flushed in init().
|
||||
const crypto = require('crypto');
|
||||
const INSTANCE = crypto.randomBytes(8).toString('hex');
|
||||
|
||||
let pub = null, sub = null;
|
||||
const pending = []; // [pattern, handler] queued before connect
|
||||
|
||||
async function doSubscribe(pattern, handler) {
|
||||
const onMessage = (message, channel) => {
|
||||
let m; try { m = JSON.parse(message); } catch { return; }
|
||||
if (m.i === INSTANCE) return; // our own publish — already delivered locally
|
||||
try { handler(channel, m.d); } catch (_) {}
|
||||
};
|
||||
if (pattern.includes('*')) await sub.pSubscribe(pattern, onMessage);
|
||||
else await sub.subscribe(pattern, onMessage);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const { createClient } = require('redis');
|
||||
const url = process.env.REDIS_URL || 'redis://bizgaze-redis:6379';
|
||||
pub = createClient({ url });
|
||||
sub = pub.duplicate();
|
||||
pub.on('error', () => {}); sub.on('error', () => {}); // never let a redis blip crash the app
|
||||
await pub.connect();
|
||||
await sub.connect();
|
||||
for (const [pattern, handler] of pending) { try { await doSubscribe(pattern, handler); } catch (_) {} }
|
||||
pending.length = 0;
|
||||
}
|
||||
|
||||
function publish(channel, data) {
|
||||
if (!pub) return;
|
||||
pub.publish(channel, JSON.stringify({ i: INSTANCE, d: data })).catch(() => {});
|
||||
}
|
||||
|
||||
function subscribe(pattern, handler) {
|
||||
if (!sub) { pending.push([pattern, handler]); return; } // buffer until init() connects
|
||||
doSubscribe(pattern, handler).catch(() => {});
|
||||
}
|
||||
|
||||
module.exports = { name: 'redis', init, publish, subscribe };
|
||||
Reference in New Issue
Block a user