11 KiB
title, description
| title | description |
|---|---|
| Multi-process session server — requirements | What the opt-in multi-process session server must do and must not break. The central doc-dev spec for the session modules; per-file mechanics live in each file's header docstring. |
Multi-process session server — requirements
This is the demands-and-targets spec for the opt-in multi-process session server. The multi-process session modules (core.py, sharding.py, ipc.py, router.py, worker.py, and supervisor.py, which lands with the control plane) carry # doc-dev: docs/developer/multi-process-session-server.md and must conform to it; their per-file mechanics live in their header docstrings. Enforcement points outside the flagged set: linear_trajectory.py (the collision guard in SessionRegistry.create_session, the per-session lock / closing gate), the rollout_manager check() call sites, and --session-server-workers in arguments.py.
Goal
Multi-turn agent rollouts must not be bottlenecked by the session server. Under R3 (routed_experts / indexer_topk) every chat turn returns a 100+ MiB body, and its json.loads + validation is pure-Python, GIL-bound work — a single process serializes every session's parse on one event loop, and threads add no parse throughput under the GIL. What we want is session throughput that scales with CPU cores; the only lever is more interpreters. So: shard sessions across worker processes, one GIL each — strictly opt-in, with the single-process server staying the unchanged default.
Measured at the production shape (32 sessions × 50 turns, final-turn body ≈ 134 MiB): 16 workers give 7.2–7.6× wall-time/throughput over single-process, with the workers pegged (~94% CPU, the intended bottleneck) and the router at ~5% (tests/benchmark/bench_session_server_overhead.py, landing separately).
Functional requirements
- Default path unchanged.
--session-server-workers=1keeps today's single-process server, behavior-identical; multi-process is strictly opt-in. - Process-stable sticky routing. Sessions are stateful (the TITO trajectory accumulates across turns), so every turn of a session must reach the same worker; router and workers must agree on the owner of a
session_idwithout coordination — a stable hash modulo worker count, never the builtinhash()(salted per process byPYTHONHASHSEED). - TITO correctness under concurrency preserved exactly. The per-session
asyncio.Lock, theclosingre-checks, and thenum_assistant-mismatch skip path inSessionCore.chat_completionsmust remain. - Worker death is a hard, fast error. A dead worker owns a hash shard, so an undetected death turns every request to that shard into a black hole; the supervisor's
check()must be called at the start of bothgenerate()andeval()so either phase fails loudly instead of hanging. - No orphan processes on crash or teardown.
- The router only moves bytes. It never
json.loadsa body; it hash-routes to the owning worker and relays the response bytes verbatim — parsing in the router would re-incur the exact cost being escaped. - R3 never reaches the client.
routed_experts/indexer_topkare replay-only training inputs, consumed from the session records viaGET /sessions/{id}— never from the live chat reply — so the core strips them from client-facing chat responses (landed just below this stack as its own PR). Without the strip every relaying hop pays for the blobs: the single router process measured 100% CPU and capped 16-worker scaling at ~4.6×.
Required decomposition
SessionCore(core.py) — the logic. Request primitives in, session mutation + upstream proxy, StarletteResponseout; knows nothing about processes, sockets, IPC, or HTTP servers; reused unchanged by both chassis.- worker (
worker.py) — one process, one shard. Exactly oneSessionCoreplus its own httpx proxy backend; speaks IPC only: decode a request, call the core, serialize theResponseback. - router (
router.py) — the single client-facing HTTP listener. Hash-routes each request to the owning worker and forwards the reply back unchanged, without ever parsing it; imports neither core nor worker, so the router process stays tokenizer-free. - supervisor (
supervisor.py) — process lifecycle. Spawns N workers + 1 router, waits for readiness, monitors and fail-fasts on any child death, tears the group down without orphans. - Support:
sharding.py(stdlib-only owner function + id minting, importable without FastAPI),ipc.py(framed, multiplexed request/reply over one socket per worker), andSessionRegistry.create_session(session_id=None)(mints when None; a router-supplied id is created under exactly that id, with a loud collision guard — the single-process path never passes one).
The per-turn chat path — the load the sharding exists for:
flowchart TD
client["client (rollout harness)"]
router["router — the single HTTP listener<br/>tokenizer-free, only moves bytes"]
worker["worker i = blake2b(session_id) mod N<br/>SessionCore + tokenizer, one GIL each"]
upstream["inference backend"]
client -->|"① chat turn (small request)"| router
router -->|"② one IPC frame"| worker
worker -->|"③ httpx proxy"| upstream
upstream -->|"④ R3 response body<br/>(~134 MiB/turn at prod shape)"| worker
worker -->|"⑤ parse + validate + record under this worker's GIL —<br/>the cost being sharded; client reply as one IPC frame<br/>(small — R3-stripped by the core; full R3 stays in this worker's records)"| router
router -->|"⑥ forwards the reply to the client unchanged<br/>(never parses or re-encodes it)"| client
The end-of-rollout full-records fetch — same topology, very different byte profile:
flowchart TD
client["client (rollout harness)"]
router["router"]
worker["worker owning the session"]
client -->|"① GET /sessions/:id (full records)"| router
router -->|"② IPC request (tiny)"| worker
worker -->|"③ records dump = ONE u64 IPC frame (~3.15 GiB at prod shape);<br/>FIFO writes queue sibling chat replies behind it"| router
router -->|"④ relays the multi-GiB body over HTTP"| client
The supervisor spawns and monitors the router and every worker and fail-fasts on any death; it is never on a data path.
Sessions are sticky-by-hash, so the chat path only ever carries a single turn's request/response over IPC — never accumulated session state. The records path is the exception: session records are never pruned (the training data path consumes R3 from them), so its reply is the full accumulated dump as one frame — see "Open decisions" for the measured consequences.
Behavior parity
workers=N must match the single-process server's observable behavior, with exactly two known deltas (introduced by the SessionCore extraction and inherent to multi-process, where the worker receives already-read bytes over IPC):
- Body-read timing / lock scope. The route reads the body before entering
SessionCore.chat_completionsinstead of insidesession.lock; JSON parsing, TITO mutation, and record writes remain under the lock, proxying remains outside it. Consequences: a chat to a missing/closing session consumes the full body before the error returns, and a DELETE-vs-chat race during upload more likely rejects the chat (the outcome was already non-deterministic; the race-condition tests pass). - Per-request debug logging removed (the
debug_request_loggermiddleware and two DELETE-pathdebuglines). Logging-only; no contract impact.
Design targets
- Observational equivalence. To a client,
workers=Nis indistinguishable fromworkers=1— statuses, headers, body bytes, and TITO session metadata — on success and error paths alike (the two known deltas above excepted). - Deterministic ownership. Every operation of a session resolves to the same worker for the session's entire lifetime; ownership never depends on process identity, timing, or load.
- Loud failure. A dead child never degrades into hangs or partial service: the rollout path fails fast, and teardown leaves no orphans.
Open decisions and accepted risks
Deliberately cut — reintroduce only with evidence: IPC chunking / round-robin writer / send-buffer budget / configurable frame limits (single-frame bodies instead; reopen rule below); per-worker 503 backpressure, asyncio.shield on client disconnect, and the router task-tracking set (cancel-safe request + late-reply drop instead); worker-side max_inflight / max_queued_bytes admission and the parse-gate semaphore; the prototype's 409 in-flight gate, 500 invariant, and stricter response validation (behavior changes, not part of the mechanism).
- Records-path head-of-line and router relay ceiling (accepted for now). Measured with 32 concurrent full-records
GET /sessions/{id}overlapping chats (w16, production shape): chat reply p99 collapses ~33× (1.6 s → 52 s) and a ~3.15 GiB GET averages ~103 s — the giant reply frame queues sibling chat replies behind it on the IPC channel (FIFO whole frames) and every byte re-serializes through the single router process. The chat path itself never sends large bodies over IPC (the large body is parsed inside the worker); benchmark provenance in "Goal". Accepted for this stack; the fix is decided and lands as a follow-up records-path PR (see "Not covered"). - Single-frame large bodies. A full-records
GET /sessions/{id}reply crosses IPC as one in-memory frame — measured ~3.15 GiB per session at the production shape (records are never pruned, so it grows with turns × body size). This is why frame lengths are u64 with a 64 GiB cap enforced at send time:_MAX_FRAMEis a corruption guard, not a size feature, and an oversized frame must fail only its own request — if it reached the peer's reader it would be indistinguishable from a corrupt length and tear down the channel, silently poisoning every other session on the shard. - No client-disconnect shielding. A cancelled client request drops only the router-side await (cancel-safe
request()+ late-reply drop); the worker handler runs as a separate task and still commits consistent state — pinned by a deterministic cancel test. - No per-request dispatch timeout. A dead worker surfaces as EOF → 503, but a live-but-hung worker leaves requests pending until the channel closes. Deliberate:
chat/proxylegitimately run long (LLM generation), so a fixed timeout would false-positive on slow chats. - Silent session mis-routing is the biggest correctness risk. If the owner were computed differently at create vs. chat vs. get, sessions would intermittently land on the wrong worker — load-dependent and invisible at
workers=1; the routing-determinism and equivalence tests exist to catch exactly this.
Not covered
- Consistent-hashing / rebalanceable routing — v1 is modulo over a stable hash; resizing the worker count at runtime is unsupported.
- A delta / incremental-R3 protocol (would need SGLang/trainer coordination).
- General backpressure / admission control / rate limiting.
- TODO (decided, follow-up records-path PR): worker-direct bulk replies — the router 307-redirects the records GET to the owning worker's own listener, so the multi-GiB records body never crosses IPC or the router. Design and flowchart land with that PR.