[sgl-router] refactor - layout BucketResolver, Bucket, EngineGroup and implement PowerOfTwo (#40241)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kan Wu
2026-09-20 16:57:07 -07:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 4a9dc5c4af
commit aedda8377e
15 changed files with 2566 additions and 4 deletions
+575
View File
@@ -0,0 +1,575 @@
# Engine selection: target design
This document describes the target architecture for engine selection. It defines
responsibilities and behavior; the interface and configuration examples are
sketches, not a specification of the current API or CLI. Implementation status is
listed at the end.
## Principles
1. **Order compatible buckets by token length first.** `BucketResolver` returns
all matching buckets, smallest capacity first, without inspecting workers or policies.
2. **The bucket owns plain versus PD engine selection.** `Bucket::pick_engines`
calls its one plain group or both prefill and decode groups, returning a
complete selection. Both PD engines come from that same bucket.
3. **An engine group owns membership and its policy.** `EngineGroup::pick`
filters live workers by model, health, stage, and membership, invokes the
policy, and validates that its result belongs to the exact candidate set.
4. **A policy owns selection, fallback, admission, and its state dependencies.**
Construction injects the load, KV, or affinity handles it needs. Request-time
arguments contain facts and candidates. A policy cannot choose another bucket
or cross a PD role boundary.
5. **The handler owns bucket fallback and dispatch.** It calls `pick_engines`
on each bucket and dispatches only after a complete selection succeeds.
Missing candidates or admission rejection advance to the next bucket, where
all required engines are selected again. Invalid signals or policy results stop routing.
An engine is represented by `Worker`. Registry role labels remain authoritative
when filtering candidates. Groups reference worker IDs rather than owning live
workers. Group policy instances are reused across requests.
### Data ownership
| Type | Owns |
| --- | --- |
| `BucketResolver` | A model's bucket collection, length filtering, and ordering |
| `Bucket` | ID, length constraints, rank, groups, and complete plain/PD engine selection |
| `EngineGroup` | Engine membership, attached policy, and engine selection |
| `WorkerRegistry` | Live workers, model membership, health, and role |
| Request handler | Request preparation, ordered bucket attempts, HTTP errors, and dispatch |
`worker_ids: None` means every healthy engine serving the requested model and
role. An explicit empty set means no engines. `EngineGroup::new(policy)` creates
a catch-all membership group. `BucketGroups::Pd` requires both groups, making
partial or mixed plain/PD bucket configurations unrepresentable.
## 1. Code organization
```text
src/
buckets.rs Bucket resolution and engine-group selection
policies/
mod.rs Policy contract and construction
admission.rs Per-engine acceptance checks
cache_aware.rs Cache selection and local/remote adapter
session_aware.rs Session selection and fallback
sticky.rs Routing-key selection and fallback
least_load.rs Least-load selection
power_of_two.rs Pair sampling and stage-aware comparison
random.rs Random selection
round_robin.rs Rotation
state/
mod.rs Shared exports
kv_events/ Local index, subscriptions, hashing, wire format
load_monitor/ Engine reports and router-local request accounting
affinity_store.rs Assignments, expiry, and atomic updates
server/
app_context.rs Shared service lifecycle and policy wiring
routes/chat.rs Request preparation, PD coordination, dispatch
```
During migration, `buckets_reorg.rs` and `policies_reorg/` implement this design
beside the live `policies/` path. The layout above is the target after switchover.
Shared state already lives directly under `src/state/`.
Dependencies flow from engine groups to policies, and from policies and admission
to shared state. State does not depend on bucket ordering or concrete policy
strategies. Small shared helpers are sufficient; no generic score-composition,
tier executor, or separate selection framework is required.
## 2. Responsibilities and request flow
`BucketResolver::resolve(input_tokens, expected_peak_tokens)` returns an ordered
list of compatible bucket references (possibly empty), or an invalid-signal error.
It does not receive a stage or a load view, resolve live engines, or invoke policies.
The handler iterates this list until a bucket supplies the complete engine selection.
`Bucket::pick_engines(workers, request)` accepts a `BucketRequest`
of prepared routing facts, invokes the required groups, and returns `BucketPick`
(one plain pick or a complete P/D pair). Failures retain their stage. This API
has no HTTP headers, `AppContext`, or forwarding dependency.
`EngineGroup::pick(workers, request)` resolves healthy workers for the request's
model and stage, intersects them with its membership, sorts them by stable ID,
and invokes its attached policy. It rejects foreign results, including a newly
allocated worker with the same ID as a candidate.
```text
chat_completions (reorg configured): prepare tokens and expected peak
|
v
BucketResolver::resolve: ordered length-compatible buckets
|
v
For each bucket: bucket.pick_engines(...)
|
+-- BucketGroups::Plain
| plain.pick() -> one plain engine
|
+-- BucketGroups::Pd
prefill.pick() -> P engine
decode.pick() -> D engine from the same bucket
|
+-- empty group / admission rejection -> try next bucket (repeat all picks)
+-- invalid signal / configuration / foreign pick -> return error
|
v
Complete selection -> forward_chat_request: acquire guards, attach PD bootstrap, forward response
```
The handler extracts token facts and header keys once into `BucketRequest`.
The bucket creates a stage-specific `PickRequest` for each group call, supplying
its own ID and the role associated with that group. Input length, expected peak,
token IDs, and session/routing keys pass through. Policies obtain observations
from their own shared-state handles; buckets and handlers do not provide load,
KV, or affinity services on each call. PD uses separate group policies, but never
independently resolves a decode bucket. Decode selection
failure discards that tentative prefill choice and advances to the next bucket
on missing candidates or admission rejection. No forwarding guards are acquired
and no prefill request is sent until both picks in one bucket succeed.
PD compatibility constraints beyond model and role remain follow-up work.
There is one endpoint: `POST /v1/chat/completions`. `AppContext::chat_routing`
chooses its implementation:
- `ChatRouting::Legacy` (default) uses the existing policies and bucket selector.
- `ChatRouting::Reorg(HashMap<ModelId, BucketResolver>)` uses the new bucket and
policy interfaces, with explicit model-specific resolvers.
Callers set this field before building the router. A missing model in the reorg
map returns 404, without falling back to legacy routing. This PR adds the
programmatic configuration switch; CLI/configuration factory construction and
the remaining production policies remain follow-ups. Power-of-two is implemented
for explicit attachments; the default serving path remains legacy.
Both implementations reuse request preparation (including sampling validation
and tokenization), forwarding, streaming, middleware, and the 32 MiB body limit.
The reorg implementation requests tokenization for length matching, retaining
the existing body-size estimate when tokenization is unavailable.
## 3. Bucket resolution
1. Validate that a known expected peak is at least the input length.
2. Keep buckets whose inclusive input-token range contains the input length.
3. Check the bucket context capacity against input plus requested output when
known, or against input length when the output budget is unknown.
4. Sort by ascending input capacity (the lesser of the input upper bound and
context capacity). Unbounded capacities sort last. Break ties by ascending
bucket rank, then ID, and return the entire ordered list.
5. The handler calls each bucket's `pick_engines` until one supplies its complete
selection. A failed PD attempt never contributes an engine to a later pair.
The handler checks addition overflow when computing the expected peak.
An empty bucket list becomes a 400 `NoMatchingBucket` response. After exhausting
the list, accumulated admission rejection details produce a selection failure
(503); if there were no admission rejections, the last unavailable stage produces
a stage-specific 503. Invalid policy signals/configuration or out-of-candidate
picks stop the pass immediately with an internal error. Successful engine
selection ends the pass; forwarding errors do not restart bucket iteration.
Token ranges and rank belong to the bucket, not its engine groups. For a PD
bucket, both groups share this one request-length decision. Policy fallback on
a cache/affinity miss stays within that group's candidates. There is no second
pass with relaxed admission and no post-policy substitution.
SLO ordering, cache lookup, and session/sticky policies are follow-ups. Their
integration must preserve bucket-first selection and the same-bucket PD rule.
Cross-bucket affinity probing is not part of this interface. Session/routing
keys still pass through `PickRequest` for policies operating inside the selected
group; unsupported legacy modes need explicit migration decisions before the
standard serving path switches.
## 4. Policy and admission contracts
### Policy
All policies implement one asynchronous, object-safe `Policy::pick` interface.
A boxed future supports the remote prefix indexer; local policies can return an
immediately ready result.
```rust
pub trait Policy: Send + Sync + std::fmt::Debug {
fn pick<'a>(
&'a self,
engines: &'a [Arc<Worker>],
request: &'a PickRequest<'a>,
) -> BoxFuture<'a, Result<Pick, PickError>>;
}
```
Policies own their required state handles and read observations locally. Nested
fallback uses `pick_fallback(engines, request)`, which calls the fallback's `pick`;
the fallback reads its own state. There is no shared observation context or cache.
Buckets and HTTP handlers supply only candidates and request facts.
`Pick` identifies one engine and a selection reason for metrics and tracing.
`PickRequest` carries model, stage, selected bucket ID, input and optional
expected peak counts, optional token IDs, and session/routing keys. It contains
no HTTP body, bucket resolver, state handles, snapshots, or backend configuration.
| Outcome | Meaning |
| --- | --- |
| `Pick` | The chosen engine belongs to the supplied set and passed admission |
| `NoMatchingBucket` | No bucket supports the requested length |
| `NoCandidates` | No eligible member or policy selection miss |
| `NoAdmissibleEngine` | A policy exhausted its candidates; includes rejection reasons |
| `AdmissionRejected` | The chosen engine failed after-selection admission |
| `InvalidSignal` / `InvalidConfiguration` | Invalid policy input or configuration |
| `OutsideCandidates` | Policy returned an engine outside its exact candidate set |
### Admission
Admission evaluates acceptance. It does not rank engines, choose replacements,
change buckets, or mutate affinity.
| Check | Acceptance rule |
| --- | --- |
| `AllowAll` | Add no acceptance constraint |
| `CapacityAdmission` | Projected running requests and KV tokens fit reported capacity |
| `PendingPrefillAdmission` | Waiting uncached tokens plus incoming uncached work fit the budget |
| `InFlightLimitAdmission` | Router-local in-flight requests are below the limit |
| `QueueLimitAdmission` | Engine-reported waiting requests are below the limit |
| `AllOfAdmission` | Every attached check allows the request |
`EngineAdmission::check(engine, request, load)` checks one engine and returns
`Allow`, `Reject(reason)`, or an error for invalid inputs. Policies attach the
checker directly as `Arc<dyn EngineAdmission>`. There is no placement setting,
filtering wrapper, or before/after API; each policy decides where checking
belongs in its selection algorithm. The `load` argument is an
`Option<&EngineReportedWorkerLoad>` retained by the policy for this engine, including
request counts, token usage, capacity, and the report timestamp. `None` means
no usable observation, never zero load; each check defines its missing-data
behavior. Other required state handles belong to the checker.
Power-of-two first selects an engine, then calls admission exactly once on that
engine. A rejection returns `AdmissionRejected` to the bucket loop; it does not
resample, choose the other sampled engine, or run a policy fallback. No candidates
returns `NoCandidates` without invoking admission. A single candidate is selected
directly; otherwise two distinct candidates are sampled uniformly, and the one
with lower stage pressure wins. A complete tie keeps the first sampled engine.
Power-of-two reuses the existing pure pressure-comparison functions. Plain and
prefill stages compare estimated prefill queue time when both reports provide it,
then waiting uncached tokens, waiting requests, and running requests. Decode
compares waiting requests, running requests, KV usage fraction, then used KV tokens.
Reported-pressure ties use router-local active requests. If either sampled engine
lacks a fresh, complete native report with valid capacity, both are compared by
router-local active requests instead. Basic reports from older publishers are
still passed to admission when fresh, but do not supply native pressure metrics.
Prepare the signals needed by admission before checking. A pending-prefill check
uses per-engine uncached work when a prefix is known, and full input otherwise.
Decode capacity uses the expected peak sequence length when available, including
on a cache hit. Power-of-two retains the selected engine's load record from
selection and passes it to admission without another snapshot. A single candidate
still has its load read for admission, even though selection needs no comparison.
Neither the bucket nor HTTP handler supplies observations. Concrete load-aware
acceptance rules and additional cache-specific admission signals remain follow-up
work. Synchronous checks do not fetch telemetry over the network themselves.
`AllowAll` is the default for new explicit policy attachments. It leaves health,
role, membership, and policy preferences in force. Migrated configurations must
retain their existing capacity and configured budget checks; see compatibility
below. Each check defines its missing-data behavior. Unknown load is not zero;
the existing capacity and pending-prefill checks allow requests without a fresh,
complete native report.
The cache policy's `worker_queue_limit` is a **soft preference**, not
`QueueLimitAdmission`. Saturation handling can reconsider a queued engine, but
cannot bypass attached hard admission.
Admission checks observe capacity; they do not reserve it. Concurrent requests
may pass against the same observation. Strict reservations would require a
separate mechanism.
## 5. Concrete policies
| Policy | Selection behavior |
| --- | --- |
| `RoundRobinPolicy` | Rotate over candidates using a cursor owned by this policy instance |
| `RandomPolicy` | Choose uniformly from candidates |
| `PowerOfTwoPolicy` | Sample two distinct candidates when possible and choose the lower-pressure engine using the stage's load comparison |
| `LeastLoadPolicy` (`load_based`) | Choose the least loaded engine; preserve tie-breaking, telemetry fallback, and recent-dispatch correction |
| `SessionAwarePolicy` | Reuse an admitted session binding; use power-of-two for new or keyless sessions |
| `StickyPolicy` | Reuse an admitted routing-key binding; use the configured fallback for new or missing keys |
| `CacheAwarePolicy` | Prefer a usable prefix under cache and pressure rules; use a load-based fallback on a miss |
Session and sticky policies do not create assignments for missing keys. A
binding outside the candidates cannot win. A missing binding may invoke policy
fallback within the group; hard admission rejection remains an error.
Sticky fallback supports `round_robin`, `random`, `power_of_two`, and `load_based`,
with round-robin as the default. Nested fallbacks use `AllowAll`; the owning
policy explicitly checks the engine returned by its fallback.
### Cache-aware behavior
The architecture preserves the cache algorithm within the supplied candidate
set. Its responsibilities are:
1. Look up prefix ownership through the local index or remote indexer.
2. Apply minimum matched-token and optional ratio thresholds.
3. Bound candidates using prefix/pressure ordering and the configured minimum,
ratio, and maximum worker counts.
4. Apply the soft queue gate and saturation rules, and call admission explicitly
as required by the cache policy's candidate-selection algorithm.
5. Choose among usable prefix holders using uncached work, the switch margin,
and the pressure guard.
6. On a miss, run the load fallback, preferring engines admitted by the soft
queue gate when available.
Candidate limits and saturation observations use only the selected bucket's
role-group candidates.
Saturation pinning must still pass hard admission.
The target load fallback supports power-of-k sampling through
`--min-load-choices`, default 2. When k covers the group, choose the exact minimum.
Preserve queue-tier preference and avoid sorting with a pairwise pressure
comparator that does not define a total ordering.
Memoize the prefix lookup once per request, including remote I/O. Each policy
restricts those matches to its own candidates. A memoized lookup does not imply
that another bucket or stage's cache selection and admission have already run.
Any optimization that skips those steps must establish that the previous result
applies to the current group.
## 6. Shared state and construction
Application wiring starts shared services once. Policy construction validates
configuration and passes the required handles to each policy and admission
implementation. For example, `PowerOfTwoPolicy::new(Arc<EngineReportedLoadTable>)`
retains the application's shared load table. KV-aware and affinity-aware policies
receive their corresponding shared handles when implemented. Policies with no
state dependency require none. Policy instances do not create duplicate
subscriptions, polling loops, indexes, or remote-client concurrency limits.
Requirements come from all configured policies, their admission checks, and
nested fallbacks. This includes tokenization, affinity-header extraction, load
observations, and dispatch timestamps. A role-group override must receive its
required settings even when the model's default uses a different policy.
### Load state
`state/load_monitor/` owns engine reports and existing router-local request
accounting. Power-of-two owns an `Arc<EngineReportedLoadTable>` and captures a snapshot
locally for each nonempty selection attempt. Its `pick` method selects the engine,
then passes that engine's borrowed load record directly to admission.
No snapshot or observation is added to `Pick`, `PickRequest`,
or the bucket interface, and no shared observation context is threaded through
policies or fallbacks.
The existing snapshot reader preserves rank aggregation, freshness, and capacity
fields. Missing, stale, or rank-incomplete reports yield `None`, not zero load.
A new pick reads current state. Admission reuses the selected observation even if
reports change after selection; it neither recaptures nor reserves capacity.
Fallback policies read their own state and do not share snapshots with callers.
Snapshot capture still scans the full table; an engine-scoped reader can be added
if profiling justifies it. Power-of-two reuses the legacy prefill/decode pressure
comparisons, including router-local fallback. Concrete load-aware admission remains
in #40271. Further shared load interpretation and correction for dispatches since
the report remain follow-ups; these must preserve source, freshness, and available
measurements without adding another
independent in-flight counter. Load and cache observations are not an atomic global
snapshot. Preserve request-guard cleanup.
### Cache state
`state/kv_events/` owns local subscriptions, event application, hashing, and the
radix-tree index. The tree stores prefix ownership and storage tiers, not KV
tensors. Eviction, invalidation, and worker removal update this shared state.
A small `CacheSource` adapter beside the cache policy normalizes local and remote
lookups into common prefix results. Preserve model/cache namespaces, required
rank information, and hash/block-size handling. Convert block counts to token
counts only with a valid conversion.
A confirmed miss and an unavailable backend both allow cache fallback, but
remain distinguishable in diagnostics. Invalid queries and configuration errors
remain explicit errors. Remote indexing and load-only deployments do not need
a duplicate local cache tree.
### Affinity state
`AffinityStore` owns scoped assignments, idle expiry, worker invalidation, and
atomic updates. Policies decide when to reuse or replace an assignment.
Concurrent first assignments must converge on an effective binding that is
still a candidate and passes admission. If a concurrent binding returns a
different engine, the policy must check that engine before returning it.
Reconcile conflicts with bounded retry.
Create or replace a binding only after admission succeeds. A binding records
preferred placement, not successful execution, so it may remain if later PD
selection or dispatch fails. It must not increment dispatch accounting.
## 7. Configuration and compatibility
This conceptual example shows the target attachment model. It is not copyable
current CLI/JSON syntax; existing bucket field names need not change.
```yaml
buckets:
- id: short-context
rank: 10
limits: {min: 0, max: 4096}
max_context_tokens: 8192
groups:
pd:
prefill:
worker_ids: [P1, P2]
policy:
type: cache_aware
admission: {type: capacity}
decode:
worker_ids: [D1, D2]
policy:
type: power_of_two
admission: {type: capacity}
- id: long-context
rank: 20
limits: {min: 0, max: 131072}
max_context_tokens: 131072
groups:
pd:
prefill:
worker_ids: [P3, P4]
policy:
type: cache_aware
admission: {type: capacity}
decode:
worker_ids: [D3, D4]
policy:
type: power_of_two
admission: {type: capacity}
```
A request with 4k input tokens and a 16k expected peak cannot fit the short
bucket's context capacity. It selects the long bucket and both of its P/D groups.
With a known peak of 8k or less, the same input selects both groups of the short bucket.
The planned factory validates unique nonempty bucket IDs, token ranges, and
role-compatible membership. Each bucket is either plain or PD. The selected
bucket invokes its required groups through `pick_engines`. The existing worker registry
still rejects mixed plain and PD engines within one model; this PR preserves
that constraint. The engine group's model and stage filters apply on every pick.
Legacy `BucketSpec` represents a single role-specific membership set. Migration
must explicitly associate prefill and decode specs into complete PD buckets;
never infer those associations from matching rank or similar names. Translation
of role-specific ranges/ranks into bucket-level constraints needs explicit
validation and is deferred with the configuration factory.
Retained settings keep their meanings, defaults, units, and validation unless a
change is listed below. Policy-specific tuning applies to role groups using that
policy. Reject unsupported settings and incompatible combinations at startup;
do not accept and ignore them.
### Retained behavior
- Keep `load_based` as the CLI name for `LeastLoadPolicy`.
- Preserve explicit engine membership and context constraints. Bucket-level
ranges and ordering replace independent per-stage selection.
Restore existing SLO behavior in the separate SLO PR before serving switchover;
legacy routing continues to support SLOs during this skeleton-only phase.
- Preserve cache-provider selection, endpoint validation, query timeout and
concurrency limits, and unavailable-backend fallback.
- Preserve cache thresholds and tuning: the 1,024-token default minimum hit,
optional ratio gate, candidate bounds, switch margin, pressure guard, soft
queue limit, and saturation floor.
- Preserve session and sticky headers, idle timeouts, eviction cadence, and the
four sticky fallback choices. Global modes need a bucket-first migration design.
- Translate `--filter overloaded` and `--max-in-flight` into
`InFlightLimitAdmission`, composed with other checks through `AllOfAdmission`.
- Preserve configured capacity, pending-prefill, and in-flight checks, including
their missing-report behavior. Power-of-two applies admission to its selected
engine; other policies explicitly place checks in their selection logic.
Other paths use `AllowAll` unless a check is configured. Never silently discard
a configured budget.
Listener and shutdown configuration, discovery, worker health and circuit
breakers, tokenizer loading, request timeouts, sampling overrides, and logging
remain outside the policy redesign. Preserve their existing behavior and
validation, including dispatch-time breaker probes and request cancellation.
### Deliberate changes
| Behavior | Target |
| --- | --- |
| Power-of-two admission | Check only the chosen engine; rejection advances to the next bucket |
| Round-robin cursor | One cursor per role-group policy instance |
| Capacity exhaustion | Try the next compatible bucket; return accumulated rejection details if all fail |
| Primary/backup proposals and post-policy substitution | Removed; each policy returns one engine |
| Session affinity | Reuse admitted bindings; remove primary/backup pressure escape |
| Omitted `--affinity-mode` | Admitted-binding reuse replaces the former soft-mode default |
| Pressure-guard tuning | Applies to cache-aware selection; reject session-only use |
| Policy attachment | Explicit role-group policy overrides the applicable model/stage default |
Reject these dropped options explicitly:
- `--policy fused_score` and `--policy score_policy`, including `--fuse` terms
and weights.
- `--decode-policy legacy_host_affinity`.
- `--stable-pair`.
- `--affinity-mode soft`; only strict admitted-binding reuse remains.
- `--filter prefix_cache` and `--prefix-cache-min-share`. The removed prefix-share
filter is not equivalent to the cache-aware minimum-hit gate.
## 8. Dispatch and failure handling
A successful pick means admission passed against the observed state. Health and
capacity can change before dispatch. The handler owns network operations, retry
rules, and accounting for the engines actually dispatched to.
Selection fallback advances through the ordered compatible buckets before any
network dispatch. Every PD attempt picks both engines from that bucket. Transport
retry integration remains separate work; a forwarding failure does not resume
the bucket loop or silently replace a successful policy pick.
For PD, acquire and release accounting for the actual stages and clean up
partial setup on failure. Policy selection does not own the PD request lifetime.
Selection metrics must not imply that dispatch or execution succeeded.
## Implementation status
This PR adds the side-by-side interfaces in `src/buckets_reorg.rs` and
`src/policies_reorg/`, plus a configurable bucket-first implementation behind
`chat_completions`. The live `src/policies/` path remains the default.
Implemented here:
- `BucketResolver::resolve` returns all length-compatible buckets in capacity/rank/ID order.
- `Bucket::pick_engines` owns plain/PD orchestration and stage-specific policy
requests; `BucketRequest` carries prepared facts and `BucketPick` retains picks.
- `Bucket` owns input limits, context capacity, rank, and plain-or-PD groups.
- `EngineGroup::pick` owns live candidate filtering, policy invocation, and
exact candidate validation, without cross-bucket fallback.
- `Policy::pick`, within-group fallback interface, per-engine `EngineAdmission::check`,
and `AllowAll`. Power-of-two samples two distinct engines, compares stage pressure,
and checks its selected engine with no replacement on rejection.
- Policy-owned load dependency and local observations. Power-of-two passes the
selected engine's load record directly to admission, without another snapshot.
`PickRequest`, `Pick`, and bucket APIs carry no load observations.
- The reorg chat implementation iterates resolved buckets, calls `pick_engines`,
advances on empty candidates/admission rejection, and
dispatches only after one complete selection. Exhaustion retains admission reasons.
- `AppContext::chat_routing` configures legacy versus reorg routing on the same
endpoint and carries the reorg model-resolver map.
Follow-up order: concrete admission (#40271), then bucket SLO ordering
in a separate PR, followed by remaining policies and production configuration.
Not yet implemented in the reorg path:
- Other concrete policies and capacity/in-flight admission checks.
- SLO estimates, targets, and bucket preference ordering.
- CLI/configuration parsing, validation, and model-specific construction.
The YAML above is illustrative; reorg resolvers are installed in code.
- Session modes, prefix memoization, and cache-aware selection.
- Shared load interpretation, dispatch correction, and policy-specific
dispatch-timestamp requirements.
- PD compatibility filtering, retry integration, and legacy-route switchover.
The preceding policy sections describe target behavior for those follow-ups;
they do not claim those capabilities are present in this PR.
@@ -0,0 +1,238 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Order buckets by request length; each bucket owns the groups used to pick engines.
//!
//! ```text
//! BucketResolver (one model's buckets)
//! -> Bucket (token limits, context capacity, rank)
//! -> Plain: one EngineGroup
//! -> PD: prefill + decode EngineGroups
//! -> each EngineGroup: worker membership + its own Policy
//! ```
//!
//! - [`BucketResolver::resolve`] returns length-compatible buckets in preference order.
//! - [`EngineGroup::pick`] filters live workers by model, health, stage, and membership,
//! then calls [`Policy::pick`] and validates the returned engine.
//!
//! The handler tries buckets in order, advancing on missing candidates or admission
//! rejection. Both P/D picks must succeed in the same bucket before dispatch.
//! [`WorkerRegistry`] owns live workers; groups reference their IDs. Policies own
//! their load/KV/affinity dependencies and share observations within each attempt.
use std::collections::HashSet;
use std::sync::Arc;
use crate::discovery::{ModelId, WorkerId};
use crate::policies_reorg::{Pick, PickError, PickRequest, Policy, Stage};
use crate::workers::WorkerRegistry;
#[derive(Debug, Clone, Copy, Default)]
pub struct TokenLimits {
pub min: Option<u64>,
pub max: Option<u64>,
}
impl TokenLimits {
fn fits(&self, tokens: u64) -> bool {
self.min.is_none_or(|min| tokens >= min) && self.max.is_none_or(|max| tokens <= max)
}
}
#[derive(Debug)]
pub struct EngineGroup {
/// `None` includes all registered engines matching the request's model and role.
pub worker_ids: Option<HashSet<WorkerId>>,
pub policy: Arc<dyn Policy>,
}
impl EngineGroup {
pub fn new(policy: Arc<dyn Policy>) -> Self {
Self {
worker_ids: None,
policy,
}
}
/// Resolve live members and invoke this group's policy. Never changes buckets.
pub async fn pick(
&self,
workers: &WorkerRegistry,
request: &PickRequest<'_>,
) -> Result<Pick, PickError> {
let mut engines: Vec<_> = workers
.healthy_workers_for(request.model)
.into_iter()
.filter(|engine| engine.mode() == request.stage)
.filter(|engine| {
self.worker_ids
.as_ref()
.is_none_or(|ids| ids.contains(&engine.id))
})
.collect();
// Stable order so cursor-based policies see a consistent candidate list.
engines.sort_by(|left, right| left.id.0.cmp(&right.id.0));
if engines.is_empty() {
return Err(PickError::NoCandidates);
}
let pick = self.policy.pick(&engines, request).await?;
if !engines
.iter()
.any(|engine| Arc::ptr_eq(engine, &pick.engine))
{
return Err(PickError::OutsideCandidates(pick.engine.id.clone()));
}
Ok(pick)
}
}
/// A bucket serves a request on one plain engine or on its own P/D groups.
#[derive(Debug)]
pub enum BucketGroups {
Plain(EngineGroup),
Pd {
prefill: EngineGroup,
decode: EngineGroup,
},
}
/// Prepared request facts shared by all bucket attempts. The bucket supplies
/// its ID and each group's stage when calling policies.
#[derive(Debug)]
pub struct BucketRequest<'a> {
pub model: &'a ModelId,
pub input_tokens: u64,
pub expected_peak_tokens: Option<u64>,
pub token_ids: Option<&'a [u32]>,
pub session_key: Option<&'a str>,
pub routing_key: Option<&'a str>,
}
/// A complete selection from one bucket. For plain serving, `prefill` is the
/// plain engine and `decode` is absent; PD supplies both picks.
#[derive(Debug)]
pub struct BucketPick {
pub prefill: Pick,
pub decode: Option<Pick>,
}
#[derive(Debug)]
pub struct Bucket {
pub id: String,
/// Break ties between equally sized buckets; lower ranks win.
pub rank: u32,
/// Inclusive input-token range used to choose the bucket.
pub limits: TokenLimits,
/// Full sequence capacity, checked against the expected peak when known.
pub max_context_tokens: Option<u64>,
pub groups: BucketGroups,
}
impl Bucket {
pub fn new(id: impl Into<String>, groups: BucketGroups) -> Self {
Self {
id: id.into(),
rank: 0,
limits: TokenLimits::default(),
max_context_tokens: None,
groups,
}
}
/// Select this bucket's plain engine or complete P/D pair, without dispatching.
/// A failed group reports its stage; the caller may then try another bucket.
pub async fn pick_engines(
&self,
workers: &WorkerRegistry,
request: &BucketRequest<'_>,
) -> Result<BucketPick, (Stage, PickError)> {
let (prefill, decode) = match &self.groups {
BucketGroups::Plain(group) => (
self.pick_from_group(group, Stage::Plain, workers, request)
.await?,
None,
),
BucketGroups::Pd { prefill, decode } => {
let prefill = self
.pick_from_group(prefill, Stage::Prefill, workers, request)
.await?;
let decode = self
.pick_from_group(decode, Stage::Decode, workers, request)
.await?;
(prefill, Some(decode))
}
};
Ok(BucketPick { prefill, decode })
}
/// Scope the request to this bucket and role, then ask the group for one engine.
async fn pick_from_group(
&self,
group: &EngineGroup,
stage: Stage,
workers: &WorkerRegistry,
request: &BucketRequest<'_>,
) -> Result<Pick, (Stage, PickError)> {
let request = PickRequest {
model: request.model,
stage,
bucket: &self.id,
input_tokens: request.input_tokens,
expected_peak_tokens: request.expected_peak_tokens,
token_ids: request.token_ids,
session_key: request.session_key,
routing_key: request.routing_key,
};
group
.pick(workers, &request)
.await
.map_err(|error| (stage, error))
}
fn fits(&self, input_tokens: u64, expected_peak_tokens: Option<u64>) -> bool {
self.limits.fits(input_tokens)
&& self
.max_context_tokens
.is_none_or(|max| expected_peak_tokens.unwrap_or(input_tokens) <= max)
}
fn input_capacity(&self) -> u64 {
self.limits
.max
.unwrap_or(u64::MAX)
.min(self.max_context_tokens.unwrap_or(u64::MAX))
}
}
/// Model-specific bucket configuration. Selection does not inspect engine state.
#[derive(Debug, Default)]
pub struct BucketResolver {
pub buckets: Vec<Bucket>,
}
impl BucketResolver {
pub fn new(buckets: Vec<Bucket>) -> Self {
Self { buckets }
}
/// Return all length-compatible buckets, ordered by input capacity, rank, and ID.
/// The caller tries their groups in order until a complete engine selection succeeds.
pub fn resolve(
&self,
input_tokens: u64,
expected_peak_tokens: Option<u64>,
) -> Result<Vec<&Bucket>, PickError> {
if expected_peak_tokens.is_some_and(|tokens| tokens < input_tokens) {
return Err(PickError::InvalidSignal(
"expected peak tokens are below input length".into(),
));
}
let mut buckets: Vec<_> = self
.buckets
.iter()
.filter(|bucket| bucket.fits(input_tokens, expected_peak_tokens))
.collect();
buckets.sort_by_key(|bucket| (bucket.input_capacity(), bucket.rank, &bucket.id));
Ok(buckets)
}
}
+2
View File
@@ -8,10 +8,12 @@
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub mod buckets_reorg;
pub mod config;
pub mod discovery;
pub mod health;
pub mod policies;
pub mod policies_reorg;
pub mod proxy;
pub mod server;
pub mod state;
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use std::fmt::Debug;
use crate::state::load_monitor::engine_reported_load::EngineReportedWorkerLoad;
use crate::workers::Worker;
use super::{PickError, PickRequest};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Allow,
Reject(String),
}
/// Checks one engine using the load observation retained by selection.
/// `None` means no usable load observation, never zero load. Each check defines
/// its missing-data behavior and owns any other state handles it needs.
/// Each policy decides when to check an engine and how to handle rejection.
pub trait EngineAdmission: Send + Sync + Debug {
fn check(
&self,
engine: &Worker,
request: &PickRequest<'_>,
load: Option<&EngineReportedWorkerLoad>,
) -> Result<Decision, PickError>;
}
#[derive(Debug)]
pub struct AllowAll;
impl EngineAdmission for AllowAll {
fn check(
&self,
_: &Worker,
_: &PickRequest<'_>,
_: Option<&EngineReportedWorkerLoad>,
) -> Result<Decision, PickError> {
Ok(Decision::Allow)
}
}
@@ -0,0 +1,105 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Side-by-side implementation of POLICY_DESIGN.md. Chat routing can opt into
//! this interface through AppContext; `policies` remains the default.
pub mod admission;
pub mod power_of_two;
use std::fmt::Debug;
use std::sync::Arc;
use futures::future::BoxFuture;
use crate::discovery::{ModelId, WorkerId};
use crate::workers::Worker;
pub use crate::discovery::WorkerMode as Stage;
/// Request facts for engine selection. Policies own their shared-state handles.
#[derive(Debug, Clone, Copy)]
pub struct PickRequest<'a> {
pub model: &'a ModelId,
pub stage: Stage,
pub bucket: &'a str,
pub input_tokens: u64,
pub expected_peak_tokens: Option<u64>,
pub token_ids: Option<&'a [u32]>,
pub session_key: Option<&'a str>,
pub routing_key: Option<&'a str>,
}
impl<'a> PickRequest<'a> {
pub fn new(model: &'a ModelId, stage: Stage, input_tokens: u64) -> Self {
Self {
model,
stage,
bucket: "",
input_tokens,
expected_peak_tokens: None,
token_ids: None,
session_key: None,
routing_key: None,
}
}
}
#[derive(Debug, Clone)]
pub struct Pick {
pub engine: Arc<Worker>,
pub reason: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rejection {
pub engine: WorkerId,
pub reason: String,
}
#[derive(Debug, thiserror::Error)]
pub enum PickError {
#[error("no bucket matches the request length")]
NoMatchingBucket,
#[error("no candidates")]
NoCandidates,
#[error("no admissible engine: {0:?}")]
NoAdmissibleEngine(Vec<Rejection>),
#[error("selected engine rejected: {0:?}")]
AdmissionRejected(Rejection),
#[error("invalid signal: {0}")]
InvalidSignal(String),
#[error("invalid configuration: {0}")]
InvalidConfiguration(String),
#[error("policy selected an engine outside its candidates: {0:?}")]
OutsideCandidates(WorkerId),
}
/// Returns one admitted engine from exactly the supplied candidates.
/// Implementations receive shared load, KV, and affinity handles at construction;
/// they obtain their own observations rather than asking callers to supply them.
pub trait Policy: Send + Sync + Debug {
/// Read required state locally and pass the selected engine's observations to admission.
fn pick<'a>(
&'a self,
engines: &'a [Arc<Worker>],
request: &'a PickRequest<'a>,
) -> BoxFuture<'a, Result<Pick, PickError>>;
/// Runs on a miss within the same candidates; never on an admission rejection.
fn fallback(&self) -> Option<&dyn Policy> {
None
}
/// Delegate within the same candidates; the fallback reads its own state.
fn pick_fallback<'a>(
&'a self,
engines: &'a [Arc<Worker>],
request: &'a PickRequest<'a>,
) -> BoxFuture<'a, Result<Pick, PickError>> {
match self.fallback() {
Some(fallback) => fallback.pick(engines, request),
None => Box::pin(async { Err(PickError::NoCandidates) }),
}
}
}
@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use std::sync::Arc;
use std::time::Instant;
use futures::future::BoxFuture;
use rand::Rng;
use crate::policies::admission::{compare_decode_pressure, compare_prefill_pressure};
use crate::state::load_monitor::engine_reported_load::EngineReportedLoadTable;
use crate::workers::Worker;
use super::admission::{AllowAll, Decision, EngineAdmission};
use super::{Pick, PickError, PickRequest, Policy, Rejection, Stage};
/// Samples two distinct engines and selects the one with lower stage pressure.
/// Checks admission only on the selected engine; rejection never resamples.
#[derive(Debug)]
pub struct PowerOfTwoPolicy {
/// Shared application state; snapshots are local to each pick.
engine_load: Arc<EngineReportedLoadTable>,
pub admission: Arc<dyn EngineAdmission>,
}
impl PowerOfTwoPolicy {
pub fn new(engine_load: Arc<EngineReportedLoadTable>) -> Self {
Self {
engine_load,
admission: Arc::new(AllowAll),
}
}
}
impl Policy for PowerOfTwoPolicy {
fn pick<'a>(
&'a self,
engines: &'a [Arc<Worker>],
request: &'a PickRequest<'a>,
) -> BoxFuture<'a, Result<Pick, PickError>> {
Box::pin(async move {
if engines.is_empty() {
return Err(PickError::NoCandidates);
}
// Selection and admission use the same load observation.
let load = self.engine_load.capture_snapshot(Instant::now());
let engine = match engines {
[engine] => Arc::clone(engine),
_ => {
let mut rng = rand::thread_rng();
let i = rng.gen_range(0..engines.len());
let mut j = rng.gen_range(0..engines.len() - 1);
if j >= i {
j += 1;
}
let (left, right) = (&engines[i], &engines[j]);
let pressure = match request.stage {
Stage::Plain | Stage::Prefill => {
compare_prefill_pressure(left, right, Some(&load))
}
Stage::Decode => compare_decode_pressure(left, right, Some(&load)),
};
Arc::clone(if pressure.is_gt() { right } else { left })
}
};
let engine_load = load.fresh_load_for_url(&engine.url);
if let Decision::Reject(reason) = self.admission.check(&engine, request, engine_load)? {
return Err(PickError::AdmissionRejected(Rejection {
engine: engine.id.clone(),
reason,
}));
}
Ok(Pick {
engine,
reason: "power_of_two",
})
})
}
}
@@ -1,7 +1,9 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use crate::buckets_reorg::BucketResolver;
use crate::config::Config;
use crate::discovery::ModelId;
use crate::policies::buckets::BucketSelector;
use crate::policies::prefix_provider::RadixTreePrefixProvider;
@@ -14,6 +16,7 @@ use crate::state::load_monitor::engine_reported_load::EngineReportedLoadTable;
use crate::state::load_monitor::router_inflight_load::RouterInflightLoadRegistry;
use crate::tokenizer::TokenizerRegistry;
use crate::workers::WorkerRegistry;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc;
@@ -26,6 +29,15 @@ const READINESS_NOT_READY: u8 = 0;
const READINESS_READY: u8 = 1;
const READINESS_DRAINING: u8 = 2;
/// Routing implementation used by the standard chat-completions endpoint.
/// Reorg configuration is installed explicitly until its CLI factory is available.
#[derive(Debug, Default)]
pub enum ChatRouting {
#[default]
Legacy,
Reorg(HashMap<ModelId, BucketResolver>),
}
pub struct AppContext {
pub config: Config,
pub tokenizers: Arc<TokenizerRegistry>,
@@ -34,6 +46,8 @@ pub struct AppContext {
pub policies: Arc<PolicyRegistry>,
/// Converts static Bucket configuration into request candidate domains.
pub bucket_selector: Arc<BucketSelector>,
/// Select legacy policies or model-specific bucket-first routing.
pub chat_routing: ChatRouting,
/// Per-worker active-load bookkeeping shared by the proxy, policies,
/// timeout janitor, and metrics.
pub router_inflight_load: Arc<RouterInflightLoadRegistry>,
@@ -106,6 +120,7 @@ impl AppContext {
registry,
policies,
bucket_selector,
chat_routing: ChatRouting::Legacy,
router_inflight_load,
metrics,
prefix_index: None,
@@ -195,6 +210,7 @@ impl AppContext {
registry: Arc::new(WorkerRegistry::default()),
policies: Arc::new(PolicyRegistry::default()),
bucket_selector: Arc::new(BucketSelector::new(None)),
chat_routing: ChatRouting::Legacy,
router_inflight_load: RouterInflightLoadRegistry::with_defaults(),
metrics: MetricsRegistry::new(),
prefix_index: None,
@@ -3,6 +3,7 @@
mod forward;
mod preparation;
mod reorg;
use crate::config::{SessionAffinityMode, DEFAULT_MIN_LOAD_CHOICES};
use crate::discovery::{ModelId, WorkerMode};
@@ -11,7 +12,7 @@ use crate::policies::selection::{
select_decode_peer, select_prefill_worker, DecodeSelectionInputs, PrefillSelectionInputs,
};
use crate::policies::{ExternalPrefixSignal, Policy};
use crate::server::app_context::AppContext;
use crate::server::app_context::{AppContext, ChatRouting};
use crate::server::error::ApiError;
use crate::server::metrics::PolicySelectionFailureReason;
use crate::state::kv_events::{compute_block_hashes, compute_block_hashes_bigram};
@@ -38,6 +39,19 @@ pub async fn chat_completions(
State(ctx): State<Arc<AppContext>>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response<Body>, ApiError> {
match &ctx.chat_routing {
ChatRouting::Legacy => chat_completions_legacy(&ctx, headers, body).await,
ChatRouting::Reorg(resolvers) => {
reorg::chat_completions(&ctx, resolvers, headers, body).await
}
}
}
async fn chat_completions_legacy(
ctx: &AppContext,
headers: HeaderMap,
body: Bytes,
) -> Result<Response<Body>, ApiError> {
let start = Instant::now();
let mut fields = parse_routing_fields(&body)?;
@@ -59,11 +73,11 @@ pub async fn chat_completions(
.ok_or_else(|| ApiError::ModelNotFound(model.0.clone()))?;
let request =
PreparedChatRequest::prepare(&ctx, model, fields, body, policy.needs_request_tokens())?;
PreparedChatRequest::prepare(ctx, model, fields, body, policy.needs_request_tokens())?;
// Pick a plain worker, or a prefill worker followed by a decode peer in PD mode.
let workers = select_workers(
&ctx,
ctx,
&request,
&headers,
policy.as_ref(),
@@ -73,7 +87,7 @@ pub async fn chat_completions(
.await?;
// PD sends to both workers and returns the decode response.
forward_chat_request(&ctx, request, workers, headers, start).await
forward_chat_request(ctx, request, workers, headers, start).await
}
fn pool_error(error: PdResolveError, model: &ModelId) -> ApiError {
@@ -0,0 +1,136 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use super::forward::{forward_chat_request, SelectedWorkers};
use super::nonempty_header;
use super::preparation::{parse_routing_fields, PreparedChatRequest};
use crate::buckets_reorg::{BucketRequest, BucketResolver};
use crate::discovery::ModelId;
use crate::policies_reorg::{PickError, Stage};
use crate::server::app_context::AppContext;
use crate::server::error::ApiError;
use axum::body::Body;
use axum::http::{HeaderMap, Response};
use bytes::Bytes;
use std::collections::HashMap;
use std::time::Instant;
/// Bucket-first implementation selected by `AppContext::chat_routing`.
pub(super) async fn chat_completions(
ctx: &AppContext,
resolvers: &HashMap<ModelId, BucketResolver>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response<Body>, ApiError> {
let start = Instant::now();
let mut fields = parse_routing_fields(&body)?;
let model = ModelId(
fields
.model
.take()
.ok_or_else(|| ApiError::BadRequest("missing `model` field".into()))?,
);
let resolver = resolvers
.get(&model)
.ok_or_else(|| ApiError::ModelNotFound(model.0.clone()))?;
// Length-based routing needs tokenization even for load-only group policies.
let request = PreparedChatRequest::prepare(ctx, model, fields, body, true)?;
let input_tokens = request.input_token_count as u64;
let expected_peak_tokens = request
.max_output_tokens
.map(|output| {
input_tokens.checked_add(output).ok_or_else(|| {
ApiError::BadRequest("input and output token counts overflow".into())
})
})
.transpose()?;
let buckets = resolver
.resolve(input_tokens, expected_peak_tokens)
.map_err(|error| selection_error(error, &request.model, None))?;
if buckets.is_empty() {
return Err(selection_error(
PickError::NoMatchingBucket,
&request.model,
None,
));
}
let bucket_request = BucketRequest {
model: &request.model,
input_tokens,
expected_peak_tokens,
token_ids: request.tokens.as_ref().map(|tokens| tokens.ids.as_slice()),
session_key: ctx
.config
.model
.affinity
.as_ref()
.and_then(|config| nonempty_header(&headers, &config.session_id_header)),
routing_key: ctx
.config
.model
.sticky
.as_ref()
.and_then(|config| nonempty_header(&headers, &config.header_name)),
};
let mut rejections: Option<Vec<_>> = None;
let mut missing_stage = None;
for bucket in buckets {
match bucket.pick_engines(&ctx.registry, &bucket_request).await {
Ok(picks) => {
// Dispatch only after this bucket supplies the entire plain or PD selection.
let workers = SelectedWorkers {
prefill: picks.prefill.engine,
decode: picks.decode.map(|pick| pick.engine),
track_dispatch_timestamps: false,
};
return forward_chat_request(ctx, request, workers, headers, start).await;
}
Err((stage, error)) => {
tracing::debug!(bucket = %bucket.id, ?stage, %error, "bucket selection failed");
match error {
PickError::NoCandidates => missing_stage = Some(stage),
PickError::NoAdmissibleEngine(reasons) => {
rejections.get_or_insert_with(Vec::new).extend(reasons);
}
PickError::AdmissionRejected(reason) => {
rejections.get_or_insert_with(Vec::new).push(reason);
}
error => return Err(selection_error(error, &request.model, Some(stage))),
}
}
}
}
// Preserve admission exhaustion even if a later bucket has no candidates.
let error = match rejections {
Some(reasons) => PickError::NoAdmissibleEngine(reasons),
None => PickError::NoCandidates,
};
Err(selection_error(error, &request.model, missing_stage))
}
fn selection_error(error: PickError, model: &ModelId, stage: Option<Stage>) -> ApiError {
tracing::warn!(%model, ?stage, %error, "reorg selection failed");
match error {
PickError::NoMatchingBucket => {
ApiError::BadRequest("no bucket supports the requested token length".into())
}
PickError::NoCandidates => match stage {
Some(Stage::Prefill) => ApiError::NoPrefillWorkersAvailable {
model: model.0.clone(),
},
Some(Stage::Decode) => ApiError::NoDecodeWorkersAvailable {
model: model.0.clone(),
},
_ => ApiError::NoHealthyWorkers {
model: model.0.clone(),
},
},
PickError::NoAdmissibleEngine(_) | PickError::AdmissionRejected(_) => {
ApiError::PolicySelectionFailed {
model: model.0.clone(),
}
}
error => ApiError::Internal(error.into()),
}
}
@@ -10,5 +10,8 @@
mod discovery;
mod health;
mod policies;
mod policies_reorg;
mod policies_reorg_load;
mod policies_reorg_power_of_two;
mod tokenizer;
mod workers;
@@ -0,0 +1,486 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use std::sync::{Arc, Mutex};
use futures::future::BoxFuture;
use sgl_router::buckets_reorg::{
Bucket, BucketGroups, BucketRequest, BucketResolver, EngineGroup, TokenLimits,
};
use sgl_router::discovery::{ModelId, WorkerId, WorkerSpec};
use sgl_router::policies_reorg::admission::{AllowAll, Decision, EngineAdmission};
use sgl_router::policies_reorg::{Pick, PickError, PickRequest, Policy, Rejection, Stage};
use sgl_router::state::load_monitor::engine_reported_load::EngineReportedWorkerLoad;
use sgl_router::workers::{Worker, WorkerRegistry};
#[derive(Debug)]
struct TestPolicy {
admission: Arc<dyn EngineAdmission>,
result: Option<Arc<Worker>>,
miss: bool,
invalid: bool,
calls: Mutex<Vec<String>>,
}
impl Default for TestPolicy {
fn default() -> Self {
Self {
admission: Arc::new(AllowAll),
result: None,
miss: false,
invalid: false,
calls: Mutex::new(Vec::new()),
}
}
}
impl Policy for TestPolicy {
fn pick<'a>(
&'a self,
engines: &'a [Arc<Worker>],
request: &'a PickRequest<'a>,
) -> BoxFuture<'a, Result<Pick, PickError>> {
Box::pin(async move {
self.calls.lock().unwrap().push(request.bucket.to_owned());
if self.invalid {
return Err(PickError::InvalidSignal("test signal".into()));
}
if self.miss {
return Err(PickError::NoCandidates);
}
if engines.is_empty() {
return Err(PickError::NoCandidates);
}
let engine = self.result.clone().unwrap_or_else(|| engines[0].clone());
if let Decision::Reject(reason) = self.admission.check(&engine, request, None)? {
return Err(PickError::AdmissionRejected(Rejection {
engine: engine.id.clone(),
reason,
}));
}
Ok(Pick {
engine,
reason: "test",
})
})
}
}
#[derive(Debug)]
struct Reject(&'static str);
impl EngineAdmission for Reject {
fn check(
&self,
engine: &Worker,
_: &PickRequest<'_>,
_: Option<&EngineReportedWorkerLoad>,
) -> Result<Decision, PickError> {
Ok(if engine.id.0 == self.0 {
Decision::Reject("full".into())
} else {
Decision::Allow
})
}
}
fn spec(id: &str, mode: Stage, model: &str) -> WorkerSpec {
WorkerSpec {
id: WorkerId(id.into()),
url: format!("http://{id}"),
mode,
model_ids: vec![ModelId(model.into())],
bootstrap_port: None,
}
}
fn registry() -> Arc<WorkerRegistry> {
let workers = Arc::new(WorkerRegistry::default());
for (id, mode, model) in [
("a", Stage::Plain, "m"),
("b", Stage::Plain, "m"),
("unhealthy", Stage::Plain, "m"),
("other", Stage::Plain, "other"),
("p", Stage::Prefill, "pd"),
("d", Stage::Decode, "pd"),
] {
workers.add(spec(id, mode, model)).unwrap();
}
let unhealthy = workers.get(&WorkerId("unhealthy".into())).unwrap();
for _ in 0..3 {
unhealthy.breaker.record_failure();
}
workers
}
fn group(members: &[&str], policy: Arc<dyn Policy>) -> EngineGroup {
EngineGroup {
worker_ids: Some(members.iter().map(|id| WorkerId((*id).into())).collect()),
policy,
}
}
fn bucket(id: &str, max: Option<u64>, policy: Arc<dyn Policy>) -> Bucket {
let mut bucket = Bucket::new(id, BucketGroups::Plain(EngineGroup::new(policy)));
bucket.limits.max = max;
bucket
}
#[tokio::test]
async fn groups_isolate_model_health_stage_and_membership() {
let workers = registry();
let model = ModelId("m".into());
let request = PickRequest::new(&model, Stage::Plain, 10);
let group = EngineGroup::new(Arc::new(TestPolicy::default()));
assert_eq!(
group.pick(&workers, &request).await.unwrap().engine.id.0,
"a"
);
workers.remove(&WorkerId("a".into()));
assert_eq!(
group.pick(&workers, &request).await.unwrap().engine.id.0,
"b"
);
workers.remove(&WorkerId("b".into()));
assert!(matches!(
group.pick(&workers, &request).await,
Err(PickError::NoCandidates)
));
let pd = ModelId("pd".into());
for (stage, expected) in [(Stage::Prefill, "p"), (Stage::Decode, "d")] {
let request = PickRequest::new(&pd, stage, 10);
let group = self::group(
&["p", "d", "other", "unhealthy"],
Arc::new(TestPolicy::default()),
);
assert_eq!(
group.pick(&workers, &request).await.unwrap().engine.id.0,
expected
);
}
let empty = self::group(&[], Arc::new(TestPolicy::default()));
assert!(matches!(
empty.pick(&workers, &request).await,
Err(PickError::NoCandidates)
));
}
#[test]
fn resolve_orders_all_length_fits_by_capacity_rank_and_id() {
let policy = Arc::new(TestPolicy::default());
let mut z = bucket("z", Some(20), policy.clone());
z.rank = 1;
let mut a = bucket("a", Some(20), policy.clone());
a.rank = 1;
let mut later = bucket("later", Some(20), policy.clone());
later.rank = 2;
let mut min = bucket("min", Some(15), policy.clone());
min.limits.min = Some(11);
let resolver = BucketResolver::new(vec![
bucket("catch-all", None, policy.clone()),
bucket("too-small", Some(9), policy),
z,
later,
a,
min,
]);
assert_eq!(
resolver
.resolve(10, None)
.unwrap()
.iter()
.map(|bucket| bucket.id.as_str())
.collect::<Vec<_>>(),
["a", "z", "later", "catch-all"]
);
assert_eq!(resolver.resolve(11, None).unwrap()[0].id, "min");
assert_eq!(resolver.resolve(15, None).unwrap()[0].id, "min");
assert_eq!(resolver.resolve(20, None).unwrap()[0].id, "a");
assert_eq!(resolver.resolve(21, None).unwrap()[0].id, "catch-all");
}
#[test]
fn context_capacity_checks_peak_when_known_and_input_otherwise() {
let policy = Arc::new(TestPolicy::default());
let mut short = bucket("short", None, policy.clone());
short.max_context_tokens = Some(20);
let mut long = bucket("long", None, policy);
long.max_context_tokens = Some(30);
let resolver = BucketResolver::new(vec![long, short]);
assert_eq!(resolver.resolve(10, None).unwrap()[0].id, "short");
assert_eq!(resolver.resolve(10, Some(20)).unwrap()[0].id, "short");
assert_eq!(resolver.resolve(10, Some(21)).unwrap()[0].id, "long");
assert!(resolver.resolve(10, Some(31)).unwrap().is_empty());
assert!(resolver.resolve(31, None).unwrap().is_empty());
assert!(matches!(
resolver.resolve(10, Some(9)),
Err(PickError::InvalidSignal(_))
));
assert!(BucketResolver::default()
.resolve(1, None)
.unwrap()
.is_empty());
}
#[tokio::test]
async fn selected_pd_bucket_owns_both_memberships_and_policies() {
let workers = registry();
workers.add(spec("p2", Stage::Prefill, "pd")).unwrap();
workers.add(spec("d2", Stage::Decode, "pd")).unwrap();
let model = ModelId("pd".into());
let prefill_policy = Arc::new(TestPolicy::default());
let decode_policy = Arc::new(TestPolicy::default());
let resolver = BucketResolver::new(vec![Bucket::new(
"shared",
BucketGroups::Pd {
prefill: group(&["p2", "d", "a"], prefill_policy.clone()),
decode: group(&["d2", "p", "other"], decode_policy.clone()),
},
)]);
let bucket = resolver.resolve(10, Some(20)).unwrap()[0];
let request = BucketRequest {
model: &model,
input_tokens: 10,
expected_peak_tokens: Some(20),
token_ids: None,
session_key: None,
routing_key: None,
};
let picks = bucket.pick_engines(&workers, &request).await.unwrap();
assert_eq!(picks.prefill.engine.id.0, "p2");
assert_eq!(picks.decode.unwrap().engine.id.0, "d2");
assert_eq!(*prefill_policy.calls.lock().unwrap(), ["shared"]);
assert_eq!(*decode_policy.calls.lock().unwrap(), ["shared"]);
}
#[tokio::test]
async fn resolver_includes_empty_groups_without_invoking_policies() {
let workers = registry();
let model = ModelId("m".into());
let policy = Arc::new(TestPolicy::default());
let mut empty = Bucket::new(
"empty",
BucketGroups::Plain(group(&["missing"], policy.clone())),
);
empty.limits = TokenLimits {
min: None,
max: Some(10),
};
let resolver = BucketResolver::new(vec![empty, bucket("available", Some(20), policy.clone())]);
let buckets = resolver.resolve(10, None).unwrap();
assert_eq!(
buckets
.iter()
.map(|bucket| bucket.id.as_str())
.collect::<Vec<_>>(),
["empty", "available"]
);
let bucket = buckets[0];
let BucketGroups::Plain(group) = &bucket.groups else {
panic!("expected plain")
};
let request = PickRequest {
bucket: &bucket.id,
..PickRequest::new(&model, Stage::Plain, 10)
};
assert!(matches!(
group.pick(&workers, &request).await,
Err(PickError::NoCandidates)
));
assert!(policy.calls.lock().unwrap().is_empty());
}
#[tokio::test]
async fn group_propagates_rejections_misses_and_invalid_signals() {
let workers = registry();
let model = ModelId("m".into());
let request = PickRequest::new(&model, Stage::Plain, 10);
let rejected = group(
&["a"],
Arc::new(TestPolicy {
admission: Arc::new(Reject("a")),
..Default::default()
}),
);
assert!(matches!(
rejected.pick(&workers, &request).await,
Err(PickError::AdmissionRejected(_))
));
let miss = group(
&["a"],
Arc::new(TestPolicy {
miss: true,
..Default::default()
}),
);
assert!(matches!(
miss.pick(&workers, &request).await,
Err(PickError::NoCandidates)
));
let invalid = group(
&["a"],
Arc::new(TestPolicy {
invalid: true,
..Default::default()
}),
);
assert!(matches!(
invalid.pick(&workers, &request).await,
Err(PickError::InvalidSignal(_))
));
}
#[tokio::test]
async fn group_rejects_foreign_pick_even_with_same_worker_id() {
let workers = registry();
let model = ModelId("m".into());
let request = PickRequest::new(&model, Stage::Plain, 10);
let group = group(
&["a"],
Arc::new(TestPolicy {
result: Some(Arc::new(Worker::new(spec("a", Stage::Plain, "m")))),
..Default::default()
}),
);
assert!(matches!(
group.pick(&workers, &request).await,
Err(PickError::OutsideCandidates(_))
));
}
#[tokio::test]
async fn selected_engine_rejection_does_not_try_an_alternative() {
let model = ModelId("m".into());
let request = PickRequest::new(&model, Stage::Plain, 10);
let engines = [
Arc::new(Worker::new(spec("a", Stage::Plain, "m"))),
Arc::new(Worker::new(spec("b", Stage::Plain, "m"))),
];
let policy = TestPolicy {
admission: Arc::new(Reject("a")),
..Default::default()
};
assert!(matches!(
policy.pick(&engines, &request).await,
Err(PickError::AdmissionRejected(_))
));
assert!(matches!(
policy.pick(&[], &request).await,
Err(PickError::NoCandidates)
));
}
#[tokio::test]
async fn bucket_scopes_plain_pick_and_preserves_request_facts() {
#[derive(Debug)]
struct InspectRequest;
impl Policy for InspectRequest {
fn pick<'a>(
&'a self,
engines: &'a [Arc<Worker>],
request: &'a PickRequest<'a>,
) -> BoxFuture<'a, Result<Pick, PickError>> {
Box::pin(async move {
assert_eq!(request.model.0, "m");
assert_eq!(request.bucket, "plain-bucket");
assert_eq!(request.stage, Stage::Plain);
assert_eq!(request.input_tokens, 2);
assert_eq!(request.expected_peak_tokens, Some(12));
assert_eq!(request.token_ids, Some([7, 9].as_slice()));
assert_eq!(request.session_key, Some("session"));
assert_eq!(request.routing_key, Some("routing"));
Ok(Pick {
engine: engines[0].clone(),
reason: "inspected",
})
})
}
}
let workers = registry();
let model = ModelId("m".into());
let bucket = Bucket::new(
"plain-bucket",
BucketGroups::Plain(group(&["b"], Arc::new(InspectRequest))),
);
let request = BucketRequest {
model: &model,
input_tokens: 2,
expected_peak_tokens: Some(12),
token_ids: Some(&[7, 9]),
session_key: Some("session"),
routing_key: Some("routing"),
};
let picks = bucket.pick_engines(&workers, &request).await.unwrap();
assert_eq!(picks.prefill.engine.id.0, "b");
assert_eq!(picks.prefill.reason, "inspected");
assert!(picks.decode.is_none());
}
#[tokio::test]
async fn power_of_two_checks_selected_engine_and_propagates_rejection_without_fallback() {
use sgl_router::policies_reorg::power_of_two::PowerOfTwoPolicy;
use sgl_router::state::load_monitor::engine_reported_load::EngineReportedLoadTable;
#[derive(Debug)]
struct Check {
calls: Mutex<Vec<WorkerId>>,
reject: bool,
invalid: bool,
}
impl EngineAdmission for Check {
fn check(
&self,
engine: &Worker,
_: &PickRequest<'_>,
_: Option<&EngineReportedWorkerLoad>,
) -> Result<Decision, PickError> {
self.calls.lock().unwrap().push(engine.id.clone());
if self.invalid {
Err(PickError::InvalidSignal("admission input".into()))
} else if self.reject {
Ok(Decision::Reject("full".into()))
} else {
Ok(Decision::Allow)
}
}
}
let model = ModelId("m".into());
let request = PickRequest::new(&model, Stage::Plain, 10);
let engine = Arc::new(Worker::new(spec("a", Stage::Plain, "m")));
let other = Arc::new(Worker::new(spec("b", Stage::Plain, "m")));
let _busy = other.load_guard();
for engines in [vec![engine.clone()], vec![other.clone(), engine.clone()]] {
for (reject, invalid) in [(false, false), (true, false), (false, true)] {
let check = Arc::new(Check {
calls: Mutex::new(Vec::new()),
reject,
invalid,
});
let mut policy = PowerOfTwoPolicy::new(EngineReportedLoadTable::new());
policy.admission = check.clone();
assert!(matches!(
policy.pick(&[], &request).await,
Err(PickError::NoCandidates)
));
assert!(check.calls.lock().unwrap().is_empty());
let result = policy.pick(&engines, &request).await;
if invalid {
assert!(matches!(result, Err(PickError::InvalidSignal(_))));
} else if reject {
assert!(matches!(result, Err(PickError::AdmissionRejected(reason))
if reason.engine == engine.id && reason.reason == "full"));
} else {
assert!(Arc::ptr_eq(&result.unwrap().engine, &engine));
}
assert_eq!(
check.calls.lock().unwrap().as_slice(),
std::slice::from_ref(&engine.id)
);
}
}
}
@@ -0,0 +1,138 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use sgl_router::discovery::{ModelId, WorkerId, WorkerSpec};
use sgl_router::policies_reorg::admission::{Decision, EngineAdmission};
use sgl_router::policies_reorg::power_of_two::PowerOfTwoPolicy;
use sgl_router::policies_reorg::{PickError, PickRequest, Policy, Stage};
use sgl_router::state::load_monitor::engine_reported_load::{
EngineReportedLoadTable, EngineReportedWorkerLoad, LoadStat,
};
use sgl_router::workers::Worker;
const URL: &str = "http://engine";
fn report(table: &EngineReportedLoadTable, rank: u32, running: u64, at: Instant) {
table.set(
URL,
rank,
LoadStat {
num_running_reqs: running,
num_waiting_reqs: 2,
num_tokens: 30,
max_total_num_tokens: 100,
native_cache: None,
},
at,
);
}
fn engine() -> Arc<Worker> {
Arc::new(Worker::new(WorkerSpec {
id: WorkerId("a".into()),
url: URL.into(),
mode: Stage::Plain,
model_ids: vec![ModelId("m".into())],
bootstrap_port: None,
}))
}
#[derive(Debug)]
struct ObserveAdmission {
table: Arc<EngineReportedLoadTable>,
observations: Mutex<Vec<Option<EngineReportedWorkerLoad>>>,
}
impl EngineAdmission for ObserveAdmission {
fn check(
&self,
engine: &Worker,
_: &PickRequest<'_>,
load: Option<&EngineReportedWorkerLoad>,
) -> Result<Decision, PickError> {
assert_eq!(engine.url, URL);
// A new report arriving after selection must not change the observation
// supplied to admission. The next pick should read the new report.
report(&self.table, 0, 99, Instant::now());
self.observations.lock().unwrap().push(load.cloned());
Ok(Decision::Allow)
}
}
#[tokio::test]
async fn selected_load_reaches_admission_and_next_pick_reads_fresh_state() {
let table = EngineReportedLoadTable::new();
let first_at = Instant::now();
report(&table, 0, 1, first_at);
report(&table, 1, 3, first_at);
table.mark_expected_rank(URL, 0);
table.mark_expected_rank(URL, 1);
let admission = Arc::new(ObserveAdmission {
table: table.clone(),
observations: Mutex::default(),
});
let mut policy = PowerOfTwoPolicy::new(table);
policy.admission = admission.clone();
let model = ModelId("m".into());
let request = PickRequest::new(&model, Stage::Plain, 10);
let alternative = Arc::new(Worker::new(WorkerSpec {
id: WorkerId("b".into()),
url: "http://other".into(),
mode: Stage::Plain,
model_ids: vec![ModelId("m".into())],
bootstrap_port: None,
}));
// These old-format reports lack native pressure metrics, so selection uses
// local active counts for both candidates and chooses the second engine.
let _busy = alternative.load_guard();
let engines = [alternative, engine()];
for _ in 0..2 {
let pick = policy.pick(&engines, &request).await.unwrap();
assert!(Arc::ptr_eq(&pick.engine, &engines[1]));
}
let observations = admission.observations.lock().unwrap();
assert_eq!(observations.len(), 2);
assert_eq!(
observations[0],
Some(EngineReportedWorkerLoad {
num_running_reqs: 4,
num_waiting_reqs: 4,
num_tokens: 60,
max_total_num_tokens: 200,
captured_at: first_at,
})
);
assert_eq!(observations[1].as_ref().unwrap().num_running_reqs, 102);
}
#[tokio::test]
async fn missing_stale_and_incomplete_reports_reach_admission_as_unknown() {
for case in ["missing", "stale", "incomplete"] {
let table = EngineReportedLoadTable::new();
match case {
"missing" => {}
"stale" => report(&table, 0, 1, Instant::now() - Duration::from_secs(3600)),
"incomplete" => {
report(&table, 0, 1, Instant::now());
table.mark_expected_rank(URL, 0);
table.mark_expected_rank(URL, 1);
}
_ => unreachable!(),
}
let admission = Arc::new(ObserveAdmission {
table: table.clone(),
observations: Mutex::default(),
});
let mut policy = PowerOfTwoPolicy::new(table);
policy.admission = admission.clone();
let model = ModelId("m".into());
policy
.pick(&[engine()], &PickRequest::new(&model, Stage::Plain, 10))
.await
.unwrap();
assert_eq!(*admission.observations.lock().unwrap(), [None], "{case}");
}
}
@@ -0,0 +1,203 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant};
use sgl_router::discovery::{ModelId, WorkerId, WorkerSpec};
use sgl_router::policies_reorg::power_of_two::PowerOfTwoPolicy;
use sgl_router::policies_reorg::{PickRequest, Policy, Stage};
use sgl_router::state::load_monitor::engine_reported_load::{
EngineReportedLoadTable, LoadStat, NativeCacheRankLoad,
};
use sgl_router::workers::Worker;
fn engine(id: &str, stage: Stage, active: usize) -> Arc<Worker> {
let worker = Arc::new(Worker::new(WorkerSpec {
id: WorkerId(id.into()),
url: format!("http://{id}"),
mode: stage,
model_ids: vec![ModelId("m".into())],
bootstrap_port: None,
}));
worker.active_requests.store(active, Ordering::Relaxed);
worker
}
fn load(running: u64, waiting: u64, tokens: u64, capacity: u64, pending: u64) -> LoadStat {
LoadStat {
num_running_reqs: running,
num_waiting_reqs: waiting,
num_tokens: tokens,
max_total_num_tokens: capacity,
native_cache: Some(NativeCacheRankLoad {
num_waiting_uncached_tokens: pending,
num_total_tokens: tokens,
max_running_requests: 100,
total_prefill_uncached_tokens: 0,
total_prefill_busy_us: 0,
}),
}
}
async fn assert_winner(
policy: &PowerOfTwoPolicy,
engines: &[Arc<Worker>],
stage: Stage,
expected: &Arc<Worker>,
) {
let model = ModelId("m".into());
let request = PickRequest::new(&model, stage, 10);
// With exactly two candidates the winner is independent of sample order.
for _ in 0..16 {
let pick = policy.pick(engines, &request).await.unwrap();
assert!(Arc::ptr_eq(&pick.engine, expected), "stage: {stage:?}");
assert_eq!(pick.reason, "power_of_two");
}
}
#[tokio::test]
async fn plain_and_prefill_use_pending_work_while_decode_uses_request_pressure() {
for stage in [Stage::Plain, Stage::Prefill, Stage::Decode] {
let engines = [engine("a", stage, 100), engine("b", stage, 0)];
let table = EngineReportedLoadTable::new();
table.set(&engines[0].url, 0, load(5, 8, 10, 100, 1), Instant::now());
table.set(&engines[1].url, 0, load(1, 1, 10, 100, 100), Instant::now());
let expected = if stage == Stage::Decode { 1 } else { 0 };
assert_winner(
&PowerOfTwoPolicy::new(table),
&engines,
stage,
&engines[expected],
)
.await;
}
}
#[tokio::test]
async fn prefill_uses_estimated_queue_time_only_when_both_engines_have_rates() {
for both_have_rates in [true, false] {
let engines = [
engine("a", Stage::Prefill, 0),
engine("b", Stage::Prefill, 0),
];
let table = EngineReportedLoadTable::new();
for (i, worker) in engines.iter().enumerate() {
let mut report = load(1, 1, 10, 100, if i == 0 { 10 } else { 20 });
if i == 0 || both_have_rates {
table.set(&worker.url, 0, report.clone(), Instant::now());
}
let native = report.native_cache.as_mut().unwrap();
native.total_prefill_uncached_tokens = if i == 0 { 100 } else { 1000 };
native.total_prefill_busy_us = 1_000_000;
table.set(&worker.url, 0, report, Instant::now());
}
// B has more queued tokens, but its higher throughput gives a shorter
// estimated queue. Without B's rate, compare queued tokens for both.
let expected = usize::from(both_have_rates);
assert_winner(
&PowerOfTwoPolicy::new(table),
&engines,
Stage::Prefill,
&engines[expected],
)
.await;
}
}
#[tokio::test]
async fn decode_orders_by_waiting_running_kv_fraction_then_tokens() {
let cases = [
(load(50, 1, 90, 100, 0), load(1, 2, 1, 100, 0)),
(load(1, 1, 90, 100, 0), load(2, 1, 1, 100, 0)),
(load(1, 1, 100, 1000, 0), load(1, 1, 20, 100, 0)),
(load(1, 1, 10, 100, 0), load(1, 1, 100, 1000, 0)),
];
for (left, right) in cases {
let engines = [
engine("a", Stage::Decode, 100),
engine("b", Stage::Decode, 0),
];
let table = EngineReportedLoadTable::new();
table.set(&engines[0].url, 0, left, Instant::now());
table.set(&engines[1].url, 0, right, Instant::now());
assert_winner(
&PowerOfTwoPolicy::new(table),
&engines,
Stage::Decode,
&engines[0],
)
.await;
}
}
#[tokio::test]
async fn unusable_telemetry_falls_back_to_local_load_for_both_candidates() {
for stage in [Stage::Plain, Stage::Prefill, Stage::Decode] {
for case in [
"missing",
"stale",
"incomplete",
"old_publisher",
"unknown_capacity",
] {
let engines = [engine("a", stage, 1), engine("b", stage, 5)];
let table = EngineReportedLoadTable::new();
// A's high reported pressure must not be compared to B's local
// count or to a fabricated zero for its unavailable telemetry.
table.set(
&engines[0].url,
0,
load(90, 90, 90, 100, 900),
Instant::now(),
);
let mut right = load(0, 0, 0, 100, 0);
let mut at = Instant::now();
match case {
"missing" => {}
"stale" => at -= Duration::from_secs(3600),
"incomplete" => {
table.mark_expected_rank(&engines[1].url, 0);
table.mark_expected_rank(&engines[1].url, 1);
}
"old_publisher" => right.native_cache = None,
"unknown_capacity" => right.max_total_num_tokens = 0,
_ => unreachable!(),
}
if case != "missing" {
table.set(&engines[1].url, 0, right, at);
}
assert_winner(&PowerOfTwoPolicy::new(table), &engines, stage, &engines[0]).await;
}
}
}
#[tokio::test]
async fn equal_reported_pressure_uses_local_active_load_as_tiebreaker() {
for stage in [Stage::Plain, Stage::Prefill, Stage::Decode] {
let engines = [engine("a", stage, 5), engine("b", stage, 1)];
let table = EngineReportedLoadTable::new();
for worker in &engines {
table.set(&worker.url, 0, load(1, 1, 10, 100, 10), Instant::now());
}
assert_winner(&PowerOfTwoPolicy::new(table), &engines, stage, &engines[1]).await;
}
}
#[tokio::test]
async fn multiple_candidates_never_select_the_unique_busiest_engine() {
let engines: Vec<_> = (0..8)
.map(|i| engine(&i.to_string(), Stage::Plain, i))
.collect();
let policy = PowerOfTwoPolicy::new(EngineReportedLoadTable::new());
let model = ModelId("m".into());
let request = PickRequest::new(&model, Stage::Plain, 10);
for _ in 0..64 {
let pick = policy.pick(&engines, &request).await.unwrap();
// Every distinct pair has an engine less busy than the last candidate.
assert!(engines[..7]
.iter()
.any(|engine| Arc::ptr_eq(engine, &pick.engine)));
}
}
@@ -21,6 +21,8 @@ use std::sync::Arc;
use std::time::Duration;
use tower::ServiceExt;
mod reorg;
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
fn config_for(_worker_url: &str) -> Config {
@@ -0,0 +1,523 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use super::*;
use crate::common::mock_worker::MockWorker;
use futures::future::BoxFuture;
use sgl_router::buckets_reorg::{Bucket, BucketGroups, BucketResolver, EngineGroup};
use sgl_router::policies::PolicyRegistry;
use sgl_router::policies_reorg::admission::{AllowAll, Decision, EngineAdmission};
use sgl_router::policies_reorg::{Pick, PickError, PickRequest, Policy, Rejection, Stage};
use sgl_router::server::app_context::ChatRouting;
use sgl_router::state::load_monitor::engine_reported_load::EngineReportedWorkerLoad;
use std::sync::Mutex;
type PickCall = (String, Stage, u64, Option<u64>);
#[derive(Debug)]
struct FirstPolicy {
calls: Mutex<Vec<PickCall>>,
admission: Arc<dyn EngineAdmission>,
miss: bool,
invalid: bool,
}
impl Default for FirstPolicy {
fn default() -> Self {
Self {
admission: Arc::new(AllowAll),
miss: false,
invalid: false,
calls: Mutex::new(Vec::new()),
}
}
}
impl Policy for FirstPolicy {
fn pick<'a>(
&'a self,
engines: &'a [Arc<Worker>],
request: &'a PickRequest<'a>,
) -> BoxFuture<'a, Result<Pick, PickError>> {
Box::pin(async move {
self.calls.lock().unwrap().push((
request.bucket.to_owned(),
request.stage,
request.input_tokens,
request.expected_peak_tokens,
));
if self.invalid {
return Err(PickError::InvalidSignal("invalid policy input".into()));
}
if self.miss {
return Err(PickError::NoCandidates);
}
if engines.is_empty() {
return Err(PickError::NoCandidates);
}
let engine = engines[0].clone();
if let Decision::Reject(reason) = self.admission.check(&engine, request, None)? {
return Err(PickError::AdmissionRejected(Rejection {
engine: engine.id.clone(),
reason,
}));
}
Ok(Pick {
engine,
reason: "test",
})
})
}
}
#[derive(Debug)]
struct RejectAll;
impl EngineAdmission for RejectAll {
fn check(
&self,
_: &Worker,
_: &PickRequest<'_>,
_: Option<&EngineReportedWorkerLoad>,
) -> Result<Decision, PickError> {
Ok(Decision::Reject("full".into()))
}
}
fn rejecting_policy() -> Arc<FirstPolicy> {
Arc::new(FirstPolicy {
admission: Arc::new(RejectAll),
..Default::default()
})
}
fn group(id: &str, policy: Arc<FirstPolicy>) -> EngineGroup {
EngineGroup {
worker_ids: Some([WorkerId(id.into())].into_iter().collect()),
policy,
}
}
fn context(workers: &[(&str, Stage, &MockWorker)], buckets: Vec<Bucket>) -> Arc<AppContext> {
let config = config_for("");
let registry = Arc::new(WorkerRegistry::default());
for &(id, mode, worker) in workers {
registry
.add(WorkerSpec {
id: WorkerId(id.into()),
url: worker.url.clone(),
mode,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: Some(8998),
})
.unwrap();
}
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&config).unwrap());
let mut ctx = AppContext::new(
config,
tokenizers,
Arc::new(Proxy::new(TEST_TIMEOUT).unwrap()),
registry,
// The reorg route must not require the legacy policy registry.
Arc::new(PolicyRegistry::default()),
);
ctx.chat_routing = ChatRouting::Reorg(
[(ModelId("tiny".into()), BucketResolver::new(buckets))]
.into_iter()
.collect(),
);
Arc::new(ctx)
}
fn request(value: serde_json::Value) -> Request<Body> {
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&value).unwrap()))
.unwrap()
}
fn body(content: &str) -> serde_json::Value {
serde_json::json!({"model": "tiny", "messages": [{"role": "user", "content": content}]})
}
#[tokio::test]
async fn length_selects_plain_bucket_before_engine_selection() {
let short_worker = MockWorker::start(vec![]).await;
let long_worker = MockWorker::start(vec![]).await;
let policy = Arc::new(FirstPolicy::default());
let mut short = Bucket::new("short", BucketGroups::Plain(group("short", policy.clone())));
short.limits.max = Some(4);
let long = Bucket::new("long", BucketGroups::Plain(group("long", policy.clone())));
let ctx = context(
&[
("short", Stage::Plain, &short_worker),
("long", Stage::Plain, &long_worker),
],
vec![long, short],
);
let app = build_router(ctx);
let response = app.clone().oneshot(request(body("hi"))).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let _ = response.into_body().collect().await.unwrap();
assert!(short_worker.captured.lock().unwrap().last_body.is_some());
assert!(long_worker.captured.lock().unwrap().last_body.is_none());
let response = app
.oneshot(request(body(&"hello ".repeat(30))))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let _ = response.into_body().collect().await.unwrap();
assert!(long_worker.captured.lock().unwrap().last_body.is_some());
let calls = policy.calls.lock().unwrap();
assert_eq!(calls.len(), 2);
assert_eq!((&*calls[0].0, calls[0].1), ("short", Stage::Plain));
assert_eq!((&*calls[1].0, calls[1].1), ("long", Stage::Plain));
}
#[tokio::test]
async fn pd_picks_both_groups_from_selected_bucket_and_shares_bootstrap() {
let prefill = MockWorker::start(vec![]).await;
let decode = MockWorker::start(vec![]).await;
let policy = Arc::new(FirstPolicy::default());
let mut selected = Bucket::new(
"selected",
BucketGroups::Pd {
prefill: group("p", policy.clone()),
decode: group("d", policy.clone()),
},
);
selected.limits.max = Some(100);
let other_policy = Arc::new(FirstPolicy::default());
let other = Bucket::new(
"other",
BucketGroups::Pd {
prefill: group("p", other_policy.clone()),
decode: group("d", other_policy.clone()),
},
);
let ctx = context(
&[
("p", Stage::Prefill, &prefill),
("d", Stage::Decode, &decode),
],
vec![other, selected],
);
let mut body = body("hello");
body["max_completion_tokens"] = 10.into();
let response = build_router(ctx).oneshot(request(body)).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["x-sgl-decode-url"], decode.url);
let _ = response.into_body().collect().await.unwrap();
tokio::time::timeout(TEST_TIMEOUT, async {
while prefill.captured.lock().unwrap().last_body.is_none() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let p: serde_json::Value =
serde_json::from_slice(prefill.captured.lock().unwrap().last_body.as_ref().unwrap())
.unwrap();
let d: serde_json::Value =
serde_json::from_slice(decode.captured.lock().unwrap().last_body.as_ref().unwrap())
.unwrap();
assert!(p["bootstrap_room"].is_number());
assert_eq!(p["bootstrap_room"], d["bootstrap_room"]);
let calls = policy.calls.lock().unwrap();
assert_eq!(calls.len(), 2);
assert_eq!((&*calls[0].0, calls[0].1), ("selected", Stage::Prefill));
assert_eq!((&*calls[1].0, calls[1].1), ("selected", Stage::Decode));
assert_eq!(calls[0].3, Some(calls[0].2 + 10));
assert_eq!(calls[1].3, calls[0].3);
assert!(other_policy.calls.lock().unwrap().is_empty());
}
#[tokio::test]
async fn missing_decode_in_all_buckets_does_not_dispatch_prefill() {
let prefill = MockWorker::start(vec![]).await;
let decode = MockWorker::start(vec![]).await;
let policy = Arc::new(FirstPolicy::default());
let mut selected = Bucket::new(
"selected",
BucketGroups::Pd {
prefill: group("p", policy.clone()),
decode: group("missing", policy.clone()),
},
);
selected.limits.max = Some(100);
let other = Bucket::new(
"other",
BucketGroups::Pd {
prefill: group("p", policy.clone()),
decode: group("also-missing", policy.clone()),
},
);
let ctx = context(
&[
("p", Stage::Prefill, &prefill),
("d", Stage::Decode, &decode),
],
vec![other, selected],
);
let response = build_router(ctx.clone())
.oneshot(request(body("hi")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response.headers()["x-router-error-code"],
"no_decode_workers_available"
);
assert!(prefill.captured.lock().unwrap().last_body.is_none());
assert!(decode.captured.lock().unwrap().last_body.is_none());
assert_eq!(policy.calls.lock().unwrap().len(), 2);
assert_eq!(ctx.router_inflight_load.inflight_count(), 0);
assert_eq!(
ctx.registry
.get(&WorkerId("p".into()))
.unwrap()
.router_inflight_load(),
0
);
}
#[tokio::test]
async fn rejects_unsupported_length_unknown_model_and_overflow_before_policy() {
let worker = MockWorker::start(vec![]).await;
let policy = Arc::new(FirstPolicy::default());
let mut bucket = Bucket::new("short", BucketGroups::Plain(group("w", policy.clone())));
bucket.max_context_tokens = Some(4);
let app = build_router(context(&[("w", Stage::Plain, &worker)], vec![bucket]));
let mut long = body("hi");
long["max_tokens"] = 100.into();
let mut overflow = body("hi");
overflow["max_tokens"] = u64::MAX.into();
let mut unknown = body("hi");
unknown["model"] = "unknown".into();
for (body, status) in [
(long, StatusCode::BAD_REQUEST),
(overflow, StatusCode::BAD_REQUEST),
(unknown, StatusCode::NOT_FOUND),
(serde_json::json!({}), StatusCode::BAD_REQUEST),
] {
let response = app.clone().oneshot(request(body)).await.unwrap();
assert_eq!(response.status(), status);
}
assert!(policy.calls.lock().unwrap().is_empty());
assert!(worker.captured.lock().unwrap().last_body.is_none());
}
#[tokio::test]
async fn streaming_uses_existing_forwarder() {
let worker = MockWorker::start(vec!["data: {\"choices\":[]}\n\n", "data: [DONE]\n\n"]).await;
let policy = Arc::new(FirstPolicy::default());
let bucket = Bucket::new("plain", BucketGroups::Plain(group("w", policy)));
let app = build_router(context(&[("w", Stage::Plain, &worker)], vec![bucket]));
let mut body = body("hi");
body["stream"] = true.into();
let response = app.oneshot(request(body)).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(response.headers()["content-type"]
.to_str()
.unwrap()
.starts_with("text/event-stream"));
let bytes = response.into_body().collect().await.unwrap().to_bytes();
assert!(String::from_utf8_lossy(&bytes).contains("data: [DONE]"));
}
#[tokio::test]
async fn reorg_route_keeps_chat_body_limit() {
let app = build_router(context(&[], vec![]));
let request = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(vec![b' '; MAX_CHAT_BODY_BYTES + 1]))
.unwrap();
assert_eq!(
app.oneshot(request).await.unwrap().status(),
StatusCode::PAYLOAD_TOO_LARGE
);
}
#[tokio::test]
async fn plain_fallback_skips_empty_missed_and_rejected_buckets_then_stops_on_success() {
let rejected_worker = MockWorker::start(vec![]).await;
let winner = MockWorker::start(vec![]).await;
let skipped = Arc::new(FirstPolicy::default());
let rejected = rejecting_policy();
let missed = Arc::new(FirstPolicy {
miss: true,
..Default::default()
});
let accepted = Arc::new(FirstPolicy::default());
let buckets = vec![
Bucket::new(
"a-empty",
BucketGroups::Plain(group("missing", skipped.clone())),
),
Bucket::new(
"b-miss",
BucketGroups::Plain(group("rejected", missed.clone())),
),
Bucket::new(
"c-rejected",
BucketGroups::Plain(group("rejected", rejected.clone())),
),
Bucket::new(
"d-winner",
BucketGroups::Plain(group("winner", accepted.clone())),
),
Bucket::new(
"e-unused",
BucketGroups::Plain(group("winner", skipped.clone())),
),
];
let ctx = context(
&[
("rejected", Stage::Plain, &rejected_worker),
("winner", Stage::Plain, &winner),
],
buckets,
);
let response = build_router(ctx)
.oneshot(request(body("hi")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let _ = response.into_body().collect().await.unwrap();
assert!(rejected_worker.captured.lock().unwrap().last_body.is_none());
assert!(winner.captured.lock().unwrap().last_body.is_some());
assert!(skipped.calls.lock().unwrap().is_empty());
assert_eq!(missed.calls.lock().unwrap().len(), 1);
assert_eq!(rejected.calls.lock().unwrap().len(), 1);
assert_eq!(accepted.calls.lock().unwrap()[0].0, "d-winner");
}
#[tokio::test]
async fn decode_failure_retries_both_groups_in_next_bucket_without_dispatching_first_prefill() {
// Cover an empty decode group and rejection of a selected decode engine.
for reject_decode in [false, true] {
let first_prefill = MockWorker::start(vec![]).await;
let second_prefill = MockWorker::start(vec![]).await;
let decode = MockWorker::start(vec![]).await;
let first = Arc::new(FirstPolicy::default());
let rejected = rejecting_policy();
let accepted = Arc::new(FirstPolicy::default());
let buckets = vec![
Bucket::new(
"a-first",
BucketGroups::Pd {
prefill: group("p1", first.clone()),
decode: group(if reject_decode { "d" } else { "missing" }, rejected),
},
),
Bucket::new(
"b-second",
BucketGroups::Pd {
prefill: group("p2", accepted.clone()),
decode: group("d", accepted.clone()),
},
),
];
let ctx = context(
&[
("p1", Stage::Prefill, &first_prefill),
("p2", Stage::Prefill, &second_prefill),
("d", Stage::Decode, &decode),
],
buckets,
);
let response = build_router(ctx.clone())
.oneshot(request(body("hi")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let _ = response.into_body().collect().await.unwrap();
tokio::time::timeout(TEST_TIMEOUT, async {
while second_prefill.captured.lock().unwrap().last_body.is_none() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(first_prefill.captured.lock().unwrap().last_body.is_none());
assert!(decode.captured.lock().unwrap().last_body.is_some());
assert_eq!(
ctx.registry
.get(&WorkerId("p1".into()))
.unwrap()
.router_inflight_load(),
0
);
assert_eq!(first.calls.lock().unwrap().len(), 1);
let calls = accepted.calls.lock().unwrap();
assert_eq!(calls.len(), 2);
assert_eq!((&*calls[0].0, calls[0].1), ("b-second", Stage::Prefill));
assert_eq!((&*calls[1].0, calls[1].1), ("b-second", Stage::Decode));
}
}
#[tokio::test]
async fn admission_exhaustion_is_preserved_when_later_buckets_are_empty() {
let worker = MockWorker::start(vec![]).await;
let first = rejecting_policy();
let second = rejecting_policy();
let empty = Arc::new(FirstPolicy::default());
let ctx = context(
&[("w", Stage::Plain, &worker)],
vec![
Bucket::new("a-first", BucketGroups::Plain(group("w", first.clone()))),
Bucket::new("b-second", BucketGroups::Plain(group("w", second.clone()))),
Bucket::new(
"c-empty",
BucketGroups::Plain(group("missing", empty.clone())),
),
],
);
let response = build_router(ctx.clone())
.oneshot(request(body("hi")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response.headers()["x-router-error-code"],
"policy_selection_failed"
);
assert_eq!(first.calls.lock().unwrap().len(), 1);
assert_eq!(second.calls.lock().unwrap().len(), 1);
assert!(empty.calls.lock().unwrap().is_empty());
assert!(worker.captured.lock().unwrap().last_body.is_none());
assert_eq!(ctx.router_inflight_load.inflight_count(), 0);
}
#[tokio::test]
async fn invalid_policy_signal_stops_bucket_iteration() {
let worker = MockWorker::start(vec![]).await;
let invalid = Arc::new(FirstPolicy {
invalid: true,
..Default::default()
});
let later = Arc::new(FirstPolicy::default());
let ctx = context(
&[("w", Stage::Plain, &worker)],
vec![
Bucket::new(
"a-invalid",
BucketGroups::Plain(group("w", invalid.clone())),
),
Bucket::new("b-later", BucketGroups::Plain(group("w", later.clone()))),
],
);
let response = build_router(ctx)
.oneshot(request(body("hi")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(invalid.calls.lock().unwrap().len(), 1);
assert!(later.calls.lock().unwrap().is_empty());
assert!(worker.captured.lock().unwrap().last_body.is_none());
}