Skip to content

Scale: Redis-backed rate limiting + presence TTLs#76

Merged
jwicks31 merged 1 commit into
mainfrom
claude/redis-ratelimit-presence-ttl
Jun 13, 2026
Merged

Scale: Redis-backed rate limiting + presence TTLs#76
jwicks31 merged 1 commit into
mainfrom
claude/redis-ratelimit-presence-ttl

Conversation

@jwicks31

Copy link
Copy Markdown
Owner

What

Next-roadmap item 2/2 (operator side). Hardens the multi-process (REDIS_URL) path so two things that were per-process become correct cluster-wide: per-IP rate limiting and the presence roster.

How

Rate limiting (src/rate-limit.ts)

  • 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. Fails open on a Redis error — a limiter outage must never take the API down. The in-memory limiter is unchanged when Redis is absent.

Presence durability (src/modules/realtime/transport.ts)

  • The Redis roster moves from a plain hash to a sorted set scored by expiry (member = cid) + 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, defaulting to 30s/10s.

Neither changes the API, and the single-process default is untouched.

Verification

  • SQLite 62/62 (the in-memory limiter path is unchanged), Postgres 44/44.
  • Redis suite 3/3 (npm run test:redis, against a real redis-server): cross-process fan-out; a shared rate-limit window where the 4th request is 429 even though it lands on the second instance (proving the counter is shared); and a "crashed" process's presence member aging out of the roster after its TTL.
  • CI's verify job already runs test:redis against the redis:7 service. Docs updated (.env.example, README, /docs).

This completes the two next-roadmap items we lined up (public realtime #75 + this). Happy to keep going down the roadmap — schemas, cursor pagination, /metrics + /ready, or CREATE INDEX CONCURRENTLY are the next candidates.

https://claude.ai/code/session_018efxvWw3MRjdtvE5xgBqya


Generated by Claude Code

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).
@jwicks31 jwicks31 merged commit 483d597 into main Jun 13, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants