The warmup scheduler's circuit-breaker keys were written to Redis without `REDIS_KEY_PREFIX`, so they escaped OmniRoute's namespace and could collide with another app sharing the instance — the one Redis surface the prefix wasn't reaching. Probe: 2/2 pass in `tests/unit/lib/warmupScheduler/redisCircuitBreakerStorePrefix.test.ts`, covering both the prefixed case and the unset/blank case where keys must stay unchanged.
**Batch validation** — boarded with the other 10 PRs of your batch into one worktree cut from `release/v3.8.51`; every PR verified as an ancestor of the combined HEAD before validating.
- Focused tests across all 11 PRs: **104/104 pass** on the combined tree.
- Gates on the combined tree: `check-changelog-integrity` PASS, `check-complexity` PASS, `check-cognitive-complexity` PASS, `typecheck:core` PASS, `check:open-sse-typecheck` PASS.
- `check-file-size` is red, but reproduces with byte-identical line counts on the pure `release/v3.8.51` tip (`open-sse/handlers/imageGeneration.ts` 3304, `open-sse/services/combo/roundRobinCombo.ts` 1221, `open-sse/utils/stream.ts` 3115). Inherited base-red, nothing added by this batch — it is also why this PR's "Fast Quality Gates" check was red.
**Reconciled** — this PR was `CONFLICTING`. The conflict was in `docs/reference/ENVIRONMENT.md` and purely additive: the release tip had inserted `APP_BIND_HOST` / `QDRANT_BIND_HOST` / `BIFROST_BIND_HOST` rows directly above the `REDIS_KEY_PREFIX` row you edited. Kept both sides — the tip's three new rows and your updated description naming the warmup circuit breaker — then merged the current release branch in (120a92f6) and re-ran your focused test on the reconciled tree: 2/2 pass. No line of your diff was dropped.
Thanks, @datrixlab — you also updated `.env.example`, `docs/ops/REDIS_PRODUCTION_CONFIG.md` and `ENVIRONMENT.md` alongside the code, which is why the only thing left to do here was a mechanical conflict resolution.
8.6 KiB
title, version, lastUpdated
| title | version | lastUpdated |
|---|---|---|
| Redis Production Configuration Guide | 3.8.50 | 2026-08-06 |
Redis Production Configuration Guide
Overview
Redis is an optional, soft dependency in OmniRoute — the application degrades gracefully (in-memory fallbacks) when Redis is unavailable. In production, tuning Redis reduces latency for four distinct workloads:
| Workload | Driver | Client Factory | Key Pattern |
|---|---|---|---|
| Rate limiting | rateLimiter.ts |
getRedisClient() — lazy ioredis singleton |
<prefix>rl:* Lua‑atomic rate limit windows |
| Auth cache | apiKeys.ts |
Reuses rateLimiter's client |
<prefix>auth:api_key:<sha256> with TTL |
| Quota store | redisQuotaStore.ts |
Separate getRedisClient(url) singleton |
<prefix>quota:* configurable per-instance |
| Warmup circuit breaker | redisCircuitBreakerStore.ts |
Separate client in circuitBreakerFactory.ts |
<prefix>warmup:cb:<connectionId> |
All four workloads share one namespace prefix so OmniRoute can co-exist with other apps on a
single Redis instance (e.g. 127.0.0.1:6379). See Key Namespacing.
Current Configuration (Code Defaults)
| Setting | Value | Where |
|---|---|---|
REDIS_URL env var |
redis://redis:6379 (compose), optional |
rateLimiter.ts:5, .env.example |
REDIS_KEY_PREFIX env var |
omniroute: (default) |
rateLimiter.ts, redisQuotaStore.ts, redisCircuitBreakerStore.ts, .env.example |
QUOTA_STORE_REDIS_URL env var |
separate, can differ from REDIS_URL |
quota/storeFactory.ts |
QUOTA_STORE_DRIVER |
"sqlite" (default), "redis" optional |
quota/storeFactory.ts |
ioredis maxRetriesPerRequest |
3 |
rateLimiter.ts client creation |
enableReadyCheck |
not set (ioredis default: true) |
— |
lazyConnect |
not set (ioredis default: false) |
— |
retryStrategy |
not set (ioredis default: 200ms base, exponential) | — |
| TLS / password / DB index | not configured | — |
| Sentinel / Cluster | not configured — standalone single-node only | — |
Key Namespacing
OmniRoute shares a Redis instance with whatever else runs on the host. Without a namespace,
keys like auth:api_key:<sha256> or rl:* could collide with keys from other applications
using the same Redis (this instance runs Redis on 127.0.0.1:6379 alongside other services).
Set REDIS_KEY_PREFIX to a non-empty string to prefix every OmniRoute key:
# .env — all OmniRoute keys become omniroute:rl:*, omniroute:auth:*, omniroute:quota:*, omniroute:warmup:cb:*
REDIS_KEY_PREFIX=omniroute:
- Default:
omniroute:(applied whenREDIS_KEY_PREFIXis unset or blank). - Applied to: rate limiter + auth cache (shared
ioredisclient viakeyPrefix) and the quota store (KEY_PREFIX = "${REDIS_KEY_PREFIX}quota") and the warmup circuit breaker (KEY_PREFIX = "${REDIS_KEY_PREFIX}warmup:cb:"). - Changing the prefix when keys already exist in Redis orphans the old keys (they expire
via TTL / LRU). Safe to change; no migration needed. The one exception is a warmup
circuit-breaker key for a connection marked forbidden: it is persisted without a TTL, so
list leftovers with
redis-cli --scan --pattern '<old-prefix>warmup:cb:*'and delete them. - ioredis
keyPrefixautomatically prepends the prefix on writes and strips it on reads, so application code never sees the prefix.
Recommended Production Tuning
1. Connection Pool / Client Options (ioredis Redis constructor)
The current code creates a single new Redis(url) with no custom options. For production
multi‑replica deployments, pass a client factory in the code or wrap getRedisClient():
const redis = new Redis(REDIS_URL, {
maxRetriesPerRequest: null, // no retry limit; let retryStrategy decide
enableReadyCheck: true, // verify server is ready before accepting calls
lazyConnect: true, // don't connect on construction; wait for first call
retryStrategy: (times) => {
if (times > 10) return null; // give up after 10 retries → reconnect later
return Math.min(times * 200, 5000); // 200ms, 400ms, …, 5s cap
},
enableAutoPipelining: true, // coalesce concurrent commands into one TCP write
keepAlive: 10000, // TCP keep‑alive every 10s
});
Key trade-offs:
maxRetriesPerRequest: null+retryStrategy— preferred for production so transient Redis restarts don't immediately fail every request. The in-memory fallback incheckRateLimit()absorbs the failure path.lazyConnect: true— avoids a startup dependency on Redis being up before the server begins accepting connections.enableAutoPipelining: true— reduces round-trips for concurrent rate-limit checks; beneficial at >50 RPS on a single connection.
2. Redis Server Configuration (redis.conf)
# Memory
maxmemory 80% # leave room for OS page cache
maxmemory-policy allkeys-lru # evict stale auth cache entries under pressure
# Persistence (optional — OmniRoute is crash‑safe without it)
save 300 1 # snapshot at least every 5 min if ≥1 key changed
appendonly no # AOF not needed; data is regeneratable
appendfsync no # no fsync overhead (RDB is sufficient)
# Networking
timeout 0 # no idle disconnect
tcp-keepalive 300 # 5 min keep‑alive
tcp-backlog 511 # connection backlog for bursty load
# Performance
hz 10 # default; 100 for latency‑sensitive
activedefrag yes # auto‑defragment when fragmentation >10%
Trade-off for maxmemory-policy allkeys-lru: Auth cache entries may be evicted under
memory pressure. This is safe — setCachedApiKey always re-populates on miss, and the
SQLite fallback is authoritative. The rate-limiter Lua script creates small keys that are
short-lived by design.
3. Docker Compose Settings
The prod compose (docker-compose.prod.yml) uses redis:8.6.2-alpine. Add:
redis:
image: redis:8.6.2-alpine
command: [
"redis-server",
"--maxmemory", "512mb",
"--maxmemory-policy", "allkeys-lru",
"--activedefrag", "yes",
"--save", "300 1",
]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
4. Multi‑Instance / Scaling Considerations
Single Redis for all replicas — the rate-limiter Lua script depends on a single authoritative key space. Multiple Redis instances behind replicas would lose atomicity and double the budget. Use a single Redis (or Redis Sentinel cluster with failover) for all application replicas.
Connection count: Each application replica opens 2 TCP connections to Redis (rate limiter client + quota store client). At 10 replicas → 20 connections, well within a default Redis instance's 10k connection ceiling.
5. Monitoring
Expose via health-check endpoint:
// src/app/api/monitoring/health/route.ts already calls rateLimiter functions
// Add Redis-specific checks:
// 1. PING latency via ioredis .ping()
// 2. Memory usage via INFO memory
// 3. Connection count via INFO clients
// 4. Hit rate for maxmemory-policy (evicted_keys / keyspace_hits)
Key metrics to watch:
- Evicted keys / sec — if persistently non-zero, increase
maxmemory - Blocked clients — non-zero suggests slow Lua scripts or high contention
- Rejected connections — connection limit hit; rare at 20 connections
Architecture Diagram
flowchart LR
subgraph App["App Replica"]
RL[rateLimiter.ts]
AK[apiKeys.ts]
QS[redisQuotaStore.ts]
end
RL -- "REDIS_URL" --> R1[(Redis\nshared)]
AK -- "reuses RL's client" --> R1
QS -- "QUOTA_STORE_REDIS_URL" --> R2[(Redis\nquota store)]
R1 --> R2 -- "can be same instance" --> R1
References
| File | Purpose |
|---|---|
src/shared/utils/rateLimiter.ts |
Primary Redis client, Lua rate-limit script, in-memory fallback |
src/lib/db/apiKeys.ts |
Auth cache — Redis→SQLite fallback |
src/lib/quota/redisQuotaStore.ts |
Separate Redis client for optional quota store |
src/lib/quota/storeFactory.ts |
Switches between sqlite and redis quota drivers |
docker-compose.prod.yml |
Prod Redis container (image redis:8.6.2-alpine) |
.env.example |
Redis env vars documentation |
src/app/api/local/redis/ |
API routes for dev container orchestration |
bin/cli/commands/redis.mjs |
CLI commands for dev container orchestration |