Redis layout
The Redis keys, TTLs, Lua scripts, and pub/sub channels obleth uses, and how it rebuilds Redis from Postgres.
9 min readUpdated for v2.4.0Edit on GitHub
Redis holds the data plane's hot state: resolver entries for keys, models, and MCP servers, budget counters, shared fairshare slots, and a few caches. Postgres stays the source of truth for configuration; see Postgres schema.
Requirements
- A password. Redis holds API keys, so an open instance lets anyone who
reaches it mint keys. Connect with
OBLETH_REDIS_URL=redis://:PASSWORD@host:6379. The bundled Compose and Helm Redis always run with--requirepass(REDIS_PASSWORD, Helmredis.password). - One primary. Redis Cluster and sharded keyspaces are not supported: the budget and slot scripts touch several keys in one call. A managed endpoint that fails over behind one address is fine.
- Timeouts. Each command waits at most
OBLETH_REDIS_RESPONSE_TIMEOUT_MS(250) and each connectOBLETH_REDIS_CONNECT_TIMEOUT_MS(2000). A failed budget call followsOBLETH_FAIL_OPEN; a failed key lookup returns503.
redis-cli -a "$REDIS_PASSWORD" --no-auth-warning --scan --pattern 'obleth:model:*'
Key prefixes
| Key pattern | Holds | TTL |
|---|---|---|
obleth:key:<hash> | String: ResolvedKey JSON, keyed by the key's key_hash | None |
obleth:model:<name> | String: resolved model route, one entry per name, alias and variant | None |
obleth:mcp:<name> | String: resolved MCP server | None |
obleth:budget:<tenant_id> | Hash: per-minute bucket tokens, ts (ms); absent when tokens_per_minute is 0 | 10 min, re-armed on every reserve and reconcile |
obleth:term_usage:<scope> | Hash: committed term usage period, tokens, cost | None; an old TTL is cleared on the next read or write |
obleth:term_reserved:<scope> | Hash: in-flight term reservations period, tokens, cost | 10 min from creation, never re-armed |
obleth:cache:<sha256> | String: cached response | The model's cache_ttl_secs |
obleth:compress:<tenant_id>:<sha256> | String: original text the compression boon replaced | Compression Originals kept (3600 s) |
obleth:knowledge:q:<sha256> | Bytes: query embedding, little-endian f32 | Knowledge Query cache (600 s) |
obleth:auto:<tenant_id>:<sha256> | String: an auto conversation's model and level, stored under a hash of its last turn; or 1 under a hash of a request already answered | 2 hours |
obleth:daily:mcp:<server>:<YYYY-MM-DD> | Hash: direct, tool_calls, errors, ms for one MCP server and UTC day | 40 days, re-armed on each write |
obleth:daily:knowledge:<collection_id>:<YYYY-MM-DD> | Hash: searches, hits, chunks for one collection and UTC day | 40 days, re-armed on each write |
obleth:gateway:replicas | Sorted set: live gateway replicas, scored by heartbeat expiry | None; expired members pruned each heartbeat |
obleth:fairshare:slots | Hash: cluster-wide slot counts and wait markers | None |
obleth:fairshare:held:<instance> | Hash: slots one replica holds | None; reclaimed when its heartbeat expires |
obleth:fairshare:hold:<id> | Hash: one slot held for a session's next step | None; freed once its expiry in obleth:fairshare:holds passes |
obleth:fairshare:holds | Sorted set: held slot ids, scored by expiry | None; expired members freed and pruned by slot scripts and heartbeats |
obleth:fairshare:recent | Sorted set: session hold ids, scored by when the session's last step finished | None; members older than the warm window pruned on each hold |
obleth:fairshare:chain | Hash: session hold id to its holds in a row while the pool was waited on | None; a field is removed with its recent member, or when the session's slot is released |
obleth:fairshare:view:<instance> | String: JSON view of one replica's scheduler (pools, groups, tenants and keys with work), merged into the cluster-wide live view | Three publish intervals; deleted on clean shutdown |
obleth:provisioner:heartbeat | String: epoch seconds of the provisioner's last poll | 3600 s |
obleth:provisioner:version | String: provisioner build JSON (version, git_sha, built_at) | 3600 s |
obleth:provisioner:tick | String: last reconcile tick JSON (status, detail, at, last_ok_at, since) | 3600 s |
<scope> is a tenant id, or a key id for a key-level budget. A TTL of 0 for
the response cache, compression originals, or query cache writes nothing.
| Channel | Carries |
|---|---|
obleth:invalidate | Resolver invalidations; see Invalidation channel |
obleth:fairshare:released | Id of a pool where a slot was freed while someone waited; see Shared fairshare slots |
- Response cache.
<sha256>covers the tenant id, model name, and raw request body, so no entry reaches another tenant (the compression key carries the tenant id too). Bodies over 512 KiB are not cached. See Response cache. - Query cache. The hash covers the embedding model and the query text. A read error, or a length not a multiple of 4 bytes, counts as a miss.
- Daily counters. MCP calls through
/mcp/<name>count asdirect, calls from the gateway tool loop astool_calls.GET /api/v1/stats/daily(kind=mcporknowledge,days1–40, default 7) reads them. - Provisioner keys. The gateway writes them each time the provisioner fetches its Slurm settings, so every replica reports the same status. See Slurm provisioning.
- Video jobs live in Postgres (
video_jobs), not Redis, because a key with a TTL can be evicted mid-render.
Committed term usage (obleth:term_usage:*) exists only in Redis. Losing the
Redis data resets term-budget counters for the current period. On a deployment
with term budgets, keep Redis persistence on (the bundled Compose and Helm
Redis use an append-only file).
ResolvedKey
Everything admission needs is in one JSON value, so the request path does no relational lookup:
{
"key_id": "661f9500-...",
"tenant_id": "550e8400-...",
"tenant_name": "chatbot",
"fairshare_group": "default",
"group_weight": 100,
"weight": 100,
"tokens_per_minute": 50000,
"max_in_flight": null,
"disabled": false,
"status": "active",
"timezone": "UTC",
"active_from": null,
"active_until": null,
"weekly_windows": null,
"budget_tokens": null,
"budget_cost_usd": null,
"budget_period": null,
"budget_started_at": null,
"key_budget_tokens": null,
"key_budget_cost_usd": null,
"key_budget_period": null,
"key_budget_started_at": null,
"key_weight": 100,
"key_max_in_flight": null,
"end_user_fairshare": false,
"allowed_models": null,
"internal": false,
"tracing_enabled": false,
"guardrails_policy": null,
"compression_policy": null,
"synthetic": false
}
weight and max_in_flight are the tenant's; key_weight and
key_max_in_flight are the key's share and cap inside each model's pool.
end_user_fairshare gives each end user the key names their own place in
those pools.
internal marks gateway-owned traffic such as health probes; synthetic
marks benchmark traffic.
Budget scripts
Budget changes run as Lua scripts, so they are atomic across replicas.
| Script | Keys | Does |
|---|---|---|
RESERVE | bucket | Refills the bucket for the elapsed time, then reserves the estimate |
RESERVE_WITH_TERM | bucket, term usage, term reserved | Checks committed plus reserved usage against the term cap, then reserves from the bucket and records the term reservation |
RECONCILE | bucket | Applies estimate minus actual to the bucket |
TERM_USAGE_READ, TERM_USAGE_ADD | term usage | Reads or adds to committed usage, resetting it when the period changes |
TERM_RECONCILE | term usage, term reserved | Releases a reservation and commits the actual usage once |
The bucket refills at tokens_per_minute / 60000 tokens per millisecond. An
estimate larger than the whole bucket is admitted when the bucket is full and
runs into debt down to -capacity. The term check runs first, so a request
refused for its term budget holds no bucket tokens.
| Condition | Status | Message |
|---|---|---|
| Per-minute bucket empty | 429 | token budget exceeded |
| Tenant term budget used up | 403 | tenant term budget exhausted |
| Key term budget used up | 403 | api key term budget exhausted |
A reservation that is never settled, for example after a crash, goes when its 10-minute key expires. All of a scope's reservations expire together, so brief over-admission is possible, bounded by concurrency times the estimate. Commits are always exact, and a failed upstream call is settled unbilled. See Quotas and rate limits.
Shared fairshare slots
With OBLETH_FAIRSHARE_SHARED_SLOTS on (the default) and more than one replica
live, every fairshare limit is a set of cluster-wide slots. See
Multiple gateway replicas.
- Replica set (
obleth:gateway:replicas): one member per process, its host name plus a random suffix, scored by heartbeat expiry on the Redis clock. Each replica heartbeats everyOBLETH_FAIRSHARE_REPLICA_HEARTBEAT_SECS(5); a member expires afterOBLETH_FAIRSHARE_REPLICA_TTL_SECS(15, at least twice the interval). - Totals (
obleth:fairshare:slots):gfor every pool together,p|<pool>,t|<pool>|<tenant>, andk|<pool>|<key>, plus wait markersw|<pool>andw(global) holding the Redis time until which a replica waits, andwa|<pool>, until which a request there has waited too long for slots to be held (session affinity). - Holdings (
obleth:fairshare:held:<instance>): the same count fields for one replica. Every script applies a holdings change to the totals too, so the totals are always their sum. Counts at zero are deleted. - Held slots (session affinity):
each
obleth:fairshare:hold:<id>holds the count fields of one slot kept for a session's next step, plush|<pool>, which the totals also count, so a held slot still counts against every limit.<id>is the pool id,|, the key id,/and the session id. Their ids are inobleth:fairshare:holds, scored by expiry on the Redis clock, and the totals are the sum of the replicas' holdings and the held slots.
| Script | Keys | Does |
|---|---|---|
REPLICA_HEARTBEAT | replica set, totals, held slot ids | Refreshes this replica, reclaims the holdings of expired replicas, prunes them, frees expired held slots, returns the live count |
REPLICA_DEREGISTER | replica set, totals, holdings | On clean shutdown, frees what the replica holds and removes it |
SLOT_ACQUIRE | totals, holdings, replica set, held slot ids | Frees expired held slots. A claim for a request that waited the bypass limit marks the pool as starving (wa|<pool>, 1 s) and frees its other held slots. Takes over the slot held for the claim's session if there is one; a claim for that alone stops there. Otherwise takes pool, global, tenant, and key slots together, or none. Refused for an expired replica. A refusal marks the pool (or w) as waited on for 1 s |
SLOT_RELEASE | totals, holdings | Frees one slot, only where this replica's holdings still record it |
SLOT_HOLD | totals, holdings, held slot ids, recent finishes, hold runs | Records the session's finish. Moves the slot to a held slot if the step followed straight on from its last one, the pool is not marked as starving, the pool is under its held limit, and the session has had fewer than MAX_JUMPS holds in a row while the pool (or w) is waited on, or nobody waits. Otherwise frees it as SLOT_RELEASE does |
SLOT_RECONCILE | totals, holdings, replica set | Replaces the recorded holdings with what the replica really holds; runs every heartbeat |
When a release, reconcile, or reclaim frees a slot in a pool with a current
wait marker, the script publishes the pool id on obleth:fairshare:released
and waiting replicas retry once. An uncontended fleet publishes nothing. After
a resubscribe a replica retries every pool, and a random 100–250 ms retry
covers other lost messages.
The replica set and slot hashes carry no TTL, so volatile-lru never evicts
them: an evicted set would drop every replica's count to 1.
Invalidation channel
Management API writes publish a target on obleth:invalidate. Each replica
drops the matching entry from its in-process cache (300-second TTL in front of
Redis).
| Payload | Drops |
|---|---|
* | Every in-process entry |
model:<name> | One model route |
mcp:<name> | One MCP server |
| Anything else | The key with that hash |
After a resubscribe, a replica drops its whole in-process cache and re-warms Redis, since messages sent while it was disconnected are lost.
Rebuilding from Postgres
Resolver entries have no TTL, so the gateway keeps them in step with Postgres four ways:
- Startup. Each gateway pushes every key, model, and MCP server.
- Every write. A Management API change updates or deletes the entry and
publishes an invalidation. A delete whose eviction fails returns
502. - Re-warm. Every
OBLETH_REDIS_REWARM_SECS(300, at most 86400;0turns the timer off) and after every Redis reconnect, each gateway pushes a fresh snapshot and prunes stale entries. - Resync.
POST /api/v1/resync, or Reconcile cache under Settings › Data and backups, runs the same push and prune on demand and returns counts per namespace.
The prune SCANs obleth:key:*, obleth:model:*, and obleth:mcp:* and
deletes what the snapshot doesn't list, so a revoked key stops resolving. It
then re-reads Postgres and restores anything created during the pass. The
request path never reads Postgres: a key missing from Redis returns 401
until the entry is written back.
Eviction
The bundled Helm Redis runs with --maxmemory (redis.maxmemory, default
400mb, under a 512Mi limit) and --maxmemory-policy volatile-lru, so only
keys with a TTL are evicted. Resolver entries, committed term usage, the
replica set, and the slot hashes are never evicted. When memory is full and
nothing is left to evict, Redis rejects writes, which the gateway treats as a
Redis failure under OBLETH_FAIL_OPEN. The bundled Compose Redis sets no
memory limit. On your own Redis, use a volatile-* policy or noeviction: an
allkeys-* policy can evict resolver entries, which then return 401 until
the next re-warm.
