Skip to content

feat(llm): resolve KV-routed sessions through the selection core - #84

Draft
ishandhanani wants to merge 1 commit into
idhanani/session-affinity-unification-p2from
idhanani/session-affinity-unification-p3
Draft

ishandhanani wants to merge 1 commit into
idhanani/session-affinity-unification-p2from
idhanani/session-affinity-unification-p3

Conversation

@ishandhanani

@ishandhanani ishandhanani commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

Third PR of the session-affinity unification stack (base: idhanani/session-affinity-unification-p2). The frontend KV plane stops running its own session layer and uses the shared AffinityResolver through the selection core.

Before. The frontend acquired the session itself (AffinityCoordinator), pinned a Hard binding as an exact pinned_worker, checked the bound worker's liveness only after a failed selection, retried once, committed after dispatch, and returned HTTP 529 when the table was full. The core never saw the session (SessionBinding::None).

After. The KV plane hands the core a SessionBinding (the request's session key or subagent group, plus any explicit target the binding must agree with) under Lease admission. The core resolves the session through the partition's resolver, enforces the binding with the scheduler's pin rule, and returns the uncommitted Hold in Selected. The host commits through the same resolver once dispatch has returned a response stream and ties the lease to that stream, as before.

How the frontend KV host maps to the resolver

Concern Frontend KV
Session key x-dynamo-session-affinity-id context, subagent group key (unchanged, host-side)
Liveness DiscoveryLiveness: discovery knows the worker and the bound rank is within its DP range (today's affinity_target_is_valid), installed on the partition resolver via SelectionPartition::session_affinity_with_liveness
Admission Lease: core resolves, scheduler applies the pin rule, hold returned in Selected::affinity_hold
Commit point after dispatch returns a stream (bind_affinity); a failed dispatch releases the hold and binds nothing
Lease owner the response stream (tracked_stream)
Cancellation client disconnect drops the selection future (the existing await_with_cleanup_policy wrapper); a decode leg with staged KV waits through a stop under the cleanup budget
Pre-dispatch check AffinityResolver::check_dispatch: a live Hard binding the request's own constraints steered away from is rejected before a worker starts the request; the binding is kept

Prefill and decode: the KV prefill host now uses its own partition's table (kv_chooser.host_affinity) instead of a free-standing one. Prefill and decode keep separate tables, which is deliberate: they bind to different worker pools and may carry different TTLs (the prefill card's session_affinity_ttl_secs).

Behavior changes (frontend KV, session affinity enabled)

Decisions with defaults from the design, each needing sign-off:

  1. Hard no longer waits in the router for a busy bound worker. Selection is limited to the bound worker while it is eligible; admission stays fleet-wide, so the request goes to its bound worker and queues there. Explicit pinned_worker requests still wait in the router (unchanged).
  2. Ineligible Hard target selects normally instead of erroring. Departed worker (not discovered, or rank out of range): the binding is dropped before selection and the session re-binds to the worker the request runs on (also at commit, with failover, if it departs between hold and commit). Live but excluded by the request's allowlist, taints, or migration exclusions: rejected before dispatch with InvalidArgument, binding kept (was: rejected at selection, binding kept).
  3. Overloaded bound worker. The pin rule treats overload as ineligibility, so a Hard request whose bound worker the overload detector has shed selects another worker and is then rejected before dispatch (InvalidArgument), binding kept. Before, the exact pin ignored overload and dispatched to the overloaded worker. This follows decision 1 of the design but is the most visible change for operators running the frontend overload detector; flagging it explicitly.
  4. Full table routes the request without affinity and counts it (AffinityResolver::full_table_fallbacks, a counter on the resolver that is not yet exported as a metric) instead of returning HTTP 529 (ResourceExhausted).
  5. Explicit worker-only target on a multi-rank worker with a session: selection is narrowed to that worker and the binding, or the scheduler, chooses the rank (was: the binding's rank was merged into an exact pin). Without a session, the rank is still required (unchanged).
  6. A request whose explicit target disagrees with its binding is still rejected with InvalidArgument (now by the core, mapped from SelectionError::BadRequest).

Where the core and the frontend differ on a live Hard mismatch: the core rejects at commit and the table drops the binding; the frontend rejects before dispatch and keeps the binding (nothing ran; the request, not the session, caused the mismatch). Recorded in the design note; open to aligning either way.

Builtin routing modes are unchanged in this PR (next PR).

Validation

Run with RUSTC_WRAPPER= and a per-worktree CARGO_TARGET_DIR.

Command Result
cargo test -p dynamo-kv-router --features standalone-selection --lib 1287 passed, 2 ignored (+1 resolver check_dispatch test, +1 core lease-admission test)
cargo test -p dynamo-llm --lib kv_router 411 passed (408 - 1 deleted + 4 new)
cargo test -p dynamo-llm --lib session_affinity 49 passed
cargo test -p dynamo-custom-policy-builtin -p dynamo-mocker passed
cargo test -p dynamo-ext-proc passed
cargo clippy -p dynamo-kv-router --features standalone-selection,bench --all-targets -- -D warnings clean
cargo clippy -p dynamo-llm -p dynamo-ext-proc -p dynamo-custom-policy-builtin -p dynamo-mocker --all-targets -- -D warnings clean
cargo fmt --all -- --check clean

New host tests (routing_host/tests.rs): kv_session_commits_after_dispatch_and_releases_with_the_stream, kv_failed_dispatch_does_not_bind_the_session, kv_binding_to_a_departed_worker_rebinds_on_dispatch, kv_explicit_target_must_agree_with_the_session_binding. Core: lease_admission_returns_the_session_hold_for_the_host_to_commit. Resolver: check_dispatch_rejects_only_a_live_hard_mismatch.

Existing frontend tests that pin the new semantics unchanged: stale_affinity_rank_recovers_within_request, stale_affinity_rebind_classifies_once_without_intermediate_abort, config_watch_gap_preserves_hard_affinity, request_constraints_preserve_worker_only_affinity, migration_exclusion_preserves_hard_affinity_without_widening_or_escaping_hard_pins, kv_stopped_decode_request_survives_a_contended_session_affinity_wait, route_preview_does_not_acquire_session_affinity, aborted_route_plan_drops_pending_affinity_initialization, the subagent group tests.

Deleted test: affinity_pin_supplies_rank_for_matching_explicit_worker (kv_selection.rs), with the merge_affinity_pin helper it tested; the rank merge is replaced by the pin rule (behavior change 5). "Hard no longer waits for a busy bound worker" is covered at the scheduler level (hard_affinity_admission_stays_fleet_wide, PR 1); the frontend test harness has no queueing fixture, so no frontend-level test was added for it.

Python validation ran on the stack tip (Phase 6 head, which includes this change) after maturin develop --uv --features select-service in <worktree>/.venv, with etcd v3.5.21 and nats-server v2.10.26 on PATH:

Command Result
pytest tests/router/test_router_e2e_with_mockers.py -k session_affinity 3 passed (test_mocker_session_affinity[etcd], [file], test_disagg_per_role_session_affinity): two KV frontends, the replicated binding overrides each frontend's own KV-prefix placement
pytest lib/bindings/python/tests/test_kv_bindings.py -k "affinity or session" 13 passed, 20 deselected
pytest tests/router/test_router_e2e_with_mockers.py (whole file, 56 tests) 50 passed, 6 skipped (the suite's own Flaky on CI: TCP round-robin skip marker). First pass with a select-service-only build failed 5 tests on missing build features (dynamo.indexer needs kv-indexer; the AIS perf model needs ais-forward-pass); rebuilt with select-service,kv-indexer,ais-forward-pass (a subset of the shipped wheel's feature set) and those 5 passed in 90 s

Related Issues

@ishandhanani
ishandhanani had a problem deploying to external_collaborator October 6, 2026 08:39 — with GitHub Actions Failure
@github-actions github-actions Bot added feat documentation Improvements or additions to documentation router labels Oct 6, 2026
The frontend KV plane stops running its own session layer. It hands the
selection core a `SessionBinding` (the request's session key or subagent
group, plus any explicit target the binding must agree with) under `Lease`
admission; the core resolves the session through the partition's
`AffinityResolver`, enforces the binding with the scheduler's pin rule, and
returns the uncommitted `Hold` in `Selected`. The host commits it through the
same resolver once dispatch has returned a response stream and ties the
lease to that stream, as before; a failed dispatch binds nothing.

Hard bindings move from an exact `pinned_worker` to the `Hard` requirement:
selection is limited to the bound worker while it is eligible, an ineligible
target selects normally, and a hard-bound request no longer waits in the
router for its busy worker. A binding whose worker discovery no longer knows
is dropped before selection and re-bound to the worker the request ran on.
A live hard binding the request's own constraints steered away from is
rejected before dispatch (`AffinityResolver::check_dispatch`) and the binding
is kept. A full table routes the request without affinity instead of
failing it. The frontend installs its own liveness source, discovery plus the
DP-rank range, on the partition resolver; the prefill KV host uses its
partition's table, so prefill and decode bind independently as they did.

Query-only selections (route previews, prefill-busy probes, query_instance_id)
read the binding without a hold. The explicit-pin rank merge is replaced by
the pin rule: a worker-only target on a multi-rank worker narrows selection
to that worker and the binding, or selection, chooses the rank. Builtin
routing modes keep the coordinator for now.

Host tests cover commit after dispatch and lease release with the stream, no
binding on a failed dispatch, re-binding from a departed worker, and
explicit-target agreement; a core test covers the lease-admission hold and
query bindings. Fleet-wide admission for a busy bound worker is covered by
the scheduler tests in the previous PR.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
@ishandhanani
ishandhanani force-pushed the idhanani/session-affinity-unification-p3 branch from e1e021a to b9fd095 Compare October 6, 2026 20:42
@ishandhanani
ishandhanani had a problem deploying to external_collaborator October 6, 2026 20:42 — with GitHub Actions Failure
@ishandhanani
ishandhanani added this pull request to stack #89 October 6, 2026 20:45

This branch had an error being deployed

1 failed deployment
external_collaborator — b9fd095f Deployed Oct 6, 2026 by ishandhanani via ok-to-test #269
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation feat router

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant