Skip to content

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 variableDefaultScope
DISCOVERY_MAX_ACTIVE_SESSIONS1,000All unexpired admitted sessions
DISCOVERY_MAX_ACTIVE_SESSIONS_PER_USER8One complete user:<id> actor
DISCOVERY_MAX_ACTIVE_SESSIONS_PER_GUEST3One 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 keyContents 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:activeSession 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 measurement10,000 cards30,000 cards
Delivered / pages / writes10,000 / 417 / 41830,000 / 1,250 / 1,251
Fixture runtime / page p954,345 ms / 15.104 ms18,938 ms / 23.318 ms
Peak complete session JSON1,811,156 B3,402,343 B
Peak retained snapshot JSON1,064,786 B1,065,266 B
Aggregate written JSON455,976,703 B2,341,213,721 B
Aggregate read JSON454,907,540 B2,338,548,319 B
Stringify / parse CPU708 / 962 ms3,273 / 3,441 ms
Peak JSON × default 1,000 sessions1.811 GB3.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 measurementResult
Runtime9,571 ms
Ranking / delivery features / metadata fields136 / 19 / 3
Example snapshot JSON4,398 B
Maximum single payload1,015,140 B
Conservative attempted payload total5,585,558 B
Exact-key MEMORY USAGE before cleanup1,415,373 B
Cleaned keys / cleanupComplete41 / 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:

powershell
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 30000

The 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.