Files
OmniRoute/docs/ops/REDIS_PRODUCTION_CONFIG.md
Nguyen Thanh Dat 5acac8021d fix(redis): namespace warmup circuit-breaker keys with REDIS_KEY_PREFIX (#13328)
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.
2026-09-16 21:09:38 -03:00

209 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "Redis Production Configuration Guide"
version: 3.8.50
lastUpdated: 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:*` Luaatomic 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](#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:
```bash
# .env — all OmniRoute keys become omniroute:rl:*, omniroute:auth:*, omniroute:quota:*, omniroute:warmup:cb:*
REDIS_KEY_PREFIX=omniroute:
```
- **Default:** `omniroute:` (applied when `REDIS_KEY_PREFIX` is unset or blank).
- **Applied to:** rate limiter + auth cache (shared `ioredis` client via `keyPrefix`) 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 `keyPrefix`** automatically 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
multireplica deployments, pass a client factory in the code or wrap `getRedisClient()`:
```typescript
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 keepalive 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 in
`checkRateLimit()` 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 crashsafe 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 keepalive
tcp-backlog 511 # connection backlog for bursty load
# Performance
hz 10 # default; 100 for latencysensitive
activedefrag yes # autodefragment 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:
```yaml
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. MultiInstance / 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:
```typescript
// 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
```mermaid
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 |