Repository navigation
feat(kv-router): enforce hard session affinity in the scheduler for every policy - #82
Draft
ishandhanani wants to merge 3 commits into
Draft
ishandhanani wants to merge 3 commits into
ishandhanani wants to merge 3 commits into
Conversation
Port of ai-dynamo#15661. Under Hard session affinity the selection core rejected a booking that a custom policy dispatched away from an eligible bound worker, because the binding reached the policy only as advisory context. Wrap the policy factory so every policy treats an eligible bound worker as exclusive, and drop a bound hold when a policy filter rejects the bound worker so the session re-binds instead of failing on every retry. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
Replace the `affinity_target` field on `ScheduleRequest` and `SchedulingRequest` with `affinity: Option<AffinityRequirement>`, a target paired with the strength the host resolved (`Hard` or `Soft`). The selection core derives the strength from the partition's session-affinity mode for a managed or queried session and treats an explicit request target as `Soft`; a classifier-supplied target is `Soft` as well. The scheduler and the policy context still read only the target, so selection behavior is unchanged. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
ishandhanani
had a problem deploying
to
external_collaborator
October 6, 2026 08:27 — with
GitHub Actions
Failure
ishandhanani
force-pushed
the
idhanani/session-affinity-unification
branch
from
October 6, 2026 08:31
0bbf97d to
7150b43
Compare
ishandhanani
had a problem deploying
to
external_collaborator
October 6, 2026 08:31 — with
GitHub Actions
Failure
…very policy One pin rule for session affinity, applied by the scheduler queue when it selects a worker: a `Hard` requirement limits every policy to its bound worker while that worker is eligible; an ineligible target selects normally; a `Soft` requirement reaches the policy as context only. Queue admission is unchanged and stays fleet-wide, so a hard-bound request waits in the router only when every eligible worker is busy, as any request does, and otherwise goes to its bound worker and queues there. A classifier's `worker_selection_target` override is `Soft` and cannot replace or clear a `Hard` binding. Remove the per-policy exclusivity switch that the rule replaces: `WorkerSelector::uses_exclusive_affinity_target`, `WorkerSelectionPolicy::with_exclusive_affinity`, and the factory wrapper the selection core installed under `Hard` mode. The builtin default policy keeps its `Soft` behavior, staying on the bound worker while it is a candidate, inside its picker; the unbound paths read the candidate slice directly as before. The test-only reference selector narrows for `Soft` the same way in its own selection arm. `WorkerAffinityTarget::matches` is the one rank-aware comparison the eligibility filter and the picker share. Scheduler tests cover the rule for the default selector and a custom picker that avoids the binding, the ineligible-target fallback, the classifier override, and fleet-wide admission with a busy bound worker that does not hold back a later unbound request. Eligibility tests cover a ranked target and every ineligibility reason. Builtin tests cover the default picker's `Soft` preference, ranked and worker-only, with and without sampling. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
ishandhanani
force-pushed
the
idhanani/session-affinity-unification
branch
from
October 6, 2026 20:42
7150b43 to
4035919
Compare
ishandhanani
had a problem deploying
to
external_collaborator
October 6, 2026 20:42 — with
GitHub Actions
Failure
ishandhanani
added this pull request to stack #89
October 6, 2026 20:45
This branch had an error being deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Session affinity (binding a session to the worker that served it so its prefix cache is reused) is implemented twice around one shared table: the frontend (
lib/llm/src/session_affinity,kv_router/routing_host*) and the selection core used by the standalone selection service and the EPP (services/selection/core). This stack moves the session layer into the selection core and makes every host use it. This first PR defines the one rule for how a binding constrains worker selection and removes the per-policy switch it replaces.Problem. The frontend pins a
Hardbinding as an exactpinned_worker. The core passed the binding to policies as an advisoryaffinity_targetand narrowed selection only when the selector reporteduses_exclusive_affinity_target()(the default selector). UnderHard, a custom policy could select another worker and have its booking rejected at commit (ai-dynamo#15661).Commits
fix(kv-router): keep custom selection policies on hard session bindings: byte-identical port of fix(kv-router): keep custom selection policies on hard session bindings ai-dynamo/dynamo#15661 (still open upstream). Its factory wrapper is replaced by commit 3; its second fix (drop a bound hold when a policy filter rejects the bound worker) and both tests stay. If fix(kv-router): keep custom selection policies on hard session bindings ai-dynamo/dynamo#15661 merges first, this commit drops on rebase and the PR becomes two commits.refactor(kv-router): carry affinity strength on scheduling requests:ScheduleRequest::affinity_targetandSchedulingRequest::affinity_targetbecomeaffinity: Option<AffinityRequirement { target, strength: Hard | Soft }>. The core derives the strength from the table's mode for a session binding; an explicit wireaffinity_targetand a classifier-supplied target areSoft. 34 struct-literal sites updated mechanically. No selection change.feat(kv-router): enforce hard session affinity in the scheduler for every policy: the pin rule below; removesWorkerSelector::uses_exclusive_affinity_target,WorkerSelectionPolicy::with_exclusive_affinity, and fix(kv-router): keep custom selection policies on hard session bindings ai-dynamo/dynamo#15661's wrapper.The pin rule
Applied by the scheduler queue when it selects a worker (
scheduling/queue.rs):WorkerSelectionContext::affinity_target(). The builtin default policy keeps the bound worker while it is a candidate, now inside its picker (ranked and worker-only, with and without sampling), so the worker it picks is unchanged. Custom policies keep receiving the full candidate set. A classifier'sworker_selection_targetoverride isSoftand cannot replace or clear aHardbinding.pinned_worker: unchanged. Exact, its own queue lane, and it still waits in the router for a busy pinned worker (test_blocked_pinned_lane_does_not_block_other_worker). A pin and a managed session binding never coexist on one request (the core rejects a disagreeing explicit target before selection), so the pin rule does not special-case pins.Where to start.
scheduling/queue.rs::select_worker_for_request(the rule, oneif), thenrouter-plugins/builtin/src/default/picker.rs(theSoftpreference), thenscheduling/types.rs(AffinityRequirement).Reconciliation with ai-dynamo#15139. That PR adds
respect_soft_affinityinside the two-tier picker and recordsuses_exclusive_affinity_target()as a default-selector compatibility hook. This PR removes the hook. Soft affinity reaches every picker the same way, so ai-dynamo#15139's picker-side option composes unchanged, andHardneeds no per-policy handling at all.Behavior changes
Hard: every policy is limited to an eligible bound worker. Before fix(kv-router): keep custom selection policies on hard session bindings ai-dynamo/dynamo#15661 only the default selector was; fix(kv-router): keep custom selection policies on hard session bindings ai-dynamo/dynamo#15661's wrapper reached the same result per policy.Hardwith a classifier plugin that sets aworker_selection_target(the thunderagent classifier does): the binding now wins. Before, the classifier's target replaced it and the request was rejected at commit.Softmode: the worker and rank picked are unchanged, but theSoftpreference now applies inside the picker instead of narrowing the candidate set before scoring. Three observable side effects: the scorer'smin_prefillreference is fleet-wide instead of the bound worker's (only matters withoverlap_score_credit_decay > 0, default off); the frontend's non-max-overlap observer (observe_non_max_overlap_selection, a debug log and a counter) now fires for aSoftrequest kept on a bound worker that is not the max-overlap worker;max_raw_cached_tokensis computed fleet-wide.Hardon the frontend is still an exact pin here; the next PRs move it onto the shared resolver.Hardfalls back to normal selection when the bound worker is ineligible (departed, outside the caller's allowed set, unavailable, or shed by the overload detector), and the host's commit decides what that means for the binding. An explicitpinned_workerkeeps waiting in the router for a busy worker while aHardbinding queues on its worker. Both are design decisions; flagging them for sign-off.Hosts
Hardtoday)Hardas exactpinned_worker(unchanged here);Softas aSoftrequirementworker_selection_targetoverride isSoftValidation
Run with
RUSTC_WRAPPER=and a per-worktreeCARGO_TARGET_DIR.cargo test -p dynamo-kv-router --features standalone-selection --libcargo test -p dynamo-custom-policy-builtindefault_policy.rs, 2 newSoftpicker tests)cargo test -p dynamo-mockercargo test -p dynamo-llm --lib kv_routercargo test -p dynamo-llm --lib session_affinitycargo test -p dynamo-ext-proccargo clippy -p dynamo-kv-router --features standalone-selection,bench --all-targets -- -D warningscargo clippy -p dynamo-llm -p dynamo-ext-proc -p dynamo-custom-policy-builtin -p dynamo-mocker --all-targets -- -D warningscargo fmt --all -- --checkNew scheduler tests (
scheduling/queue.rs):hard_affinity_limits_a_custom_picker_to_the_eligible_target,hard_affinity_limits_the_default_selector_to_an_eligible_target,hard_affinity_survives_a_classifier_target,hard_affinity_admission_stays_fleet_wide(admission fleet-wide, waits only when every worker is busy, and does not hold back a later unbound request). The test-only reference selector narrows only forSoft(it has no picker stage), so the queue tests exercise the queue's rule, not the shim. Eligibility (scheduling/filter.rs):routing_eligibility_ranked_affinity_target_yields_exact_rank,affinity_target_is_eligible_requires_a_schedulable_target(rank out of range, unknown, outside the allowed set, overloaded, unavailable). Builtin:soft_affinity_keeps_the_bound_worker_while_it_is_a_candidate,soft_affinity_limits_sampling_to_the_bound_worker. Removed assertions on the deleted hook only; no test deleted.Benchmark (
cargo bench -p dynamo-kv-router --bench worker_selection --features bench, idle machine, criterion medians, 35 cases). The bench drives the selector directly, so the queue-level rule is not on its path; the test-only reference selector'sSoftnarrowing is. Baseline is commit 1 run twice (the second run measures the noise floor: median 1.9%, p90 11%); the before/after builtin picker is not on this bench (the builtin crate is not a dev-dependency), its allocation claim is by inspection (Copytypes, picker-owned scratch vectors).Median absolute delta against the baseline rerun is 1.2%. Two cases move more than 5%:
direct/temperature_0.7/2048(-7.1%, the sampling path reads the candidate slice directly again instead of a scratch copy) and the untouchedworker_projectionpair (-8% and -15% against the rerun, +0% against the first run: noise in that group). An earlier revision of this PR had a real +7% ondirect/temperature_0/2; it came from a strength-agnostic narrowing block in the test-only reference-selector path and is gone (76.68 ns vs 78.56 ns).Related Issues