From ddd8980bfd4d1eb5f0be4f7edd8b03ed6c2cad49 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 08:08:44 +0000 Subject: [PATCH] Scale: Redis-backed rate limiting + presence TTLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden the multi-process (REDIS_URL) path so the things that were per-process become correct cluster-wide. Rate limiting: - registerRateLimit now takes an optional Redis URL. With Redis, the per-IP fixed window is a shared INCR+PEXPIRE counter (PTTL for the reset header), so the limit holds across every process behind a load balancer. A Redis hiccup fails OPEN — a limiter outage must never take the API down. In-memory limiter unchanged when Redis is absent. Presence durability: - The Redis roster moves from a plain hash to a sorted set scored by expiry (member = cid) plus a cid→identity hash, with each process heartbeating its own members to push their expiry forward. If a process dies without a graceful leave, its members simply age out — no ghosts in the roster. members() evicts expired entries on read. (Redis 7.0 has no per-field hash TTL, hence the zset.) TTL/heartbeat are configurable for tests. Verification: 62/62 SQLite (in-memory limiter path unchanged), 44/44 Postgres 16, and the Redis suite (3/3): cross-process fan-out, a shared rate-limit window that blocks across two instances, and a crashed process's presence aging out. Docs updated (.env.example, README, /docs). --- .env.example | 6 ++- README.md | 2 +- public/docs.html | 2 +- src/modules/realtime/transport.ts | 83 +++++++++++++++++++++++++++---- src/rate-limit.ts | 63 +++++++++++++++++++---- src/server.ts | 3 +- test/realtime-redis.test.ts | 48 ++++++++++++++++++ 7 files changed, 181 insertions(+), 26 deletions(-) diff --git a/.env.example b/.env.example index bb0f3af..88d2851 100644 --- a/.env.example +++ b/.env.example @@ -30,8 +30,10 @@ # --- realtime: in-memory (default) | redis --- # Unset = single-process in-memory pub/sub + presence (the zero-config default). -# Set REDIS_URL to fan websocket messages and presence across multiple server -# processes for horizontal scale. The API and SDK are identical either way. +# Set REDIS_URL to scale horizontally across multiple server processes: websocket +# messages + presence fan out via Redis (presence entries auto-expire if a process +# dies), and the per-IP rate limit becomes a shared, cluster-wide window. The API +# and SDK are identical either way. # REDIS_URL=redis://localhost:6379 # --- file storage: disk (default) | s3 --- diff --git a/README.md b/README.md index b6db00a..c5398a4 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,7 @@ scale with an identical API/SDK. | Variable | Default | Purpose | | ----------- | --------- | ------------------------------------------------------------- | -| `REDIS_URL` | _(unset)_ | Redis connection string; enables cross-process realtime fan-out | +| `REDIS_URL` | _(unset)_ | Redis connection string; enables cross-process realtime fan-out, cluster-wide presence (entries auto-expire if a process dies), and a shared per-IP rate-limit window | ```bash REDIS_URL=redis://localhost:6379 # then run N server processes behind a load balancer diff --git a/public/docs.html b/public/docs.html index 2e8418f..c3a12e0 100644 --- a/public/docs.html +++ b/public/docs.html @@ -333,7 +333,7 @@

Core & access

API_KEY—Lock the instance with one bearer key ADMIN_KEY—Enable multi-tenant projects CORS_ORIGIN* (open)Comma-separated browser origin allowlist - REDIS_URL—Cross-process realtime (messages + presence) for horizontal scale + REDIS_URL—Horizontal scale: cross-process realtime (messages + presence) + a shared per-IP rate-limit window LOG_LEVELinfotrace … fatal, or silent diff --git a/src/modules/realtime/transport.ts b/src/modules/realtime/transport.ts index 271e000..4f49834 100644 --- a/src/modules/realtime/transport.ts +++ b/src/modules/realtime/transport.ts @@ -71,12 +71,26 @@ interface Envelope { frame: unknown; } +export interface RealtimeTransportOptions { + /** How long a roster entry lives without a heartbeat (ms). */ + ttlMs?: number; + /** How often this process re-asserts its local members (ms). */ + heartbeatMs?: number; +} + +const DEFAULT_TTL_MS = Number(process.env.REALTIME_PRESENCE_TTL_MS ?? 30_000); + /** * Redis transport: PUBLISH every frame to a shared topic so other processes can - * deliver it to their local sockets, and keep each channel's roster in a Redis - * hash so presence snapshots span the whole cluster. Frames we publish ourselves - * are tagged with this process's `origin` and skipped on receive (we already + * deliver it to their local sockets, and keep each channel's roster in Redis so + * presence snapshots span the whole cluster. Frames we publish ourselves are + * tagged with this process's `origin` and skipped on receive (we already * delivered them locally). + * + * The roster is a sorted set per channel scored by expiry (member = cid), plus a + * hash of cid → identity. Each process heartbeats its own members to push their + * expiry forward; if a process dies, its entries simply age out — so a hard + * crash can't leave ghosts in the roster (Redis 7.0 has no per-field hash TTL). */ class RedisTransport implements RealtimeTransport { readonly id = 'redis'; @@ -84,8 +98,13 @@ class RedisTransport implements RealtimeTransport { private readonly sub: Redis; private readonly origin = `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; private handler: (channel: string, frame: unknown) => void = () => {}; + private readonly ttlMs: number; + // Members this process owns, so the heartbeat can keep them alive: channel → cids. + private readonly local = new Map>(); + private readonly heartbeat: NodeJS.Timeout; - constructor(redisUrl: string) { + constructor(redisUrl: string, opts: RealtimeTransportOptions = {}) { + this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS; this.pub = new Redis(redisUrl, { maxRetriesPerRequest: null }); this.sub = this.pub.duplicate(); void this.sub.subscribe(PUBSUB_CHANNEL); @@ -99,6 +118,9 @@ class RedisTransport implements RealtimeTransport { if (env.origin === this.origin) return; // we already delivered locally this.handler(env.channel, env.frame); }); + + this.heartbeat = setInterval(() => void this.beat(), opts.heartbeatMs ?? Math.max(1000, Math.floor(this.ttlMs / 3))); + this.heartbeat.unref?.(); } onRemote(handler: (channel: string, frame: unknown) => void): void { @@ -112,27 +134,66 @@ class RedisTransport implements RealtimeTransport { }); } + private zsetKey = (channel: string): string => ROSTER_PREFIX + channel; + private idKey = (channel: string): string => `${ROSTER_PREFIX}${channel}:id`; + async addMember(channel: string, member: Member): Promise { - await this.pub.hset(ROSTER_PREFIX + channel, member.cid, member.identity ?? ''); + let set = this.local.get(channel); + if (!set) this.local.set(channel, (set = new Set())); + set.add(member.cid); + await this.pub + .multi() + .zadd(this.zsetKey(channel), Date.now() + this.ttlMs, member.cid) + .hset(this.idKey(channel), member.cid, member.identity ?? '') + .exec(); } async removeMember(channel: string, cid: string): Promise { - await this.pub.hdel(ROSTER_PREFIX + channel, cid); + this.local.get(channel)?.delete(cid); + await this.pub.multi().zrem(this.zsetKey(channel), cid).hdel(this.idKey(channel), cid).exec(); } async members(channel: string): Promise { - const map = await this.pub.hgetall(ROSTER_PREFIX + channel); - return Object.entries(map).map(([cid, identity]) => ({ cid, identity: (identity as string) || null })); + const now = Date.now(); + // Evict any expired entries (e.g. a crashed process's members) first. + const expired = await this.pub.zrangebyscore(this.zsetKey(channel), 0, now); + if (expired.length) { + await this.pub.multi().zrem(this.zsetKey(channel), ...expired).hdel(this.idKey(channel), ...expired).exec(); + } + const cids = await this.pub.zrangebyscore(this.zsetKey(channel), `(${now}`, '+inf'); + if (cids.length === 0) return []; + const identities = await this.pub.hmget(this.idKey(channel), ...cids); + return cids.map((cid, i) => ({ cid, identity: identities[i] || null })); + } + + /** Push each locally-owned member's expiry forward so live members never age out. */ + private async beat(): Promise { + const now = Date.now(); + for (const [channel, cids] of this.local) { + if (cids.size === 0) continue; + const args: (string | number)[] = []; + for (const cid of cids) args.push(now + this.ttlMs, cid); + try { + await this.pub.zadd(this.zsetKey(channel), ...args); + } catch { + /* transient; the next beat will retry */ + } + } } async close(): Promise { + clearInterval(this.heartbeat); + this.local.clear(); // Graceful QUIT drains in-flight commands and disables reconnection, so we - // don't leave pending hgetall/publish to reject after shutdown. + // don't leave pending commands to reject after shutdown. await Promise.allSettled([this.pub.quit(), this.sub.quit()]); } } /** Construct the realtime transport selected by REDIS_URL (defaults to local). */ -export function getRealtimeTransport(redisUrl = config.realtime.redisUrl): RealtimeTransport { - return redisUrl ? new RedisTransport(redisUrl) : new LocalTransport(); +export function getRealtimeTransport( + redisUrl = config.realtime.redisUrl, + opts: RealtimeTransportOptions = {}, +): RealtimeTransport { + return redisUrl ? new RedisTransport(redisUrl, opts) : new LocalTransport(); } diff --git a/src/rate-limit.ts b/src/rate-limit.ts index 62457e7..00a2e39 100644 --- a/src/rate-limit.ts +++ b/src/rate-limit.ts @@ -1,3 +1,4 @@ +import { Redis } from 'ioredis'; import type { FastifyInstance } from 'fastify'; export interface RateLimitOptions { @@ -7,14 +8,19 @@ export interface RateLimitOptions { } /** - * In-memory, per-IP fixed-window rate limiter. No dependency, single-process — - * exactly the scope of one VM. Sets X-RateLimit-* headers and answers 429 with - * the standard error envelope once a client exceeds its window. + * Per-IP fixed-window rate limiter. In-memory by default (single process — the + * zero-config VM); when a Redis URL is given the window counter lives in Redis + * so the limit holds across every process in a horizontally-scaled deployment. + * Either way it sets X-RateLimit-* headers and answers 429 once a client exceeds + * its window. */ -export function registerRateLimit(app: FastifyInstance, opts: RateLimitOptions): void { +export function registerRateLimit(app: FastifyInstance, opts: RateLimitOptions, redisUrl: string | null = null): void { if (opts.max <= 0) return; // disabled + if (redisUrl) registerRedisLimiter(app, opts, redisUrl); + else registerMemoryLimiter(app, opts); +} - const { max, windowMs } = opts; +function registerMemoryLimiter(app: FastifyInstance, { max, windowMs }: RateLimitOptions): void { const hits = new Map(); // Periodically drop expired buckets so the map can't grow unbounded. @@ -35,14 +41,51 @@ export function registerRateLimit(app: FastifyInstance, opts: RateLimitOptions): hits.set(req.ip, bucket); } bucket.count += 1; - - reply.header('X-RateLimit-Limit', max); - reply.header('X-RateLimit-Remaining', Math.max(0, max - bucket.count)); - reply.header('X-RateLimit-Reset', Math.ceil(bucket.resetAt / 1000)); - + setRateHeaders(reply, max, bucket.count, bucket.resetAt - now); if (bucket.count > max) { reply.header('Retry-After', Math.ceil((bucket.resetAt - now) / 1000)); return reply.code(429).send({ error: 'rate_limited', message: 'Too many requests.' }); } }); } + +function registerRedisLimiter(app: FastifyInstance, { max, windowMs }: RateLimitOptions, redisUrl: string): void { + const redis = new Redis(redisUrl, { maxRetriesPerRequest: null }); + app.addHook('onClose', async () => { + redis.disconnect(); + }); + + app.addHook('onRequest', async (req, reply) => { + if (req.url === '/health') return; + + const key = `zero:rl:${req.ip}`; + let count: number; + let ttl: number; + try { + // INCR the window counter and read its TTL in one round trip. + const res = await redis.multi().incr(key).pttl(key).exec(); + count = Number(res?.[0]?.[1] ?? 0); + ttl = Number(res?.[1]?.[1] ?? -1); + // First hit of a new window: start the expiry. + if (ttl < 0) { + await redis.pexpire(key, windowMs); + ttl = windowMs; + } + } catch { + // Fail open: a Redis hiccup must not take the whole API down. + return; + } + + setRateHeaders(reply, max, count, ttl); + if (count > max) { + reply.header('Retry-After', Math.ceil(ttl / 1000)); + return reply.code(429).send({ error: 'rate_limited', message: 'Too many requests.' }); + } + }); +} + +function setRateHeaders(reply: { header: (k: string, v: number) => void }, max: number, count: number, ttlMs: number): void { + reply.header('X-RateLimit-Limit', max); + reply.header('X-RateLimit-Remaining', Math.max(0, max - count)); + reply.header('X-RateLimit-Reset', Math.ceil((Date.now() + ttlMs) / 1000)); +} diff --git a/src/server.ts b/src/server.ts index c601636..4edb32a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -136,7 +136,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise { + // A tiny shared limit across two instances; the 4th request overall is blocked + // even though it lands on the second instance (proving the counter is shared). + const rl = { max: 3, windowMs: 60_000 }; + const s1 = await buildServer({ logger: false, rateLimit: rl }); + const s2 = await buildServer({ logger: false, rateLimit: rl }); + await s1.listen({ host: '127.0.0.1', port: 0 }); + await s2.listen({ host: '127.0.0.1', port: 0 }); + const u1 = `http://127.0.0.1:${(s1.server.address() as AddressInfo).port}/v1`; + const u2 = `http://127.0.0.1:${(s2.server.address() as AddressInfo).port}/v1`; + const cleaner = new Redis(REDIS as string, { maxRetriesPerRequest: null }); + try { + await cleaner.del('zero:rl:127.0.0.1'); // start from a clean window + + assert.equal((await fetch(u1)).status, 200); // 1 + assert.equal((await fetch(u1)).status, 200); // 2 + assert.equal((await fetch(u2)).status, 200); // 3 (other instance) + const fourth = await fetch(u2); // 4 → over the shared limit + assert.equal(fourth.status, 429, 'the shared window should block across processes'); + assert.equal(fourth.headers.get('x-ratelimit-limit'), '3'); + } finally { + cleaner.disconnect(); + await s1.close(); + await s2.close(); + } +}); + +test('presence (redis): a crashed process’s members age out of the roster', { skip: !REDIS }, async () => { + const channel = `ttl-room-${Date.now()}`; + // t1 owns a member with a short TTL; t2 is an independent reader. + const t1 = getRealtimeTransport(REDIS, { ttlMs: 600, heartbeatMs: 150 }); + const t2 = getRealtimeTransport(REDIS); + try { + await t1.addMember(channel, { cid: 'ghost', identity: 'casper' }); + assert.deepEqual((await t2.members(channel)).map((m) => m.cid), ['ghost']); + + // Simulate a hard crash: stop t1 (its heartbeat) WITHOUT a graceful leave. + await t1.close(); + await wait(900); // > ttl, with no heartbeat refreshing the entry + + assert.deepEqual(await t2.members(channel), [], 'a stale member should expire'); + } finally { + await t2.close(); + } +});