[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:
co-authored by
Claude Fable 5.1
parent
4a9dc5c4af
commit
aedda8377e
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,12 @@
|
|||||||
|
|
||||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
pub mod buckets_reorg;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod policies;
|
pub mod policies;
|
||||||
|
pub mod policies_reorg;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod state;
|
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-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
use crate::buckets_reorg::BucketResolver;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::discovery::ModelId;
|
||||||
|
|
||||||
use crate::policies::buckets::BucketSelector;
|
use crate::policies::buckets::BucketSelector;
|
||||||
use crate::policies::prefix_provider::RadixTreePrefixProvider;
|
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::state::load_monitor::router_inflight_load::RouterInflightLoadRegistry;
|
||||||
use crate::tokenizer::TokenizerRegistry;
|
use crate::tokenizer::TokenizerRegistry;
|
||||||
use crate::workers::WorkerRegistry;
|
use crate::workers::WorkerRegistry;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicU8, Ordering};
|
use std::sync::atomic::{AtomicU8, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -26,6 +29,15 @@ const READINESS_NOT_READY: u8 = 0;
|
|||||||
const READINESS_READY: u8 = 1;
|
const READINESS_READY: u8 = 1;
|
||||||
const READINESS_DRAINING: u8 = 2;
|
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 struct AppContext {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub tokenizers: Arc<TokenizerRegistry>,
|
pub tokenizers: Arc<TokenizerRegistry>,
|
||||||
@@ -34,6 +46,8 @@ pub struct AppContext {
|
|||||||
pub policies: Arc<PolicyRegistry>,
|
pub policies: Arc<PolicyRegistry>,
|
||||||
/// Converts static Bucket configuration into request candidate domains.
|
/// Converts static Bucket configuration into request candidate domains.
|
||||||
pub bucket_selector: Arc<BucketSelector>,
|
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,
|
/// Per-worker active-load bookkeeping shared by the proxy, policies,
|
||||||
/// timeout janitor, and metrics.
|
/// timeout janitor, and metrics.
|
||||||
pub router_inflight_load: Arc<RouterInflightLoadRegistry>,
|
pub router_inflight_load: Arc<RouterInflightLoadRegistry>,
|
||||||
@@ -106,6 +120,7 @@ impl AppContext {
|
|||||||
registry,
|
registry,
|
||||||
policies,
|
policies,
|
||||||
bucket_selector,
|
bucket_selector,
|
||||||
|
chat_routing: ChatRouting::Legacy,
|
||||||
router_inflight_load,
|
router_inflight_load,
|
||||||
metrics,
|
metrics,
|
||||||
prefix_index: None,
|
prefix_index: None,
|
||||||
@@ -195,6 +210,7 @@ impl AppContext {
|
|||||||
registry: Arc::new(WorkerRegistry::default()),
|
registry: Arc::new(WorkerRegistry::default()),
|
||||||
policies: Arc::new(PolicyRegistry::default()),
|
policies: Arc::new(PolicyRegistry::default()),
|
||||||
bucket_selector: Arc::new(BucketSelector::new(None)),
|
bucket_selector: Arc::new(BucketSelector::new(None)),
|
||||||
|
chat_routing: ChatRouting::Legacy,
|
||||||
router_inflight_load: RouterInflightLoadRegistry::with_defaults(),
|
router_inflight_load: RouterInflightLoadRegistry::with_defaults(),
|
||||||
metrics: MetricsRegistry::new(),
|
metrics: MetricsRegistry::new(),
|
||||||
prefix_index: None,
|
prefix_index: None,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
mod forward;
|
mod forward;
|
||||||
mod preparation;
|
mod preparation;
|
||||||
|
mod reorg;
|
||||||
|
|
||||||
use crate::config::{SessionAffinityMode, DEFAULT_MIN_LOAD_CHOICES};
|
use crate::config::{SessionAffinityMode, DEFAULT_MIN_LOAD_CHOICES};
|
||||||
use crate::discovery::{ModelId, WorkerMode};
|
use crate::discovery::{ModelId, WorkerMode};
|
||||||
@@ -11,7 +12,7 @@ use crate::policies::selection::{
|
|||||||
select_decode_peer, select_prefill_worker, DecodeSelectionInputs, PrefillSelectionInputs,
|
select_decode_peer, select_prefill_worker, DecodeSelectionInputs, PrefillSelectionInputs,
|
||||||
};
|
};
|
||||||
use crate::policies::{ExternalPrefixSignal, Policy};
|
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::error::ApiError;
|
||||||
use crate::server::metrics::PolicySelectionFailureReason;
|
use crate::server::metrics::PolicySelectionFailureReason;
|
||||||
use crate::state::kv_events::{compute_block_hashes, compute_block_hashes_bigram};
|
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>>,
|
State(ctx): State<Arc<AppContext>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
body: Bytes,
|
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> {
|
) -> Result<Response<Body>, ApiError> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut fields = parse_routing_fields(&body)?;
|
let mut fields = parse_routing_fields(&body)?;
|
||||||
@@ -59,11 +73,11 @@ pub async fn chat_completions(
|
|||||||
.ok_or_else(|| ApiError::ModelNotFound(model.0.clone()))?;
|
.ok_or_else(|| ApiError::ModelNotFound(model.0.clone()))?;
|
||||||
|
|
||||||
let request =
|
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.
|
// Pick a plain worker, or a prefill worker followed by a decode peer in PD mode.
|
||||||
let workers = select_workers(
|
let workers = select_workers(
|
||||||
&ctx,
|
ctx,
|
||||||
&request,
|
&request,
|
||||||
&headers,
|
&headers,
|
||||||
policy.as_ref(),
|
policy.as_ref(),
|
||||||
@@ -73,7 +87,7 @@ pub async fn chat_completions(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// PD sends to both workers and returns the decode response.
|
// 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 {
|
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 discovery;
|
||||||
mod health;
|
mod health;
|
||||||
mod policies;
|
mod policies;
|
||||||
|
mod policies_reorg;
|
||||||
|
mod policies_reorg_load;
|
||||||
|
mod policies_reorg_power_of_two;
|
||||||
mod tokenizer;
|
mod tokenizer;
|
||||||
mod workers;
|
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 std::time::Duration;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
mod reorg;
|
||||||
|
|
||||||
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
|
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
fn config_for(_worker_url: &str) -> Config {
|
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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user