Redis discovery sessions: architecture and scale evidence
September 19, 2026. Worktree: C:\Users\jefra\.codex\worktrees\yumina-discovery; branch codex/discovery-measurement, initial base 49eec145a. This report covers the final worker implementation and controller-reported testing Redis run. The worker made no remote/production calls, deployments or purchases.
Architecture and admission limits
Creation atomically reserves global and actor capacity and writes the session in one Lua operation. Normal CAS compares a small metadata hash instead of fetching and cjson.decode-ing the growing old payload. Session JSON remains intact; full JSON reads, parsing, serialization and writes are still required. The entire CAS is therefore not constant-time in session size.
| Environment variable | Default | Scope |
|---|---|---|
DISCOVERY_MAX_ACTIVE_SESSIONS | 1,000 | All unexpired admitted sessions |
DISCOVERY_MAX_ACTIVE_SESSIONS_PER_USER | 8 | One complete user:<id> actor |
DISCOVERY_MAX_ACTIVE_SESSIONS_PER_GUEST | 3 | One complete guest:<id> actor |
Configuration requires positive safe integers and identical settings across replicas. Actor quotas span filter scopes; user/guest identities remain distinct. These are provisional admission defaults, not validated production capacity.
| Redis key | Contents and expiration |
|---|---|
discovery:v1:<id> | Complete JSON; fixed one-hour expiresAt |
discovery:v1:meta:<id> | Revision, actor, scope, limit, expiry, hasMore; same expiry |
discovery:v1:active | Session ID → expiry sorted set; expires at latest member expiry |
discovery:v1:actor:<sha256(actor)> | Actor's corresponding expiry index |
Redis TIME, PXAT and PEXPIREAT enforce absolute expiry. CAS preserves ownership, scope, page size and expiry; updates never extend retention. Admission removes at most 128 expired members per index; ZCOUNT ignores any expired backlog left behind. Rejected creation reserves nothing. Duplicate creation and CAS retries do not charge twice. Completed/abandoned sessions remain counted until TTL so retry pages survive; a lost initial HTTP response can leave an unused reservation because cursorless requests generate fresh session IDs.
There is no production stream-length, seen-ID or payload-byte cap and no active stream eviction. Quota errors are retryable DiscoveryAdmissionErrors; the route uses HTTP 503 / discovery_retry. Redis outages/accounting errors remain explicit, with no per-replica production fallback. Existing cursors work at cap.
Recovery after admission rejection
The compatible optional store method findResumable({ actor, scope, limit }) lets a fresh request recover after actor or global rejection, including a returning actor below its own quota when global capacity is full. It inspects at most min(actorLimit, 32) live actor-index entries, ordered by expiry then ID. Only metadata is read: exact owner/scope/page-size, live matching global/actor accounting, an existing body and hasMore=1 are required. No growing JSON is decoded by this lookup. Normal service read/ownership/expiry checks and CAS advance the selected revision without another slot, TTL extension or eviction.
Requests selecting the same revision share one committed page and attribution; sequential fresh requests progress within the resumed stream. Other streams remain intact. The existing three-page replay window remains bounded: old cursors can age out as the stream advances. Separate sessions retain independent seen histories; this is not actor-wide deduplication.
A different filter/page size, terminal exhaustion, expiry, missing accounting, or no matching entry within the bounded search leaves a retryable rejection. A new filter without a retained matching stream can still hit the cap. Stores implementing only read/write remain compatible. Outages and unrelated errors do not trigger recovery; metadata lacking hasMore needs a normal CAS before it becomes resumable.
Connection and rollout requirements
An optional feature-name dictionary codec reduces repeated snapshot keys; see the paired storage measurements. DISCOVERY_SESSION_ENCODING=raw remains the default. Upgrade all readers before enabling compact-v1 on any writer. New binaries read both encodings; old binaries cannot read compact snapshots. To roll back to an old binary, switch every writer to raw and wait one complete one-hour session TTL first. This preserves keys, admission, ownership, CAS, expiry, replay and inventory. It does not make growing sessions constant-size.
DISCOVERY_REDIS_URL optionally selects a dedicated ioredis client; absent or blank preserves shared Redis. Accepts redis:// / rediss://, normalizes TLS scheme casing, rejects query/fragment options, disables offline queuing, and uses 5-second connection / 3-second command timeouts. Errors never log the URL or credentials. An unavailable override never falls back to shared Redis/memory. A dedicated recommendation Redis service, including in the same Railway account, isolates large feed JSON from shared auth/locks; a database index on the same server does not. No service was provisioned.
Use standalone Redis 6.2+ with sufficient memory and noeviction; independently evicted accounting keys can defeat exact quotas. Redis Cluster is unsupported. Synchronize Redis/application clocks. Lua isolation does not provide rollback for arbitrary OOM/type failures or guarantee durability through failover.
Legacy JSON without metadata is decoded once on successful CAS and adopted under the limits; unadopted legacy sessions are not counted. Missing metadata on an already indexed session fails explicitly. Drain old writers and expire their one-hour sessions before requiring a strict bound from a uniform new fleet. Mixed old/new writers are unsafe. Endpoint changes likewise require coordinated draining; there is no automatic endpoint migration or fallback.
Full-feature payload benchmarks
Both scripts load docs/recsys/2026-09-19-discovery-snapshot-fixture.json: unshipped discovery-features-v1, 136 numeric ranking features, 19 numeric delivery features, and three delivery metadata fields (creatorId, topic, languageGroupId). The generated snapshot alone is 4,398 B, an example, not an upper bound. Scripts use its numeric values and UUID world/creator/group IDs without importing live ranking, models, DB or HTTP code.
The benchmark uses the real session service/pagination with a local JSON codec, 24-card pages, 247 initial queued snapshots and 200-card replenishment batches. Queued snapshots contain ranking fields only; consumed snapshots gain delivery fields/metadata and persist in the last three pages. Both runs delivered every card and retained at most 247 queued / 72 page snapshots; these separate maxima need not occur together.
| Local codec measurement | 10,000 cards | 30,000 cards |
|---|---|---|
| Delivered / pages / writes | 10,000 / 417 / 418 | 30,000 / 1,250 / 1,251 |
| Fixture runtime / page p95 | 4,345 ms / 15.104 ms | 18,938 ms / 23.318 ms |
| Peak complete session JSON | 1,811,156 B | 3,402,343 B |
| Peak retained snapshot JSON | 1,064,786 B | 1,065,266 B |
| Aggregate written JSON | 455,976,703 B | 2,341,213,721 B |
| Aggregate read JSON | 454,907,540 B | 2,338,548,319 B |
| Stringify / parse CPU | 708 / 962 ms | 3,273 / 3,441 ms |
| Peak JSON × default 1,000 sessions | 1.811 GB | 3.402 GB |
Timings include fixture assertions on a shared development machine. Repeating one example does not model production feature/value distributions. Projections exclude Redis allocator/objects, metadata/indexes, replication, transient writes and operating margin. With S sessions and A actors, storage has approximately 2S + A + 1 keys and 2S index memberships. Real memory/concurrent load sizing is required; the default cap is no promise that a small Redis holds 1,000 deep sessions. Admission runs after initial ranking: it bounds retained sessions, not SQL/ranking concurrency. Rotating guest identities bypasses per-guest quotas but remains subject to the global bound. No stream truncation hides these costs.
Final real testing Redis evidence
The controller reports 8/8 checks passed, exit 0, after reverifying that the selected testing host and port differ from production and confirming the separate testing service. There were no production writes. These results were reported by the controller; the worker did not access credentials or run Redis remotely.
| Reported measurement | Result |
|---|---|
| Runtime | 9,571 ms |
| Ranking / delivery features / metadata fields | 136 / 19 / 3 |
| Example snapshot JSON | 4,398 B |
| Maximum single payload | 1,015,140 B |
| Conservative attempted payload total | 5,585,558 B |
Exact-key MEMORY USAGE before cleanup | 1,415,373 B |
Cleaned keys / cleanupComplete | 41 / true |
The eight checks cover authenticated admission races; guest/global caps; concurrent CAS/revision retries and ownership; fixed TTL/expiry recovery; bounded expired-backlog cleanup; legacy adoption/metadata-only CAS; complete 300-card continuation with snapshots and concurrent replay; and fresh-request recovery at actor/global capacity, including below-actor-quota recovery, scope, cursor replay, unchanged TTL and terminal exhaustion.
This is adapter correctness evidence, not production capacity. It does not establish HTTP/SQL/ranking throughput, concurrent-origin load, replica routing, failover behavior or deployment sizing. Attempted payload counts per-key peaks including rejected writes, not simultaneous resident memory. The memory sample is taken before cleanup after some records expired; it is not peak usage.
Reproduction and local verification
From the worktree root, after controller verification of the testing service, supply process-only DISCOVERY_TEST_REDIS_URL and ACK_TESTING_REDIS=true:
pnpm --filter @yumina/server exec tsx scripts/check-discovery-testing-redis.ts
pnpm --filter @yumina/server exec tsx scripts/benchmark-discovery-store.ts 10000
pnpm --filter @yumina/server exec tsx scripts/benchmark-discovery-store.ts 30000The testing harness uses two clients and a unique discovery-check:<uuid>: namespace. It refuses missing acknowledgement/URL without connecting; it loads no dotenv, app Redis URL or DB. It deletes/verifies only exact tracked keys in finally, never uses global FLUSH/CONFIG/scans, and emits only aggregate results. All retained fixture records expire within 60 seconds. Script-only guards are 2 MiB/body, 6 MiB summed per-key peak attempted payloads, and <10 MiB exact-key memory at the final sample. These do not cap production session bytes. Cleanup failure is exit 1. Do not put credentials in arguments/logs or use the credential-stripping isolated test launcher for a remote run.
Local focused verification: 41 passed, 0 failed, 1 skipped; server build and scoped typechecks for the owned tests/scripts passed. The local Redis suite was skipped because no executable was available; deterministic transport fixtures do not execute Lua. The controller's real run above supplies adapter evidence. The benchmark's optional --redis starts only an owned loopback Redis and fails when unavailable. Full monorepo/OSS/deployment integration remains controller-owned.
Implementation: packages/server/src/lib/discovery-store.ts, discovery-admission.ts, discovery-session.ts. Fixture/verification helpers: discovery-admission-fixture.ts, discovery-admission-local-redis.ts, discovery-store-admission.test.ts, discovery-store-redis.test.ts, and discovery-store-scale.test.ts in the same directory. Script entrypoints: packages/server/scripts/benchmark-discovery-store.ts and packages/server/scripts/check-discovery-testing-redis.ts.
