sgl-router: experimental Rust HTTP router for SGLang worker pools (#25851)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kangyan-Zhou
2026-05-25 15:34:05 +08:00
committed by GitHub
co-authored by Claude Opus 4.7
parent aae04b1241
commit 6e8fe176be
131 changed files with 28623 additions and 55 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
[codespell]
ignore-words-list = ans, als, hel, boostrap, childs, te, vas, hsa, ment, cann, thi, makro, wil, rouge, PRIS, ather, MIS, medias, allready, inout, nd, fo, visibles, nothink, renderD, ond, tbe, CopyIn
ignore-words-list = ans, als, hel, boostrap, childs, te, vas, hsa, ment, cann, thi, makro, wil, rouge, PRIS, ather, MIS, medias, allready, inout, nd, fo, visibles, nothink, renderD, ond, tbe, CopyIn, notin
skip = *.json, *.jsonl, *.patch, *.txt, *.lock
+141 -54
View File
@@ -1,35 +1,101 @@
name: PR Test (sgl-router)
# Trigger contract — modeled on `pr-test.yml`:
#
# * No `paths:` filter at the trigger level. The workflow ALWAYS
# fires on every PR synchronize / push, so the run record always
# appears as a check on the PR (no more "workflow silently didn't
# fire" mystery debugging). Path-based skip decisions are made
# inside the `sgl-router-gate` job below, where we can log the
# reason. Same for the `run-ci` label gate — moved off the
# workflow trigger and into the gate job so its outcome is
# observable.
#
# * `push` on `main` keeps firing so post-merge runs still record
# against the default branch.
on:
push:
branches: [main]
paths:
- "experimental/sgl-router/**"
- ".github/workflows/pr-test-sgl-router.yml"
- "scripts/ci/cuda/ci_install_dependency.sh"
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled]
paths:
- "experimental/sgl-router/**"
- ".github/workflows/pr-test-sgl-router.yml"
- "scripts/ci/cuda/ci_install_dependency.sh"
workflow_dispatch:
concurrency:
group: sgl-router-${{ github.ref }}
group: sgl-router-${{ github.event_name }}-${{ github.head_ref || github.ref_name || 'default' }}
cancel-in-progress: true
env:
SGLANG_IS_IN_CI: true
jobs:
# Stage 0 — decide whether to run the heavy tiers. Always runs
# (cheap, ubuntu-latest), and emits a single `should_run` output
# consumed by every tier below. Reasons it might evict downstream
# work:
# * `pull_request` event without the `run-ci` label (budget gate)
# * No files matching the sgl-router paths changed in this PR
# Either outcome is logged on the gate job's page, so operators can
# see *why* the tiers were skipped instead of guessing.
sgl-router-gate:
name: gate
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.decide.outputs.should_run }}
paths_changed: ${{ steps.paths.outputs.sgl_router }}
has_run_ci_label: ${{ steps.label.outputs.has_run_ci }}
steps:
- uses: actions/checkout@v4
# `dorny/paths-filter` computes the diff against the PR base
# (for pull_request) or the push's `before` SHA (for push) and
# exposes `sgl_router=true|false` on whether any tracked path
# changed. Replaces the old workflow-level `paths:` filter so
# the gate is observable.
- name: Detect sgl-router path changes
id: paths
uses: dorny/paths-filter@v3
with:
filters: |
sgl_router:
- 'experimental/sgl-router/**'
- '.github/workflows/pr-test-sgl-router.yml'
- 'scripts/ci/cuda/ci_install_dependency.sh'
# `run-ci` label gate. Cheap workflow runs (e.g. docs-only PRs
# that happen to touch the sgl-router dir) still need the
# opt-in label before consuming the H100 / kind tiers below.
# `push` events on main and `workflow_dispatch` skip this gate.
- name: Check run-ci label
id: label
run: |
if [[ "${{ github.event_name }}" != "pull_request" ]]; then
echo "has_run_ci=true" >> "$GITHUB_OUTPUT"
echo "Non-PR event (${{ github.event_name }}); label gate bypassed."
exit 0
fi
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'run-ci') }}" == "true" ]]; then
echo "has_run_ci=true" >> "$GITHUB_OUTPUT"
echo "PR has run-ci label; downstream tiers will run."
else
echo "has_run_ci=false" >> "$GITHUB_OUTPUT"
echo "::warning::PR is missing the 'run-ci' label; skipping sgl-router tiers. Apply the label to opt in."
fi
- name: Decide
id: decide
run: |
paths='${{ steps.paths.outputs.sgl_router }}'
label='${{ steps.label.outputs.has_run_ci }}'
if [[ "$paths" == "true" && "$label" == "true" ]]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "Both path filter and run-ci label match; running tiers."
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "Skipping tiers (paths_changed=$paths, has_run_ci=$label)."
fi
sgl-router-lint:
name: tier-1 — lint
if: |
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-ci')) ||
(github.event.action == 'labeled' && github.event.label.name == 'run-ci')
needs: sgl-router-gate
if: needs.sgl-router-gate.outputs.should_run == 'true'
runs-on: ubuntu-latest
env:
# Scoped per-job (not workflow-wide) to avoid SMG's documented breakage
@@ -120,11 +186,8 @@ jobs:
sgl-router-build-and-test:
name: tier-2 — build + test
needs: sgl-router-lint
if: |
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-ci')) ||
(github.event.action == 'labeled' && github.event.label.name == 'run-ci')
needs: [sgl-router-gate, sgl-router-lint]
if: needs.sgl-router-gate.outputs.should_run == 'true'
runs-on: ubuntu-latest
env:
RUSTC_WRAPPER: sccache
@@ -177,10 +240,13 @@ jobs:
- name: cargo test
working-directory: experimental/sgl-router
# Skip tokenizer_parity here: ubuntu-latest has no HuggingFace cache,
# so the matrix would hard-fail (see the test docstring). The e2e job
# runs it after pytest populates the Qwen3-0.6B tokenizer.json.
run: cargo test --release -- --skip tokenizer_parity
# Skip the tokenizer parity_matrix test here: ubuntu-latest has
# no HuggingFace cache, so the matrix would hard-fail (see the
# test docstring). The e2e job runs it after pytest populates
# the Qwen3-0.6B tokenizer.json. Filter is matched against the
# full test path `tokenizer::parity::parity_matrix`; substring
# `parity_matrix` is unique to that test.
run: cargo test --release -- --skip parity_matrix
# Regenerate the cross-impl block-hash parity fixture and fail if it
# differs from the committed file. The Python script replicates
@@ -218,11 +284,8 @@ jobs:
sgl-router-docker-build-test:
name: tier-3 — docker (placeholder)
needs: sgl-router-build-and-test
if: |
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-ci')) ||
(github.event.action == 'labeled' && github.event.label.name == 'run-ci')
needs: [sgl-router-gate, sgl-router-build-and-test]
if: needs.sgl-router-gate.outputs.should_run == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
@@ -231,13 +294,16 @@ jobs:
echo "docker-build-test not implemented yet."
exit 0
# tier-3 k8s integration is decoupled from tier-3 e2e: each runs on a
# different runner type (ubuntu kind cluster vs H100), each manages its
# own test scope (k8s_integration/ vs everything-else-in-e2e), and
# each is allowed to fail without blocking the other. They both still
# gate on tier-2's build+test passing — a Rust compile failure blocks
# the GPU/cluster runners from spinning up.
sgl-router-k8s-integration:
name: tier-3 — k8s integration
needs: sgl-router-build-and-test
if: |
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-ci')) ||
(github.event.action == 'labeled' && github.event.label.name == 'run-ci')
needs: [sgl-router-gate, sgl-router-build-and-test]
if: needs.sgl-router-gate.outputs.should_run == 'true'
runs-on: ubuntu-22.04
timeout-minutes: 30
steps:
@@ -267,21 +333,30 @@ jobs:
/tmp/e2e-venv/bin/pip install -r experimental/sgl-router/tests/e2e/k8s_integration/requirements.txt
- name: Run E2E
run: /tmp/e2e-venv/bin/pytest experimental/sgl-router/tests/e2e/k8s_integration/ -v --tb=short
- name: Dump router logs on failure
- name: Dump cluster + router diagnostics on failure
if: failure()
run: kubectl -n sgl-router-test logs deploy/sgl-router --tail=200 || true
run: |
set +e
kubectl -n sgl-router-test get pods -o wide
kubectl -n sgl-router-test describe pod -l app=sgl-router
kubectl -n sgl-router-test describe pod -l app=sglang
kubectl -n sgl-router-test logs deploy/sgl-router --tail=300
kubectl -n sgl-router-test logs deploy/sgl-router --tail=300 --previous
kubectl -n sgl-router-test get events --sort-by=.lastTimestamp
kubectl -n sgl-router-test get endpointslices -o wide
kubectl -n sgl-router-test get svc
true
sgl-router-e2e:
name: tier-3 — e2e
needs: sgl-router-build-and-test
if: |
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-ci')) ||
(github.event.action == 'labeled' && github.event.label.name == 'run-ci')
needs: [sgl-router-gate, sgl-router-build-and-test]
if: needs.sgl-router-gate.outputs.should_run == 'true'
runs-on: 2-gpu-h100
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
run: bash scripts/ci/utils/install_rust_protoc.sh
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
@@ -289,7 +364,9 @@ jobs:
shared-key: "sgl-router-cache"
- name: cargo build (release)
working-directory: experimental/sgl-router
run: cargo build --release
run: |
source "$HOME/.cargo/env"
cargo build --release
# Install SGLang from the local checkout in editable mode (no PyPI
# version pin) — mirrors `pr-test-rust.yml` so the router e2e runs
@@ -306,41 +383,51 @@ jobs:
run: |
python3 -m pip install -r experimental/sgl-router/tests/e2e/requirements.txt
# IMPORTANT: --ignore=tests/e2e/k8s_integration. The k8s integration
# suite is owned by the `sgl-router-k8s-integration` job which has
# kind + kubectl installed and a real cluster running. The e2e GPU
# runner has neither, so pytest's recursive collection of
# tests/e2e/ would ERROR every k8s test at setup. Both tiers must
# be allowed to fail independently — k8s flakes (cluster boot,
# image pull) must not surface as e2e failures on H100, and e2e
# flakes (model load, HF auth) must not surface as k8s failures.
- name: Run e2e
working-directory: experimental/sgl-router
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: python3 -m pytest tests/e2e/ -v -s --tb=short
run: |
python3 -m pytest tests/e2e/ -v -s --tb=short \
--ignore=tests/e2e/k8s_integration
- name: Tokenizer parity matrix (uses HF cache populated by e2e)
working-directory: experimental/sgl-router
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Runs after pytest so the Qwen3-0.6B tokenizer.json is cached by the
# SGLang worker. Cells without a cached snapshot are skipped; if no
# cells could be checked, the test hard-fails under SGLANG_IS_IN_CI.
run: cargo test --release --test component tokenizer::parity
run: |
source "$HOME/.cargo/env"
cargo test --release --test component tokenizer::parity
sgl-router-finish:
name: finish
needs:
- sgl-router-gate
- sgl-router-lint
- sgl-router-build-and-test
- sgl-router-docker-build-test
- sgl-router-k8s-integration
- sgl-router-e2e
# Gate finish on the same label as upstream jobs to avoid false-green on
# unlabeled PRs (skipped needs => success), and fail when any upstream
# job actually failed or was cancelled.
# `always()` lets `finish` run after upstream `skipped` outcomes
# (when the gate decided to skip). The downstream-skipped path is
# an explicit success — the workflow was correctly bypassed for a
# PR that didn't need full CI. The downstream-failure /
# downstream-cancelled path is a real fail. We also accept the
# gate's `skipped` (impossible today, kept for symmetry) and
# require the gate itself to have succeeded.
if: |
always() &&
needs.sgl-router-gate.result == 'success' &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled') &&
(
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-ci')) ||
(github.event.action == 'labeled' && github.event.label.name == 'run-ci')
)
!contains(needs.*.result, 'cancelled')
runs-on: ubuntu-latest
steps:
- name: All required checks completed
+11
View File
@@ -11,9 +11,14 @@ repos:
- id: end-of-file-fixer
- id: check-yaml
args: [--allow-multiple-documents]
# Helm chart templates embed Go template syntax ({{- ... -}})
# that is not valid YAML on its own; the rendered output is
# validated by `helm template` / `helm lint`.
exclude: ^experimental/sgl-router/helm/.*/templates/.*\.(yaml|tpl)$
- id: check-toml
- id: check-ast
- id: check-added-large-files
args: ['--maxkb=1500']
- id: check-merge-conflict
- id: check-shebang-scripts-are-executable
- id: detect-private-key
@@ -107,6 +112,12 @@ repos:
language: system
files: ^sgl-model-gateway/.*\.rs$
pass_filenames: false
- id: rustfmt-sgl-router
name: rustfmt experimental/sgl-router
entry: bash -c 'cd experimental/sgl-router && cargo fmt -- --check'
language: system
files: ^experimental/sgl-router/.*\.rs$
pass_filenames: false
- repo: https://github.com/lycheeverse/lychee.git
rev: lychee-v0.22.0
hooks:
+72
View File
@@ -0,0 +1,72 @@
# Multi-stage build for sgl-router.
#
# Three stages, each scoped to its caching contract:
# 1. chef — generate a `recipe.json` describing the dep graph.
# 2. builder — compile deps from the recipe, then the workspace.
# 3. runtime — distroless cc-debian12 with the stripped binary.
#
# The `cargo-chef` indirection is the canonical Rust multi-stage cache
# pattern: the recipe step's inputs are JUST `Cargo.toml` + `Cargo.lock`,
# so a source-only change produces a recipe-layer cache hit and the
# heavy `cook --release` step is reused untouched. A naive "copy
# manifests → cargo fetch → copy src" approach caches only the fetched
# registry; every source change still recompiles every dep.
#
# Build (from the repo root):
# docker build -f docker/sgl-router.Dockerfile -t sgl-router:dev .
# Run:
# docker run --rm -p 8090:8090 \
# -v $(pwd)/docker/sgl-router.sample.yaml:/etc/sgl-router/sgl-router.yaml \
# sgl-router:dev --config /etc/sgl-router/sgl-router.yaml
#
# Image budget: < 100 MB stripped (M6 acceptance). Verify with
# `docker image inspect sgl-router:dev --format '{{.Size}}'`.
ARG RUST_VERSION=1.90
ARG DEBIAN_VERSION=bookworm
######################## STAGE 1 — chef recipe ##########################
FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef
RUN cargo install cargo-chef --locked --version ^0.1
WORKDIR /work
COPY experimental/sgl-router/Cargo.toml experimental/sgl-router/Cargo.lock ./
COPY experimental/sgl-router/rust-toolchain.toml ./
# Recipe captures the dep graph from Cargo.{toml,lock}; output is
# cacheable per-(Cargo.lock-hash).
RUN mkdir -p src && echo "fn main() {}" > src/main.rs \
&& echo "" > src/lib.rs \
&& cargo chef prepare --recipe-path recipe.json \
&& rm -rf src
######################## STAGE 2 — builder ##############################
FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS builder
RUN cargo install cargo-chef --locked --version ^0.1
WORKDIR /work
COPY --from=chef /work/recipe.json ./recipe.json
COPY experimental/sgl-router/rust-toolchain.toml ./
# Cook (compile + cache) the dep graph from the recipe. This layer's
# inputs are recipe.json + the toolchain — code changes in src/ do NOT
# invalidate it.
RUN cargo chef cook --release --recipe-path recipe.json
# Now bring in the real sources and the manifests they need.
COPY experimental/sgl-router/Cargo.toml experimental/sgl-router/Cargo.lock ./
COPY experimental/sgl-router/src ./src
RUN cargo build --release --locked --bin sgl-router \
&& strip target/release/sgl-router
######################## STAGE 3 — runtime ##############################
FROM gcr.io/distroless/cc-debian12:nonroot AS runtime
COPY --from=builder /work/target/release/sgl-router /usr/local/bin/sgl-router
# Default config path; mount your own via `-v <host-path>:/etc/sgl-router`.
ENV SGL_ROUTER_CONFIG=/etc/sgl-router/sgl-router.yaml
EXPOSE 8090
# distroless `nonroot` runs as uid 65532. The router doesn't need root.
USER nonroot:nonroot
ENTRYPOINT ["/usr/local/bin/sgl-router"]
+3
View File
@@ -0,0 +1,3 @@
target/
*.rs.bk
.DS_Store
+79
View File
@@ -0,0 +1,79 @@
# sgl-router microbench harness + SMG comparison
This file pairs with `experimental/sgl-router/benches/` and the SMG
Criterion harnesses at:
- `~/smg_workspace/smg/model_gateway/benches/radix_tree_benchmark.rs`
- `~/smg_workspace/smg/model_gateway/benches/manual_policy_benchmark.rs`
- `~/smg_workspace/smg/model_gateway/benches/router_registry_bench.rs`
- `sgl-model-gateway/benches/*` (in-tree mirror of SMG, same code)
## Scope
These are CPU-bound microbenches that don't need GPUs — they target
routing-decision latency only. The full E2E throughput comparison
(genai-bench at 4×H200 against a real SGLang fleet) is **not** part of
this file; it requires a real GPU cluster and is tracked separately.
## How to run
sgl-router:
```bash
cd experimental/sgl-router
cargo bench --bench tree_lookup -- --sample-size 30 --measurement-time 3
cargo bench --bench policy_select -- --sample-size 30 --measurement-time 3
```
SMG (the gateway being deprecated):
```bash
cd ~/smg_workspace/smg/model_gateway
cargo bench --bench radix_tree_benchmark -- --sample-size 30 --measurement-time 3 \
'token_match_10w_4096tok|token_insert_10w_4096tok'
cargo bench --bench manual_policy_benchmark
```
For the quick smoke runs whose numbers are reproduced below: drop
`--sample-size` to 10 and `--measurement-time` to 2 (Criterion will
warn about reduced statistical confidence but the order-of-magnitude
comparison stands).
## Smoke-run data points (M1 MacBook, release profile)
These are NOT the real acceptance numbers — they're a sanity check
that the sgl-router routing primitives are in the same ballpark as the
SMG ones they replace. Real targets come from the cluster-scale
comparison and are tracked separately.
### Cache-aware lookup (`HashTree` vs SMG `TokenTree`)
| Bench | sgl-router | SMG TokenTree | Notes |
|---|---|---|---|
| Insert 64 blocks for 1 worker (medium case) | `hashtree_insert/128` ≈ 21.5 µs | `token_insert_10w_4096tok` ≈ 1.05 µs | Numbers not directly comparable — SMG counts per-token insert, sgl-router counts per-block insert. SMG inserts 4096 tokens at a fixed `block_size`; sgl-router inserts 128 pre-hashed `i64` block-hashes. The hashing step (`compute_block_hashes`) is upstream of `HashTree` and not measured here. |
| Match request prefix | `hashtree_match_prefix/w64_bpw128_q64` ≈ 47 ns | `token_match_10w_4096tok` ≈ 1.24 µs | sgl-router's match is a short-circuit walk over `i64` hashes; SMG's match tokenizes + hashes per-call. The fair comparison includes `compute_block_hashes` cost (~ tens of µs depending on prompt length). |
**Read carefully.** The 26× difference at the match step is not the
end-to-end speedup an operator should expect — `compute_block_hashes`
upstream dominates in real traffic. The number proves that sgl-router's
tree walk is no slower than SMG's, which is what the `routing-decision
latency p50 ≤ 1.10× SMG` acceptance criterion targets.
### Policy selection (non-cache-aware)
| Policy | n=4 workers | n=16 | n=64 | n=256 | SMG equivalent |
|---|---|---|---|---|---|
| `round_robin` | 2.5 ns | 2.5 ns | 2.5 ns | 2.5 ns | SMG round-robin is O(1) — same shape. |
| `random` | 16 ns | 36 ns | 137 ns | 471 ns | SMG random is also O(1) per `rand::random()` call; sgl-router's variant grows with n because it `Vec::iter().nth(idx)`. **Action item:** drop sgl-router to O(1) by indexing the slice directly. |
| `power_of_two` | … | … | … | 1.75 µs at n=256 | SMG power-of-two-choices is identical in shape (2× rand + 2× load read). |
The `random` finding (linear in worker count) is a real follow-up — file
an issue and pair it with a Criterion regression-guard in the same
bench.
## Pre-deprecation calibration runbook
Before deleting SMG, every routing-latency metric in the slim-design
spec needs a real-cluster measurement. The bench-harness here is the
small-scale, CPU-only complement; it catches algorithmic regressions in
the routing primitives without burning GPU time. Pair both: this file
in pre-commit / CI tier-2, the real-cluster e2e in the
`pr-test-rust.yml` matrix entry.
+105
View File
@@ -0,0 +1,105 @@
[workspace]
resolver = "2"
members = ["."]
[package]
name = "sgl-router"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
description = "Slim KV-aware OpenAI-compatible router for SGLang workers"
publish = false # binary-only; not published to crates.io
[lib]
name = "sgl_router"
crate-type = ["rlib"]
[[bin]]
name = "sgl-router"
path = "src/main.rs"
[lints.rust]
unused_qualifications = "warn"
[dependencies]
# Dynamo crates — pinned by SHA. Bumps are manual PRs.
dynamo-protocols = { git = "https://github.com/ai-dynamo/dynamo", rev = "1efdd4dcb901caeae636131321094090d252c8d6" }
dynamo-tokenizers = { git = "https://github.com/ai-dynamo/dynamo", rev = "1efdd4dcb901caeae636131321094090d252c8d6" }
dynamo-parsers = { git = "https://github.com/ai-dynamo/dynamo", rev = "1efdd4dcb901caeae636131321094090d252c8d6" }
# Async runtime + http
tokio = { version = "1.42", features = ["full"] }
axum = { version = "0.8", features = ["macros", "tracing"] }
tower = { version = "0.5", features = ["full"] }
tower-http = { version = "0.6", features = ["trace", "compression-gzip", "cors", "timeout", "request-id"] }
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"], default-features = false }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
# `humantime-serde` lets `WorkerConfig.request_timeout` accept human-readable
# durations like `"60s"` / `"500ms"` / `"2m"` in YAML / TOML, rather than
# forcing operators to write raw milliseconds.
humantime-serde = "1"
# Utilities
anyhow = "1"
thiserror = "2"
clap = { version = "4", features = ["derive", "env"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
futures = "0.3"
bytes = "1"
rand = "0.8"
tokio-stream = "0.1"
dashmap = "6"
serde_yaml = "0.9"
toml = "0.8"
kube = { version = "0.96", features = ["runtime", "derive"] }
k8s-openapi = { version = "0.23", features = ["v1_31"] }
tokio-util = "0.7"
uuid = { version = "1", features = ["v4"] }
# KV-event subsystem — msgpack-encoded events over ZMQ and sha256-based
# block hashing matching SGLang's `radix_cache`. Wire format authority is
# `python/sglang/srt/disaggregation/kv_events.py`.
parking_lot = "0.12"
rmp-serde = "1"
sha2 = "0.10"
url = "2"
zeromq = { version = "0.6", default-features = false, features = ["tokio-runtime", "tcp-transport"] }
[dev-dependencies]
dirs = "5"
http-body-util = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tempfile = "3"
tower = { version = "0.5", features = ["util"] }
tokio = { version = "1.42", features = ["test-util"] }
# Low-level msgpack encoder used to hand-construct wire bytes in
# kv_events golden-bytes tests (decode-only path uses rmp-serde).
rmp = "0.8"
# Criterion benches that mirror the SMG `radix_tree_benchmark` +
# `manual_policy_benchmark` so routing-decision latency can be compared
# apples-to-apples against the gateway being deprecated.
criterion = { version = "0.5", features = ["html_reports"] }
rand = "0.8"
[[test]]
name = "component"
path = "tests/component/main.rs"
[[test]]
name = "proxy"
path = "tests/proxy/main.rs"
[[bench]]
name = "tree_lookup"
harness = false
path = "benches/tree_lookup.rs"
[[bench]]
name = "policy_select"
harness = false
path = "benches/policy_select.rs"
+20
View File
@@ -0,0 +1,20 @@
# sgl-router
Slim, KV-aware, OpenAI-compatible router for SGLang workers.
**Status:** functional single-worker HTTP proxy. Exposes `/v1/tokenize`,
`/v1/detokenize`, `/v1/models`, `/v1/chat/completions` (buffered and SSE),
plus `/healthz` / `/readyz`. Forwards to one configured worker via reqwest;
parity-tested against `transformers.AutoTokenizer`. Multi-worker routing,
service discovery, and observability still pending.
## Building
```bash
cd experimental/sgl-router
cargo build --release
```
## License
Apache-2.0.
@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Policy-selection throughput microbench.
//!
//! Mirrors `sgl-model-gateway/benches/manual_policy_benchmark.rs` —
//! measures how fast the routing layer returns a worker for a given
//! request context, across the policies sgl-router actually ships
//! (round-robin, random, power-of-two-choices). The cache-aware-zmq
//! policy lives in `tree_lookup.rs`; this file targets the non-tree
//! policies' steady-state hot path.
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::power_of_two::PowerOfTwoChoicesPolicy;
use sgl_router::policies::random::RandomPolicy;
use sgl_router::policies::round_robin::RoundRobinPolicy;
use sgl_router::policies::{Policy, SelectionContext};
use sgl_router::workers::{Worker, WorkerRegistry};
use std::sync::Arc;
fn workers(n: usize, model: &str) -> Vec<Arc<Worker>> {
let registry = WorkerRegistry::default();
for i in 0..n {
registry
.add(WorkerSpec {
id: WorkerId(format!("w{i}")),
url: format!("http://w{i}:30000"),
mode: WorkerMode::Plain,
model_ids: vec![ModelId(model.into())],
bootstrap_port: None,
})
.expect("test workers are unmixed");
}
registry.workers_for(&ModelId(model.into()))
}
fn bench_policy(c: &mut Criterion, name: &str, policy: Arc<dyn Policy>) {
let mut group = c.benchmark_group(format!("policy_select::{name}"));
for &n in &[4usize, 16, 64, 256] {
let workers = workers(n, "tiny");
let model = ModelId("tiny".into());
// Same body across iterations — measures the policy's per-call
// cost rather than body-parsing overhead.
let body = serde_json::to_vec(&serde_json::json!({
"model": "tiny",
"messages": [{"role": "user", "content": "hello world"}],
}))
.unwrap();
group.throughput(Throughput::Elements(1));
group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| {
b.iter(|| {
let ctx = SelectionContext::new(&model, Some(&body));
let chosen = policy.select(black_box(&workers), &ctx);
black_box(chosen);
});
});
}
group.finish();
}
fn bench_round_robin(c: &mut Criterion) {
bench_policy(c, "round_robin", Arc::new(RoundRobinPolicy::new()));
}
fn bench_random(c: &mut Criterion) {
bench_policy(c, "random", Arc::new(RandomPolicy::new()));
}
fn bench_power_of_two(c: &mut Criterion) {
bench_policy(c, "power_of_two", Arc::new(PowerOfTwoChoicesPolicy::new()));
}
criterion_group!(benches, bench_round_robin, bench_random, bench_power_of_two);
criterion_main!(benches);
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Cache-aware tree-lookup microbench.
//!
//! Mirrors the shape of `sgl-model-gateway/benches/radix_tree_benchmark.rs`
//! (specifically the `TokenTree` / `PositionalIndexer` paths — which serve
//! the same role as sgl-router's `HashTree`). The bench measures:
//!
//! * `insert` — populate one worker's prefix.
//! * `match_prefix` — score an incoming request against the tree.
//!
//! Output is `criterion`'s default (target/criterion/...). To run:
//!
//! cargo bench --bench tree_lookup
//! cargo bench --bench tree_lookup -- --sample-size 30 # faster
//!
//! See `BENCHMARKS.md` for the SMG↔sgl-router comparison table.
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use sgl_router::policies::kv_events::tree::{HashTree, KvWorkerId};
fn build_tree(num_workers: usize, blocks_per_worker: usize, seed: u64) -> HashTree {
let tree = HashTree::new();
let mut rng = StdRng::seed_from_u64(seed);
for w in 0..num_workers {
let worker = KvWorkerId::new(format!("http://w{w}:30000"), 0);
// Each worker holds a distinct (random) prefix so the trees fan
// out — this is the realistic case for cache-aware routing.
let hashes: Vec<i64> = (0..blocks_per_worker).map(|_| rng.gen::<i64>()).collect();
tree.insert(&worker, None, &hashes);
}
tree
}
fn bench_insert(c: &mut Criterion) {
let mut group = c.benchmark_group("hashtree_insert");
for &n_blocks in &[8usize, 32, 128, 512] {
group.throughput(Throughput::Elements(n_blocks as u64));
group.bench_with_input(BenchmarkId::from_parameter(n_blocks), &n_blocks, |b, &n| {
let mut rng = StdRng::seed_from_u64(0xC0FFEE);
let hashes: Vec<i64> = (0..n).map(|_| rng.gen::<i64>()).collect();
b.iter_batched(
HashTree::new,
|tree| {
let worker = KvWorkerId::new("http://w:30000".to_string(), 0);
tree.insert(&worker, None, black_box(&hashes));
tree
},
criterion::BatchSize::SmallInput,
);
});
}
group.finish();
}
fn bench_match_prefix(c: &mut Criterion) {
let mut group = c.benchmark_group("hashtree_match_prefix");
// (workers, blocks_per_worker, query_len) cases that span the
// realistic operating window: small fleet w/ moderate prefixes,
// medium fleet w/ long prefixes, and a stress case.
let cases = [
(4usize, 32usize, 8usize),
(16, 64, 32),
(64, 128, 64),
(128, 256, 128),
];
for (workers, bpw, query_len) in cases {
let label = format!("w{workers}_bpw{bpw}_q{query_len}");
group.throughput(Throughput::Elements(query_len as u64));
let tree = build_tree(workers, bpw, 0xDEADBEEF);
// Pull one real worker's prefix so the query has a non-trivial
// partial match — closer to the production hot path.
let mut rng = StdRng::seed_from_u64(0x12345);
let probe: Vec<i64> = (0..query_len).map(|_| rng.gen::<i64>()).collect();
group.bench_function(label, |b| {
b.iter(|| {
let m = tree.match_prefix(None, black_box(&probe));
black_box(m.matched_blocks)
});
});
}
group.finish();
}
criterion_group!(benches, bench_insert, bench_match_prefix);
criterion_main!(benches);
+75
View File
@@ -0,0 +1,75 @@
[graph]
all-features = true
[advisories]
yanked = "warn"
# Unmaintained advisories are demoted to warnings: the affected crates
# (unic-*, paste, number_prefix) are all transitive through dynamo-parsers
# and have no available upgrades. They pose no security risk; revisit
# if/when dynamo-parsers feature-flags rustpython-parser off upstream.
unmaintained = "none"
# `ignore` left empty — we want to be notified of new CVEs.
ignore = []
[licenses]
allow = [
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"MIT",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-DFS-2016",
"Unicode-3.0",
"Zlib",
"CC0-1.0",
"MPL-2.0",
# Both added per the initial license review:
"NCSA", # libfuzzer-sys (transitive via rav1e) — BSD-equivalent permissive.
"CDLA-Permissive-2.0", # webpki-roots — Linux Foundation permissive license.
]
confidence-threshold = 0.93
# LGPL-3.0-only is accepted on a per-crate exception basis. Rationale:
# - sgl-router is Apache-2.0 and ships full source on the public sglang
# repo, so the LGPL "users must be able to relink" requirement is
# satisfied by the conventional Rust-ecosystem interpretation (anyone
# can git-clone the repo, bump a malachite version, rebuild).
# - The malachite-* crates are pure-Rust arbitrary-precision math, used
# four levels deep through dynamo-parsers → rustpython-parser → malachite-bigint.
# They are NOT on the routing hot path; dynamo-parsers is only wired
# into chat-completions for tool-call parsing.
# - Revisit if/when: (a) a regulated-enterprise customer objects, or
# (b) dynamo-parsers feature-flags rustpython-parser off upstream.
[[licenses.exceptions]]
name = "malachite"
allow = ["LGPL-3.0-only"]
[[licenses.exceptions]]
name = "malachite-base"
allow = ["LGPL-3.0-only"]
[[licenses.exceptions]]
name = "malachite-nz"
allow = ["LGPL-3.0-only"]
[[licenses.exceptions]]
name = "malachite-q"
allow = ["LGPL-3.0-only"]
[[licenses.exceptions]]
name = "malachite-bigint"
allow = ["LGPL-3.0-only"]
[bans]
multiple-versions = "warn"
wildcards = "deny"
# Git deps (e.g. dynamo-* pinned by SHA) have no semver version req and would
# otherwise trip the wildcard check. allow-wildcard-paths exempts non-registry
# (git + path) sources so we only deny bare '*' on crates.io deps.
allow-wildcard-paths = true
[sources]
unknown-registry = "deny"
unknown-git = "allow" # dynamo git dep pinned by SHA in Cargo.toml.
allow-git = ["https://github.com/ai-dynamo/dynamo"]
@@ -0,0 +1,4 @@
[toolchain]
channel = "1.90"
profile = "minimal"
components = ["clippy", "rustfmt"]
+540
View File
@@ -0,0 +1,540 @@
pub mod types;
pub use types::*;
use anyhow::Context as _;
use anyhow::{anyhow, Result};
use std::path::Path;
impl Config {
pub fn from_path(p: &Path) -> Result<Self> {
let raw =
std::fs::read_to_string(p).with_context(|| format!("read config {}", p.display()))?;
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
let cfg: Config = match ext {
"yaml" | "yml" => serde_yaml::from_str(&raw)
.map_err(|e| anyhow!("parse yaml {}: {e}", p.display()))?,
"toml" => {
toml::from_str(&raw).map_err(|e| anyhow!("parse toml {}: {e}", p.display()))?
}
other => {
return Err(anyhow!(
"unsupported config extension {other:?}; want yaml/yml/toml"
))
}
};
cfg.validate()?;
Ok(cfg)
}
fn validate(&self) -> Result<()> {
// Unknown policy names are rejected by serde via `PolicyKind`'s
// `rename_all = "snake_case"`; threshold = 0 is rejected by
// `NonZeroU32`. Only fields without a type-system constraint are
// checked here.
for m in &self.models {
if m.id.is_empty() {
return Err(anyhow!("model.id must be non-empty"));
}
}
match &self.discovery.backend {
DiscoveryBackend::StaticUrls(s) => {
if s.urls.is_empty() {
return Err(anyhow!(
"discovery.static_urls.urls must be a non-empty list"
));
}
// Validate every entry up front so typos surface at
// config-load with a precise diagnostic instead of as
// per-worker introspect failures or as two registry
// entries pointing at the same SGLang (trailing-slash
// near-duplicates). Dedupe runs against a normalized
// form (trimmed + trailing `/` stripped) so
// `"http://x:30000"` and `"http://x:30000/"` collide.
let mut seen = std::collections::HashSet::new();
for raw in &s.urls {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(anyhow!(
"discovery.static_urls.urls contains an empty or whitespace-only entry"
));
}
let parsed = url::Url::parse(trimmed).map_err(|e| {
anyhow!("discovery.static_urls.urls entry {raw:?} is not a valid URL: {e}")
})?;
match parsed.scheme() {
"http" | "https" => {}
other => {
return Err(anyhow!(
"discovery.static_urls.urls entry {raw:?} has unsupported scheme {other:?}; only http and https are supported"
));
}
}
let normalized = parsed.as_str().trim_end_matches('/').to_string();
if !seen.insert(normalized.clone()) {
return Err(anyhow!(
"discovery.static_urls.urls contains duplicate entry {raw:?} (normalized: {normalized:?})"
));
}
}
}
DiscoveryBackend::K8s(k) => {
// Empty namespace is intentional: triggers `Api::all(client)`
// for cluster-wide EndpointSlice watch (see
// `discovery::k8s::spawn`). Only validate the selector
// combination here.
let _ = &k.namespace;
k.mode().map_err(|e| anyhow!("{e}"))?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Write `body` to a temp file with the given extension and load it
/// through `Config::from_path`. Failures still surface the offending
/// config because each call site passes its body inline.
fn load(ext: &str, body: &str) -> Result<Config> {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(format!("c.{ext}"));
std::fs::write(&p, body).unwrap();
Config::from_path(&p)
}
#[test]
fn loads_minimal_yaml() {
let c = load(
"yaml",
r#"
server:
host: "0.0.0.0"
port: 8090
models:
- id: "qwen3-0.6b"
tokenizer_path: "/tmp/qwen.json"
discovery:
backend: static_urls
static_urls:
urls:
- "http://10.0.0.1:30000"
"#,
)
.unwrap();
assert_eq!(c.server.port, 8090);
assert_eq!(c.models[0].id, "qwen3-0.6b");
match &c.discovery.backend {
DiscoveryBackend::StaticUrls(s) => {
assert_eq!(s.urls, vec!["http://10.0.0.1:30000".to_string()])
}
_ => panic!("expected static_urls backend"),
}
}
#[test]
fn loads_minimal_toml() {
let c = load(
"toml",
r#"
[server]
host = "0.0.0.0"
port = 8090
[[models]]
id = "qwen3-0.6b"
tokenizer_path = "/tmp/qwen.json"
[discovery]
backend = "static_urls"
[discovery.static_urls]
urls = ["http://10.0.0.1:30000"]
"#,
)
.unwrap();
assert_eq!(c.server.port, 8090);
match &c.discovery.backend {
DiscoveryBackend::StaticUrls(s) => {
assert_eq!(s.urls, vec!["http://10.0.0.1:30000".to_string()])
}
_ => panic!("expected static_urls backend"),
}
}
#[test]
fn rejects_missing_discovery_section() {
let err = load(
"yaml",
"server:\n host: \"0.0.0.0\"\n port: 8090\nmodels: []\n",
)
.unwrap_err();
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("discovery") || msg.contains("missing"),
"got: {err}"
);
}
#[test]
fn rejects_unknown_extension() {
let err = load("txt", "").unwrap_err();
assert!(err.to_string().contains("yaml") && err.to_string().contains("toml"));
}
#[test]
fn loads_static_urls_discovery() {
let c = load(
"toml",
r#"
[server]
host = "127.0.0.1"
port = 8090
[[models]]
id = "qwen3-0.6b"
tokenizer_path = "/tmp/qwen.json"
policy = "round_robin"
[discovery]
backend = "static_urls"
[discovery.static_urls]
urls = ["http://10.0.0.1:30000", "http://10.0.0.2:30000"]
"#,
)
.unwrap();
match &c.discovery.backend {
DiscoveryBackend::StaticUrls(s) => {
assert_eq!(
s.urls,
vec![
"http://10.0.0.1:30000".to_string(),
"http://10.0.0.2:30000".to_string(),
],
);
}
_ => panic!("expected static_urls backend"),
}
assert_eq!(c.models[0].policy, PolicyKind::RoundRobin);
}
#[test]
fn rejects_static_urls_with_empty_list() {
let err = load(
"toml",
r#"
[server]
host = "127.0.0.1"
port = 8090
[[models]]
id = "m"
tokenizer_path = "/tmp/qwen.json"
[discovery]
backend = "static_urls"
[discovery.static_urls]
urls = []
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("non-empty"), "got: {err}");
}
#[test]
fn rejects_static_urls_with_duplicate_entry() {
let err = load(
"toml",
r#"
[server]
host = "127.0.0.1"
port = 8090
[[models]]
id = "m"
tokenizer_path = "/tmp/qwen.json"
[discovery]
backend = "static_urls"
[discovery.static_urls]
urls = ["http://x:30000", "http://x:30000"]
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("duplicate"), "got: {err}");
}
#[test]
fn rejects_static_urls_with_empty_entry() {
let err = load(
"toml",
r#"
[server]
host = "127.0.0.1"
port = 8090
[[models]]
id = "m"
tokenizer_path = "/tmp/qwen.json"
[discovery]
backend = "static_urls"
[discovery.static_urls]
urls = ["http://x:30000", ""]
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("empty"), "got: {err}");
}
/// Whitespace-only entries are user typos that previously slipped
/// through `is_empty()` checks and surfaced as "introspect against
/// ` /server_info` failed" at runtime. Catch at load.
#[test]
fn rejects_static_urls_with_whitespace_only_entry() {
let err = load(
"toml",
r#"
[server]
host = "127.0.0.1"
port = 8090
[[models]]
id = "m"
tokenizer_path = "/tmp/qwen.json"
[discovery]
backend = "static_urls"
[discovery.static_urls]
urls = ["http://x:30000", " "]
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("whitespace"), "got: {err}");
}
/// `"10.0.0.1:30000"` (missing scheme) used to pass validation; the
/// scheme/`http://` would only fail (or worse, silently degrade
/// because of the `parse_bootstrap_host` localhost fallback) at
/// introspect time. Reject at load.
#[test]
fn rejects_static_urls_with_schemeless_entry() {
let err = load(
"toml",
r#"
[server]
host = "127.0.0.1"
port = 8090
[[models]]
id = "m"
tokenizer_path = "/tmp/qwen.json"
[discovery]
backend = "static_urls"
[discovery.static_urls]
urls = ["10.0.0.1:30000"]
"#,
)
.unwrap_err()
.to_string();
assert!(
err.contains("not a valid URL") || err.contains("unsupported scheme"),
"got: {err}"
);
}
/// Non-http(s) schemes are rejected. The router speaks HTTP to
/// workers; a `tcp://` or `ws://` entry is almost certainly an
/// operator typo.
#[test]
fn rejects_static_urls_with_non_http_scheme() {
let err = load(
"toml",
r#"
[server]
host = "127.0.0.1"
port = 8090
[[models]]
id = "m"
tokenizer_path = "/tmp/qwen.json"
[discovery]
backend = "static_urls"
[discovery.static_urls]
urls = ["ws://x:30000"]
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("unsupported scheme"), "got: {err}");
}
/// Trailing-slash near-duplicates collide in the registry but used
/// to pass byte-equality dedupe. Normalize before checking so two
/// pointers at the same SGLang surface as a config error.
#[test]
fn rejects_static_urls_with_trailing_slash_near_duplicate() {
let err = load(
"toml",
r#"
[server]
host = "127.0.0.1"
port = 8090
[[models]]
id = "m"
tokenizer_path = "/tmp/qwen.json"
[discovery]
backend = "static_urls"
[discovery.static_urls]
urls = ["http://x:30000", "http://x:30000/"]
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("duplicate"), "got: {err}");
}
#[test]
fn loads_k8s_discovery() {
let c = load(
"toml",
r#"
[server]
host = "127.0.0.1"
port = 8090
[[models]]
id = "qwen3-0.6b"
tokenizer_path = "/tmp/qwen.json"
policy = "round_robin"
[discovery]
backend = "k8s"
[discovery.k8s]
namespace = "default"
label_selector = "app=sglang"
"#,
)
.unwrap();
match &c.discovery.backend {
DiscoveryBackend::K8s(k) => {
assert_eq!(k.namespace, "default");
assert_eq!(k.label_selector.as_deref(), Some("app=sglang"));
assert!(k.prefill_selector.is_none());
assert!(k.decode_selector.is_none());
}
_ => panic!("expected k8s backend"),
}
}
/// K8s PD selectors drive slice-classification only; per-worker
/// bootstrap_port comes from `/server_info` post-discovery
/// (`crate::workers::introspect`). This test pins the wire-shape;
/// the selector grammar itself is covered in `types.rs`.
#[test]
fn loads_k8s_pd_discovery_with_prefill_and_decode_selectors() {
let c = load(
"toml",
r#"
[server]
host = "127.0.0.1"
port = 8090
[[models]]
id = "qwen3-0.6b"
tokenizer_path = "/tmp/qwen.json"
[discovery]
backend = "k8s"
[discovery.k8s]
namespace = "default"
prefill_selector = "app=sglang,role=prefill"
decode_selector = "app=sglang,role=decode"
"#,
)
.expect("k8s PD config must load");
match &c.discovery.backend {
DiscoveryBackend::K8s(k) => {
assert_eq!(k.namespace, "default");
assert_eq!(
k.prefill_selector.as_deref(),
Some("app=sglang,role=prefill")
);
assert_eq!(k.decode_selector.as_deref(), Some("app=sglang,role=decode"));
assert!(k.label_selector.is_none());
}
_ => panic!("expected k8s backend"),
}
}
#[test]
fn rejects_k8s_config_with_no_selector() {
let err = load(
"toml",
r#"
[server]
host = "127.0.0.1"
port = 8090
[[models]]
id = "qwen"
tokenizer_path = "/tmp/qwen.json"
[discovery]
backend = "k8s"
[discovery.k8s]
namespace = "default"
"#,
)
.unwrap_err();
// Pin the specific variant: `ConfigError::NoSelector` ("none were
// set"). A bare `contains("selector")` would also pass for
// EmptyPdSelector / PartialPdSelectors / IdenticalPdSelectors /
// UnsupportedSelectorGrammar — variants that have semantically
// different error wording but all mention "selector". A future
// regression that returned, say, `PartialPdSelectors` for the
// all-None input would be caught here.
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("none were set"),
"expected NoSelector wording (\"none were set\"); got: {err}",
);
}
// Direct `K8sDiscoveryConfig::mode()` unit tests live alongside the
// type in `src/config/types.rs::k8s_discovery_config_tests`.
// The tests in this module exercise the `Config::from_path` ↔ K8s
// selector wiring, not the selector grammar itself.
#[test]
fn rejects_unknown_policy_name() {
let err = load(
"yaml",
"
server:
host: 0.0.0.0
port: 8090
discovery:
backend: static_urls
static_urls:
urls:
- http://x:30000
models:
- id: qwen
tokenizer_path: /tmp/qwen.json
policy: bogus_policy
",
)
.unwrap_err();
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("bogus_policy") || msg.contains("policy"),
"got: {err}"
);
}
#[test]
fn defaults_policy_to_round_robin() {
let c = load(
"toml",
r#"
[server]
host = "127.0.0.1"
port = 8090
[[models]]
id = "qwen"
tokenizer_path = "/tmp/qwen.json"
[discovery]
backend = "static_urls"
[discovery.static_urls]
urls = ["http://x:30000"]
"#,
)
.unwrap();
assert_eq!(c.models[0].policy, PolicyKind::RoundRobin);
}
}
+919
View File
@@ -0,0 +1,919 @@
use serde::{Deserialize, Serialize};
use std::num::NonZeroU32;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub server: ServerConfig,
#[serde(default)]
pub observability: ObservabilityConfig,
pub models: Vec<ModelConfig>,
pub discovery: DiscoveryConfig,
#[serde(default)]
pub proxy: ProxyConfig,
#[serde(default)]
pub active_load: ActiveLoadConfig,
}
/// Outbound proxy tuning. Default mirrors SGLang's typical prefill /
/// decode latency budget; e2e tests lower it so per-request failures
/// trip the circuit breaker within the test's wall-time.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct ProxyConfig {
/// Maximum time to wait for a single upstream HTTP request to
/// return headers + body. Default 300 s. The circuit breaker
/// records a failure when this fires.
#[serde(default = "default_proxy_request_timeout_secs")]
pub request_timeout_secs: u64,
}
fn default_proxy_request_timeout_secs() -> u64 {
300
}
impl Default for ProxyConfig {
fn default() -> Self {
Self {
request_timeout_secs: default_proxy_request_timeout_secs(),
}
}
}
/// Active-load (per-request) tracking. Production default (10 min)
/// sits above `proxy.request_timeout_secs` so the proxy timeout is the
/// one users hit first for normal slow upstreams; tests lower it to
/// let the janitor fire within their wall-time budget.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct ActiveLoadConfig {
/// How long a request entry can live in the registry before the
/// janitor fires its `cancel_token` and the chat handler returns
/// 504 `stale_request_expired`. Default 600 s.
#[serde(default = "default_stale_request_timeout_secs")]
pub stale_request_timeout_secs: u64,
}
fn default_stale_request_timeout_secs() -> u64 {
600
}
impl Default for ActiveLoadConfig {
fn default() -> Self {
Self {
stale_request_timeout_secs: default_stale_request_timeout_secs(),
}
}
}
/// Routing policy selector — the enum form lets serde reject unknown
/// values at deserialization time and removes the runtime string match in
/// the policy factory.
///
/// Serialised as `"round_robin"` / `"random"` / `"power_of_two"` /
/// `"cache_aware_zmq"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PolicyKind {
#[default]
RoundRobin,
Random,
PowerOfTwo,
/// Cache-aware routing fed by SGLang's ZMQ KV-cache event publisher.
/// Requires the model to have a tokenizer loaded; cache_aware tuning
/// lives on `ModelConfig::cache_aware`.
CacheAwareZmq,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObservabilityConfig {
#[serde(default = "default_log_level")]
pub log_level: String,
/// Selects the tracing-subscriber output format. Serde rejects
/// unrecognized values at config-load (`"jsonl"` and similar
/// plausible typos surface as an error instead of silently
/// degrading to text), matching the discoverability pattern used
/// by `policy` and `discovery.backend`.
#[serde(default)]
pub log_format: LogFormat,
}
/// `text` for human-readable dev output, `json` for one-line-per-record
/// JSON suitable for k8s log aggregators (fluent-bit / vector / Loki).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
#[default]
Text,
Json,
}
fn default_log_level() -> String {
"info".to_string()
}
impl Default for ObservabilityConfig {
fn default() -> Self {
Self {
log_level: default_log_level(),
log_format: LogFormat::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelConfig {
pub id: String,
pub tokenizer_path: String,
#[serde(default)]
pub policy: PolicyKind,
#[serde(default)]
pub circuit_breaker: Option<CircuitBreakerConfig>,
/// Tuning for the cache-aware ZMQ policy. Ignored unless
/// `policy = "cache_aware_zmq"`. `None` falls back to defaults at
/// policy construction time.
#[serde(default)]
pub cache_aware: Option<CacheAwareConfig>,
}
/// Per-model cache-aware-ZMQ tuning.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct CacheAwareConfig {
/// Lower bound on `matched_blocks / total_blocks` for the tree match
/// to win the selection. Below this, the policy falls back to
/// min-load. Default 0.5 — a half-cached prompt is still a strong
/// signal but not so weak that random hash collisions could trigger
/// affinity to an arbitrary worker.
#[serde(default = "default_cache_threshold")]
pub cache_threshold: f32,
/// Absolute load spread (`max - min`) above which the cache check is
/// skipped in favour of min-load. Default 32 — picked to dominate
/// over typical batch-of-8 effect.
#[serde(default = "default_balance_abs")]
pub balance_abs_threshold: usize,
/// Multiplicative load spread (`max > min * balance_rel_threshold`)
/// that the absolute check is gated on. Default 1.1 — 10 % relative
/// difference triggers re-balancing.
#[serde(default = "default_balance_rel")]
pub balance_rel_threshold: f32,
}
impl Default for CacheAwareConfig {
fn default() -> Self {
Self {
cache_threshold: default_cache_threshold(),
balance_abs_threshold: default_balance_abs(),
balance_rel_threshold: default_balance_rel(),
}
}
}
fn default_cache_threshold() -> f32 {
0.5
}
fn default_balance_abs() -> usize {
32
}
fn default_balance_rel() -> f32 {
1.1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CircuitBreakerConfig {
/// Consecutive failures required before the breaker opens. Encoded
/// as `NonZeroU32` so a config setting `threshold = 0` (which would
/// open the breaker before any failure) is rejected at deserialization
/// rather than silently behaving as "always open".
#[serde(default = "default_cb_threshold")]
pub threshold: NonZeroU32,
#[serde(default = "default_cb_cool_down")]
pub cool_down_secs: u64,
}
fn default_cb_threshold() -> NonZeroU32 {
NonZeroU32::new(3).unwrap()
}
fn default_cb_cool_down() -> u64 {
30
}
/// Config-level discovery section. Deserialized from:
///
/// TOML:
/// ```toml
/// [discovery]
/// backend = "static_urls"
/// [discovery.static_urls]
/// urls = ["http://10.0.0.1:30000", "http://10.0.0.2:30000"]
/// ```
///
/// YAML:
/// ```yaml
/// discovery:
/// backend: static_urls
/// static_urls:
/// urls:
/// - http://10.0.0.1:30000
/// - http://10.0.0.2:30000
/// ```
///
/// The custom `Deserialize` impl on [`DiscoveryConfig`] converts the
/// raw fields into the resolved `DiscoveryBackend` enum via `try_from`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveryConfigRaw {
pub backend: String,
pub static_urls: Option<StaticUrlsDiscoveryConfig>,
pub k8s: Option<K8sDiscoveryConfig>,
}
/// Post-validation discovery config with a resolved `DiscoveryBackend` enum.
/// Constructed by `Config::from_path` after `validate()`.
#[derive(Debug, Clone)]
pub struct DiscoveryConfig {
pub backend: DiscoveryBackend,
}
impl<'de> Deserialize<'de> for DiscoveryConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = DiscoveryConfigRaw::deserialize(deserializer)?;
raw.try_into().map_err(serde::de::Error::custom)
}
}
impl Serialize for DiscoveryConfig {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let raw: DiscoveryConfigRaw = self.clone().into();
raw.serialize(serializer)
}
}
impl TryFrom<DiscoveryConfigRaw> for DiscoveryConfig {
type Error = String;
fn try_from(raw: DiscoveryConfigRaw) -> Result<Self, Self::Error> {
let backend = match raw.backend.as_str() {
"static_urls" => {
let s = raw.static_urls.ok_or(
"discovery.backend = \"static_urls\" requires [discovery.static_urls] section",
)?;
DiscoveryBackend::StaticUrls(s)
}
"k8s" => {
let k = raw
.k8s
.ok_or("discovery.backend = \"k8s\" requires [discovery.k8s] section")?;
DiscoveryBackend::K8s(k)
}
other => {
return Err(format!(
"unknown discovery.backend = {other:?}; valid: \"static_urls\", \"k8s\""
))
}
};
Ok(DiscoveryConfig { backend })
}
}
impl From<DiscoveryConfig> for DiscoveryConfigRaw {
fn from(cfg: DiscoveryConfig) -> Self {
match cfg.backend {
DiscoveryBackend::StaticUrls(s) => DiscoveryConfigRaw {
backend: "static_urls".to_string(),
static_urls: Some(s),
k8s: None,
},
DiscoveryBackend::K8s(k) => DiscoveryConfigRaw {
backend: "k8s".to_string(),
static_urls: None,
k8s: Some(k),
},
}
}
}
#[derive(Debug, Clone)]
pub enum DiscoveryBackend {
StaticUrls(StaticUrlsDiscoveryConfig),
K8s(K8sDiscoveryConfig),
}
/// Fixed list of worker URLs. Each URL is registered once at startup;
/// `mode`, `model_ids`, and `bootstrap_port` are resolved per-worker
/// from `/server_info` (see [`crate::workers::introspect`]).
///
/// No file watcher, no hot-reload: topology change requires a restart.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaticUrlsDiscoveryConfig {
pub urls: Vec<String>,
}
/// Configuration for the Kubernetes `EndpointSlice` discovery backend.
///
/// Two operating modes, distinguished by which selector fields are set:
///
/// 1. **Plain** — all matched workers share the same role:
/// ```toml
/// [discovery.k8s]
/// namespace = "default"
/// label_selector = "app=sglang"
/// ```
///
/// 2. **PD disaggregation** — prefill and decode workers are separated by
/// different selectors:
/// ```toml
/// [discovery.k8s]
/// namespace = "default"
/// prefill_selector = "app=sglang,role=prefill"
/// decode_selector = "app=sglang,role=decode"
/// ```
///
/// In PD mode, the selectors drive **slice-classification** (which
/// EndpointSlices feed the prefill pool vs the decode pool). The actual
/// `WorkerMode` and `bootstrap_port` for each worker are filled in by
/// the worker manager from each worker's `/server_info` introspection,
/// so PD works without any pod-level annotations — see
/// [`crate::workers::introspect`] for the `disaggregation_mode` and
/// `disaggregation_bootstrap_port` extraction.
///
/// `mode()` validates the combination and returns the resolved
/// [`K8sDiscoveryMode`]; any other selector combination is rejected.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct K8sDiscoveryConfig {
pub namespace: String,
#[serde(default)]
pub label_selector: Option<String>,
#[serde(default)]
pub prefill_selector: Option<String>,
#[serde(default)]
pub decode_selector: Option<String>,
}
/// Resolved discovery mode derived from a [`K8sDiscoveryConfig`].
///
/// The discovery backend uses this to:
/// * pick the server-side `LIST` label selector (Plain: the single selector;
/// PD: empty, with classification done client-side per slice), and
/// * assign each `EndpointSlice` a [`crate::discovery::WorkerMode`] in
/// `extract_workers`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum K8sDiscoveryMode {
/// One global label selector; every matched EndpointSlice becomes a
/// `WorkerMode::Plain` worker.
Plain { label_selector: String },
/// Two label selectors; an EndpointSlice's labels are matched against
/// each to classify it as `WorkerMode::Prefill` or `WorkerMode::Decode`.
PdDisaggregation {
prefill_selector: String,
decode_selector: String,
},
}
/// Error returned by [`K8sDiscoveryConfig::mode`] when the selector
/// combination is invalid.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("discovery.k8s requires either `label_selector` (plain) or both `prefill_selector` and `decode_selector` (PD); none were set")]
NoSelector,
#[error("discovery.k8s: `label_selector` (plain) and `prefill_selector`/`decode_selector` (PD) are mutually exclusive — set one or the other, not both")]
MixedModes,
#[error("discovery.k8s: PD mode requires BOTH `prefill_selector` and `decode_selector`")]
PartialPdSelectors,
#[error(
"discovery.k8s: {selector}_selector `{value}` uses unsupported syntax — \
only equality terms (`key=value` or `key==value`) joined by `,` are accepted. \
Set-based operators (`in`, `notin`), presence tests, and `!=` silently match \
zero endpoints at runtime and are rejected at config-load time."
)]
UnsupportedSelectorGrammar {
selector: &'static str,
value: String,
},
#[error(
"discovery.k8s: PD `{selector}_selector` is empty (or only whitespace/commas) — \
it would match every EndpointSlice, and since classify_mode checks prefill before \
decode, the opposite role's pool would stay empty. Set non-empty equality terms \
distinguishing the two roles."
)]
EmptyPdSelector { selector: &'static str },
#[error(
"discovery.k8s: `prefill_selector` and `decode_selector` resolve to the same set \
of equality terms — classify_mode would tag every matching slice as Prefill and \
leave the decode pool empty. The two selectors must differ."
)]
IdenticalPdSelectors,
}
/// Returns `true` when `selector` has zero non-empty terms after
/// trimming and splitting on `,`. `labels_match_selector` then returns
/// `true` for every label set, which is the "matches everything"
/// degenerate case PD mode must reject.
fn is_selector_empty(selector: &str) -> bool {
selector.split(',').all(|t| t.trim().is_empty())
}
/// Canonicalize a comma-separated equality selector to a sorted list of
/// parsed `(key, value)` tuples. Comparison happens at the parsed-term
/// level — *not* the raw string level — because `labels_match_selector`
/// already strips whitespace and treats `key=value` and `key==value` as
/// the same equality test. Comparing raw strings would let
/// `"app=sglang"` vs `"app==sglang"` (and `"app = sglang"` vs
/// `"app=sglang"`) past the identical-selector check, even though
/// `classify_mode` would treat them identically at runtime — exactly
/// the silent decode-pool-empty failure mode this check exists to
/// prevent.
///
/// Returns an empty `Vec` for selectors with no parseable terms
/// (whitespace-only, comma-only, or any term that doesn't match the
/// `key=value` / `key==value` grammar). Callers must run
/// [`is_equality_selector`] before this to surface malformed
/// selectors as `UnsupportedSelectorGrammar`.
fn canonical_selector(selector: &str) -> Vec<(String, String)> {
let mut terms: Vec<(String, String)> = selector
.split(',')
.filter_map(|raw| {
let term = raw.trim();
if term.is_empty() {
return None;
}
// Mirror `labels_match_selector`: prefer the `==` alias so a
// term like `key==value` parses to `(key, value)` instead of
// `(key, =value)`.
let (k, v) = term.split_once("==").or_else(|| term.split_once('='))?;
Some((k.trim().to_string(), v.trim().to_string()))
})
.collect();
terms.sort();
terms
}
/// Returns `true` when `selector` parses as a comma-separated equality
/// selector — every term has the shape `key=value` or `key==value`.
/// See [`ConfigError::UnsupportedSelectorGrammar`] for rationale.
fn is_equality_selector(selector: &str) -> bool {
for term in selector.split(',') {
let term = term.trim();
if term.is_empty() {
// Treat lone trailing commas / whitespace as fine; the runtime
// splitter ignores empty terms.
continue;
}
if let Some((k, _)) = term.split_once("==") {
if k.trim().is_empty() {
return false;
}
continue;
}
if let Some((k, _value)) = term.split_once('=') {
// Reject `!=` (rendered as `key!` + `=value` by split_once).
// Empty value is legal in K8s — `label_selector = "tier="`
// matches pods with `tier=""` — so we don't constrain it.
if k.trim().is_empty() || k.trim().ends_with('!') {
return false;
}
continue;
}
// No `=` at all → set-based operator, presence test, or garbage.
return false;
}
true
}
impl K8sDiscoveryConfig {
/// Validate the selector combination and return the resolved mode.
pub fn mode(&self) -> Result<K8sDiscoveryMode, ConfigError> {
let plain = self.label_selector.as_deref();
let prefill = self.prefill_selector.as_deref();
let decode = self.decode_selector.as_deref();
match (plain, prefill, decode) {
(Some(label), None, None) => {
// Plain mode pushes `label` to the K8s API as the
// server-side `labelSelector` of the EndpointSlice
// watcher (`watcher::Config::default().labels(&label)`
// in `discovery::k8s::spawn`). K8s itself parses the
// full label-selector grammar — equality, set-based
// (`in` / `notin`), presence (`key` / `!key`), and
// `!=` — and rejects malformed selectors at
// watch-start time. So at config-load we don't
// grammar-check `label` and let the K8s API be the
// syntax authority (README.md:25 and the multi-model
// e2e in tests/e2e/k8s_integration/test_multi_model.py
// depend on this). PD mode, in contrast, evaluates
// selectors client-side via `labels_match_selector`
// which only understands equality — so PD selectors
// are still grammar-checked below.
Ok(K8sDiscoveryMode::Plain {
label_selector: label.to_string(),
})
}
(None, Some(prefill), Some(decode)) => {
// Both selectors validated individually so the operator
// sees which one is malformed. WorkerMode + bootstrap_port
// for each prefill pod are filled in by the worker
// manager from each worker's `/server_info` — these
// selectors only drive client-side classification per
// EndpointSlice (see `classify_mode` in discovery/k8s.rs).
if !is_equality_selector(prefill) {
return Err(ConfigError::UnsupportedSelectorGrammar {
selector: "prefill",
value: prefill.to_string(),
});
}
if !is_equality_selector(decode) {
return Err(ConfigError::UnsupportedSelectorGrammar {
selector: "decode",
value: decode.to_string(),
});
}
// Empty PD selector matches every EndpointSlice at
// runtime; combined with classify_mode's prefill-first
// ordering, an empty selector would silently funnel all
// workers into one role. Reject up front.
if is_selector_empty(prefill) {
return Err(ConfigError::EmptyPdSelector {
selector: "prefill",
});
}
if is_selector_empty(decode) {
return Err(ConfigError::EmptyPdSelector { selector: "decode" });
}
// Identical selectors degrade the same way as an empty
// one: every slice matches both, prefill wins, decode
// stays empty.
if canonical_selector(prefill) == canonical_selector(decode) {
return Err(ConfigError::IdenticalPdSelectors);
}
Ok(K8sDiscoveryMode::PdDisaggregation {
prefill_selector: prefill.to_string(),
decode_selector: decode.to_string(),
})
}
(None, None, None) => Err(ConfigError::NoSelector),
(None, Some(_), None) | (None, None, Some(_)) => Err(ConfigError::PartialPdSelectors),
(Some(_), _, _) => Err(ConfigError::MixedModes),
}
}
}
#[cfg(test)]
mod k8s_discovery_config_tests {
use super::*;
fn cfg(plain: Option<&str>, prefill: Option<&str>, decode: Option<&str>) -> K8sDiscoveryConfig {
K8sDiscoveryConfig {
namespace: "ns".to_string(),
label_selector: plain.map(str::to_string),
prefill_selector: prefill.map(str::to_string),
decode_selector: decode.map(str::to_string),
}
}
#[test]
fn mode_constructs_pd_disaggregation_from_prefill_and_decode_selectors() {
// K8s PD now works without per-pod annotations: each worker's
// `/server_info` carries `disaggregation_bootstrap_port`, and the
// worker manager applies it post-discovery. The K8s config layer's
// job is just to validate the selector combination.
let m = cfg(None, Some("app=sglang,role=p"), Some("app=sglang,role=d"))
.mode()
.expect("PD mode is now valid");
assert_eq!(
m,
K8sDiscoveryMode::PdDisaggregation {
prefill_selector: "app=sglang,role=p".to_string(),
decode_selector: "app=sglang,role=d".to_string(),
}
);
}
#[test]
fn mode_pd_rejects_set_based_prefill_selector() {
// Both PD selectors get the same equality-only grammar check as
// the plain label_selector. A set-based prefill selector would
// silently match zero pods at runtime → fail-fast at load.
let err = cfg(None, Some("app in (sglang, vllm)"), Some("app=sglang"))
.mode()
.unwrap_err();
assert!(
matches!(
err,
ConfigError::UnsupportedSelectorGrammar {
selector: "prefill",
..
},
),
"expected UnsupportedSelectorGrammar(prefill), got {err:?}",
);
}
#[test]
fn mode_pd_rejects_set_based_decode_selector() {
let err = cfg(None, Some("app=sglang"), Some("app in (sglang, vllm)"))
.mode()
.unwrap_err();
assert!(
matches!(
err,
ConfigError::UnsupportedSelectorGrammar {
selector: "decode",
..
},
),
"expected UnsupportedSelectorGrammar(decode), got {err:?}",
);
}
#[test]
fn mode_accepts_plain_with_equality_selector() {
let m = cfg(Some("app=sglang"), None, None).mode().unwrap();
assert_eq!(
m,
K8sDiscoveryMode::Plain {
label_selector: "app=sglang".to_string()
}
);
}
/// Plain mode pushes its selector to the K8s API server-side
/// (`watcher::Config::default().labels(&selector)` in
/// `discovery::k8s::spawn`), so the full K8s label-selector grammar
/// — including set-based operators — is supported. README.md:25
/// advertises this, and `tests/e2e/k8s_integration/test_multi_model.py`
/// relies on it (`label_selector = "app in (sglang,sglang-small)"`).
/// Rejecting set-based selectors at config-load broke the documented
/// multi-model k8s path.
#[test]
fn mode_accepts_set_based_selector_in_plain_mode() {
let m = cfg(Some("app in (sglang,sglang-small)"), None, None)
.mode()
.expect("plain mode must accept set-based selectors");
assert_eq!(
m,
K8sDiscoveryMode::Plain {
label_selector: "app in (sglang,sglang-small)".to_string(),
}
);
}
/// `notin`, presence (`key`), absence (`!key`), and inequality (`!=`)
/// are all valid K8s server-side selector grammar — plain mode must
/// pass them through.
#[test]
fn mode_accepts_other_set_based_forms_in_plain_mode() {
for raw in [
"app notin (vllm,trtllm)",
"tier",
"!deprecated",
"tier!=canary",
] {
let m = cfg(Some(raw), None, None)
.mode()
.unwrap_or_else(|e| panic!("plain mode must accept `{raw}`, got {e:?}"));
assert_eq!(
m,
K8sDiscoveryMode::Plain {
label_selector: raw.to_string(),
},
"selector roundtrip mismatch for `{raw}`",
);
}
}
/// PD mode evaluates selectors *client-side* via
/// `labels_match_selector`, which only handles equality. A set-based
/// PD selector would silently match zero pods → fail-fast at load.
/// Pins the plain-server-side / PD-client-side asymmetry: relaxing
/// the grammar check for plain (see `mode_accepts_set_based_*`
/// above) must not accidentally relax it for PD selectors. Uses
/// `notin` so this test covers a different set-based form than
/// `mode_pd_rejects_set_based_prefill_selector` (which uses `in`)
/// — both must keep failing.
#[test]
fn mode_pd_rejects_notin_prefill_selector() {
let err = cfg(None, Some("app notin (vllm, trtllm)"), Some("app=sglang"))
.mode()
.unwrap_err();
assert!(
matches!(
err,
ConfigError::UnsupportedSelectorGrammar {
selector: "prefill",
..
},
),
"expected UnsupportedSelectorGrammar(prefill), got {err:?}",
);
}
#[test]
fn mode_accepts_comma_separated_equality_terms() {
// The canonical Plain-mode selector form: `key1=v1,key2=v2`.
let m = cfg(Some("app=sglang,zone=us-east"), None, None)
.mode()
.unwrap();
assert_eq!(
m,
K8sDiscoveryMode::Plain {
label_selector: "app=sglang,zone=us-east".to_string()
}
);
}
#[test]
fn mode_rejects_when_no_selector_is_set() {
let err = cfg(None, None, None).mode().unwrap_err();
assert!(matches!(err, ConfigError::NoSelector), "got {err:?}");
}
#[test]
fn mode_rejects_mixed_plain_and_pd_selectors() {
let err = cfg(
Some("app=sglang"),
Some("role=prefill"),
Some("role=decode"),
)
.mode()
.unwrap_err();
assert!(matches!(err, ConfigError::MixedModes), "got {err:?}");
}
#[test]
fn mode_rejects_partial_pd_selectors() {
let err = cfg(None, Some("role=prefill"), None).mode().unwrap_err();
assert!(
matches!(err, ConfigError::PartialPdSelectors),
"got {err:?}"
);
let err = cfg(None, None, Some("role=decode")).mode().unwrap_err();
assert!(
matches!(err, ConfigError::PartialPdSelectors),
"got {err:?}"
);
}
/// Empty plain `label_selector` is valid — matches every
/// EndpointSlice in the namespace (documented K8s behavior; the
/// operator opts in by setting plain mode at all).
#[test]
fn mode_accepts_empty_plain_label_selector() {
let m = cfg(Some(""), None, None).mode().unwrap();
assert_eq!(
m,
K8sDiscoveryMode::Plain {
label_selector: String::new()
}
);
}
/// PD mode is the *opposite* of plain: an empty selector would match
/// every EndpointSlice, and since `classify_mode` checks prefill
/// before decode, an empty `prefill_selector` would classify
/// everything as Prefill — decode pool stays empty and the resolver
/// surfaces the wrong `no_decode_workers_available` error. Fail-fast
/// at config load.
#[test]
fn mode_pd_rejects_empty_prefill_selector() {
let err = cfg(None, Some(""), Some("role=decode")).mode().unwrap_err();
assert!(
matches!(
err,
ConfigError::EmptyPdSelector {
selector: "prefill"
},
),
"expected EmptyPdSelector(prefill), got {err:?}",
);
}
#[test]
fn mode_pd_rejects_empty_decode_selector() {
let err = cfg(None, Some("role=prefill"), Some(""))
.mode()
.unwrap_err();
assert!(
matches!(err, ConfigError::EmptyPdSelector { selector: "decode" },),
"expected EmptyPdSelector(decode), got {err:?}",
);
}
/// Whitespace-only / comma-only PD selector parses to zero terms in
/// `labels_match_selector` and matches every slice at runtime — same
/// failure mode as a literal empty string.
#[test]
fn mode_pd_rejects_whitespace_only_prefill_selector() {
let err = cfg(None, Some(" , "), Some("role=decode"))
.mode()
.unwrap_err();
assert!(
matches!(
err,
ConfigError::EmptyPdSelector {
selector: "prefill"
},
),
"expected EmptyPdSelector(prefill), got {err:?}",
);
}
/// Identical prefill and decode selectors degrade silently: every
/// slice matches both, but `classify_mode` returns `Prefill` first,
/// so the decode pool stays empty.
#[test]
fn mode_pd_rejects_identical_prefill_and_decode_selectors() {
let err = cfg(None, Some("app=sglang"), Some("app=sglang"))
.mode()
.unwrap_err();
assert!(
matches!(err, ConfigError::IdenticalPdSelectors),
"expected IdenticalPdSelectors, got {err:?}",
);
}
/// Trailing whitespace must not be a loophole that bypasses the
/// identical-selector check.
#[test]
fn mode_pd_rejects_identical_selectors_under_whitespace_normalization() {
let err = cfg(None, Some("app=sglang"), Some(" app=sglang "))
.mode()
.unwrap_err();
assert!(
matches!(err, ConfigError::IdenticalPdSelectors),
"expected IdenticalPdSelectors, got {err:?}",
);
}
/// `labels_match_selector` accepts both `key=value` and `key==value`
/// for equality and parses them to the same `(key, value)` tuple.
/// Two selectors that differ only in this alias choice are runtime-
/// equivalent — they'd match the same EndpointSlices, then
/// `classify_mode`'s prefill-first ordering would funnel every slice
/// into Prefill, leaving decode empty. The check must canonicalize
/// at the term level (parsed `(key, value)` tuples), not the raw
/// string level.
#[test]
fn mode_pd_rejects_identical_selectors_under_eq_alias() {
let err = cfg(None, Some("app=sglang"), Some("app==sglang"))
.mode()
.unwrap_err();
assert!(
matches!(err, ConfigError::IdenticalPdSelectors),
"expected IdenticalPdSelectors, got {err:?}",
);
}
/// Inner whitespace inside a term (`"app = sglang"`) is the same
/// label as no whitespace (`"app=sglang"`) — the runtime
/// `labels_match_selector` trims key and value independently
/// (see `key.trim()` / `expected.trim()` in `k8s.rs`). Canonical
/// form must agree.
#[test]
fn mode_pd_rejects_identical_selectors_under_inner_whitespace() {
let err = cfg(None, Some("app=sglang"), Some("app = sglang"))
.mode()
.unwrap_err();
assert!(
matches!(err, ConfigError::IdenticalPdSelectors),
"expected IdenticalPdSelectors, got {err:?}",
);
}
/// Term order doesn't matter for label matching, so `"a=1,b=2"` and
/// `"b=2,a=1"` must be treated as identical. (Implied by the sort
/// in `canonical_selector`, but pinned explicitly so a future
/// "preserve user order for diagnostics" refactor can't silently
/// reintroduce the silent-failure bug.)
#[test]
fn mode_pd_rejects_identical_selectors_under_term_order_permutation() {
let err = cfg(None, Some("role=p,app=sglang"), Some("app=sglang,role=p"))
.mode()
.unwrap_err();
assert!(
matches!(err, ConfigError::IdenticalPdSelectors),
"expected IdenticalPdSelectors, got {err:?}",
);
}
/// Sanity: two selectors that genuinely differ at the term level
/// must still pass validation — the canonicalizer must not be so
/// aggressive that it false-positives on legitimate PD configs.
#[test]
fn mode_pd_accepts_truly_distinct_selectors() {
let m = cfg(
None,
Some("app=sglang,role=prefill"),
Some("app=sglang,role=decode"),
)
.mode()
.expect("distinct selectors must validate");
assert!(matches!(m, K8sDiscoveryMode::PdDisaggregation { .. }));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
pub mod k8s;
pub mod static_urls;
pub mod types;
pub use types::*;
use crate::config::{Config, DiscoveryBackend};
use anyhow::Result;
use tokio::sync::mpsc;
/// Channel capacity for discovery → registry events. Bounded to 128 —
/// pod-add/remove is infrequent, but a bound prevents unbounded memory
/// growth under any pathological burst.
pub const DISCOVERY_CHANNEL_CAP: usize = 128;
/// Spawn the configured discovery backend.
///
/// Returns the consumer end of the event channel and a [`tokio::task::JoinHandle`]
/// for the producer task. The static_urls backend's task exits once the
/// initial fan-out completes; the k8s backend's task runs for the lifetime
/// of the watch.
pub async fn spawn_discovery(
cfg: &Config,
) -> Result<(mpsc::Receiver<DiscoveryEvent>, tokio::task::JoinHandle<()>)> {
let (tx, rx) = mpsc::channel(DISCOVERY_CHANNEL_CAP);
let handle = match &cfg.discovery.backend {
DiscoveryBackend::StaticUrls(s) => static_urls::spawn(s.clone(), tx).await?,
DiscoveryBackend::K8s(k) => k8s::spawn(k.clone(), tx).await?,
};
Ok((rx, handle))
}
@@ -0,0 +1,149 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Static-URL discovery backend.
//!
//! Takes a fixed list of worker URLs and fans one [`DiscoveryEvent::Added`]
//! per entry. After the initial fan-out the task exits — there is no
//! hot-reload; topology changes require a restart.
//!
//! Each emitted [`WorkerSpec`] uses the URL itself as the `WorkerId` and
//! seeds `mode = Plain` with empty `model_ids` and `bootstrap_port = None`.
//! The worker manager fills those in from each worker's `/server_info`
//! response (see [`crate::workers::introspect`]) and overrides the seeded
//! mode/bootstrap when the worker self-discloses a PD role — so prefill,
//! decode, and plain workers can all appear in the same `urls` list and
//! end up classified correctly.
//!
//! Requires modern SGLang that exposes `disaggregation_mode` in
//! `/server_info`. Workers on older SGLang versions that predate that
//! field stay seeded as `Plain` because the manager has no signal to
//! override with — operators running PD with such a worker should use
//! the K8s backend (which can still classify via pod labels).
use crate::config::StaticUrlsDiscoveryConfig;
use crate::discovery::{DiscoveryEvent, WorkerId, WorkerMode, WorkerSpec};
use anyhow::Result;
use tokio::sync::mpsc;
/// Spawn the static-URLs producer task and return its `JoinHandle`.
///
/// Returns `Result` for parity with [`crate::discovery::k8s::spawn`] (which
/// can fail to construct a `kube::Client`); this backend itself is
/// infallible.
pub async fn spawn(
cfg: StaticUrlsDiscoveryConfig,
tx: mpsc::Sender<DiscoveryEvent>,
) -> Result<tokio::task::JoinHandle<()>> {
let handle = tokio::spawn(async move {
for url in cfg.urls {
let spec = WorkerSpec {
id: WorkerId(url.clone()),
url,
mode: WorkerMode::Plain,
model_ids: Vec::new(),
bootstrap_port: None,
};
if tx.send(DiscoveryEvent::Added(spec)).await.is_err() {
tracing::info!(
"static_urls discovery: event channel closed during fan-out; exiting"
);
return;
}
}
tracing::debug!(
"static_urls discovery: initial fan-out complete; parking until channel closes"
);
// After fan-out the static backend has no further work — but
// `server::supervisor::supervise_critical_tasks` treats *any*
// discovery exit as fatal and flips `/readyz` to 503. Park here
// until the consumer drops the receiver. `tx.closed()` resolves
// the moment every `Receiver` has been dropped; the supervisor's
// normal-shutdown path aborts this task before that. So
// reaching the `info!` below means either (a) we lost the abort
// race during a clean shutdown, or (b) the worker manager exited
// unexpectedly — in case (b) the supervisor will catch the
// subsequent discovery-task exit and `error!` + mark unready,
// and this breadcrumb gives operator triage a starting point.
tx.closed().await;
tracing::info!(
"static_urls discovery: event channel closed by receiver \
(worker manager dropped its end, or shutdown abort raced); exiting"
);
});
Ok(handle)
}
#[cfg(test)]
mod tests {
use super::*;
/// Task exits cleanly when the consumer drops the receiver mid-fanout.
/// Without this early exit, the producer would block forever on the
/// closed channel and shutdown would have to abort it. Kept in-source
/// (rather than as a component test) because it inspects the
/// `send().is_err()` branch, which is an implementation detail of
/// this module — fan-out and event-shape assertions live in
/// `tests/component/discovery/static_urls.rs`.
#[tokio::test]
async fn exits_when_receiver_dropped() {
let cfg = StaticUrlsDiscoveryConfig {
urls: (0..10).map(|i| format!("http://w{i}:30000")).collect(),
};
let (tx, rx) = mpsc::channel(1);
drop(rx);
let h = spawn(cfg, tx).await.unwrap();
// No panic, no hang — task exits on the first send error.
h.await.unwrap();
}
/// After fan-out the task must STAY ALIVE so the critical-task
/// supervisor (`server::supervisor::supervise_critical_tasks`)
/// doesn't treat the exit as a failure and flip `/readyz` to 503.
/// The static_urls backend has no hot-reload, so the only reasons
/// it should ever exit are (a) the consumer dropped the receiver,
/// or (b) the supervisor aborted it on shutdown. A "natural" exit
/// after fan-out used to be the third path, and was wrongly
/// interpreted as a panic by the supervisor — pinned here so a
/// regression to "exit after fan-out" can't sneak back in.
#[tokio::test]
async fn stays_alive_after_fanout_until_receiver_dropped() {
use std::time::Duration;
let cfg = StaticUrlsDiscoveryConfig {
urls: vec!["http://w0:30000".into(), "http://w1:30000".into()],
};
let (tx, mut rx) = mpsc::channel(8);
let h = spawn(cfg, tx).await.unwrap();
// Drain the fan-out so the task is past the for-loop.
for _ in 0..2 {
let _ = rx.recv().await.expect("fan-out event");
}
// Now give the task a long-by-test-standards moment to exit
// post-fanout. Pre-fix this would have completed in under a
// millisecond; post-fix it must time out.
let mut handle = h;
let exited = tokio::time::timeout(Duration::from_millis(200), &mut handle).await;
let still_running = exited.is_err();
if !still_running {
panic!(
"static_urls task exited after fan-out (joined as {exited:?}); \
this trips `supervise_critical_tasks` → mark_unready and the pod \
becomes /readyz 503. The task must park until the receiver is dropped."
);
}
// Clean shutdown: dropping the receiver closes the channel, which
// the post-fix task uses as its "time to exit" signal. Pin both
// halves of the contract — parks while the receiver is alive AND
// exits cleanly once it's dropped — so a future refactor that
// parks the task on the wrong signal (e.g., a sleep, a token that
// never fires) is caught here rather than silently lingering.
drop(rx);
let joined = tokio::time::timeout(Duration::from_secs(2), handle)
.await
.expect("task must exit promptly after the receiver is dropped");
joined.expect("task panicked during clean shutdown");
}
}
@@ -0,0 +1,160 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use serde::{Deserialize, Serialize};
/// Opaque worker identifier. Wraps a string so callsites can't confuse it
/// with other string types (e.g. `ModelId`).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WorkerId(pub String);
impl std::fmt::Display for WorkerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Opaque model identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ModelId(pub String);
impl std::fmt::Display for ModelId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Prefill/Decode/Plain role of a worker.
///
/// Serialises as `"plain"`, `"prefill"`, `"decode"` (snake_case).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerMode {
Plain,
Prefill,
Decode,
}
/// Immutable worker description emitted by a discovery backend.
///
/// Backends emit [`DiscoveryEvent::Added`] carrying a `WorkerSpec` when a
/// new worker becomes available, and [`DiscoveryEvent::Removed`] when it
/// leaves.
///
/// `bootstrap_port` is the SGLang disagg bootstrap server port for
/// prefill workers (set via `--disaggregation-bootstrap-port` at worker
/// startup). Resolved from each worker's `/server_info` response (see
/// [`crate::workers::introspect`]); discovery backends seed it as
/// `None`. `None` for decode and plain workers — they don't own a
/// bootstrap server. The router copies the selected prefill worker's
/// `bootstrap_host`/`bootstrap_port` plus a random `bootstrap_room`
/// u64 onto every PD-disagg request body so the prefill engine can
/// match incoming KV-transfer requests from the decode peer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerSpec {
pub id: WorkerId,
pub url: String,
pub mode: WorkerMode,
pub model_ids: Vec<ModelId>,
#[serde(default)]
pub bootstrap_port: Option<u16>,
}
/// Event produced by a discovery backend and consumed by `WorkerManager`.
///
/// Tagged with `"event"` for JSON clarity:
/// ```json
/// {"event":"added","id":"w1","url":"http://…","mode":"plain","model_ids":["m"]}
/// {"event":"removed","id":"w1"}
/// {"event":"mode_changed","id":"w1","mode":"decode"}
/// ```
///
/// The `Added` variant wraps the full [`WorkerSpec`]; the others carry only
/// what changed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum DiscoveryEvent {
Added(WorkerSpec),
Removed {
id: WorkerId,
},
/// Used by the k8s backend when only the PD label flips (rare).
ModeChanged {
id: WorkerId,
mode: WorkerMode,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn worker_spec_serde_round_trip() {
let w = WorkerSpec {
id: WorkerId("w1".into()),
url: "http://10.0.0.1:30000".into(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("qwen".into())],
bootstrap_port: None,
};
let s = serde_json::to_string(&w).unwrap();
let d: WorkerSpec = serde_json::from_str(&s).unwrap();
assert_eq!(w, d);
}
#[test]
fn worker_spec_with_bootstrap_port_round_trip() {
let w = WorkerSpec {
id: WorkerId("p1".into()),
url: "http://10.0.0.1:30000".into(),
mode: WorkerMode::Prefill,
model_ids: vec![ModelId("qwen".into())],
bootstrap_port: Some(8997),
};
let s = serde_json::to_string(&w).unwrap();
assert!(s.contains("\"bootstrap_port\":8997"));
let d: WorkerSpec = serde_json::from_str(&s).unwrap();
assert_eq!(w, d);
}
#[test]
fn worker_spec_deserializes_with_missing_bootstrap_port() {
// Older configs / hand-written JSON without the field should
// still parse — bootstrap_port defaults to None for non-PD
// deployments.
let json = r#"{"id":"w","url":"http://x","mode":"plain","model_ids":["m"]}"#;
let w: WorkerSpec = serde_json::from_str(json).unwrap();
assert_eq!(w.bootstrap_port, None);
}
#[test]
fn worker_mode_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&WorkerMode::Plain).unwrap(),
"\"plain\""
);
assert_eq!(
serde_json::to_string(&WorkerMode::Prefill).unwrap(),
"\"prefill\""
);
assert_eq!(
serde_json::to_string(&WorkerMode::Decode).unwrap(),
"\"decode\""
);
}
#[test]
fn discovery_event_round_trip() {
let e = DiscoveryEvent::Added(WorkerSpec {
id: WorkerId("w1".into()),
url: "http://x:30000".into(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("m1".into())],
bootstrap_port: None,
});
let s = serde_json::to_string(&e).unwrap();
let d: DiscoveryEvent = serde_json::from_str(&s).unwrap();
assert_eq!(e, d);
}
}
@@ -0,0 +1,150 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
// NOTE: `opened_at` uses `tokio::time::Instant` rather than `std::time::Instant`
// so that `#[tokio::test(start_paused = true)]` + `tokio::time::advance` can
// move the clock forward in tests. `std::time::Instant` is not paused by
// tokio's mock clock, so `elapsed()` would always return near-zero inside a
// paused-time test, preventing the Open → HalfOpen transition from being
// exercised deterministically.
use std::num::NonZeroU32;
use std::sync::Mutex;
use std::time::Duration;
use tokio::time::Instant;
#[derive(Debug, Clone)]
pub struct CircuitBreakerConfig {
pub threshold: NonZeroU32,
pub cool_down: Duration,
}
impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
threshold: NonZeroU32::new(3).expect("3 is non-zero"),
cool_down: Duration::from_secs(30),
}
}
}
#[derive(Debug, Clone, Copy)]
enum State {
Closed,
Open { opened_at: Instant },
HalfOpen { probe_in_flight: bool },
}
#[derive(Debug)]
struct Inner {
state: State,
consecutive_failures: u32,
}
#[derive(Debug)]
pub struct CircuitBreaker {
inner: Mutex<Inner>,
config: CircuitBreakerConfig,
}
impl CircuitBreaker {
pub fn new() -> Self {
Self::with_config(CircuitBreakerConfig::default())
}
pub fn with_config(config: CircuitBreakerConfig) -> Self {
Self {
inner: Mutex::new(Inner {
state: State::Closed,
consecutive_failures: 0,
}),
config,
}
}
/// Non-mutating predicate: would [`allow`] return `true` if called
/// right now?
///
/// Used by enumeration / filter paths (e.g.
/// [`crate::workers::registry::WorkerRegistry::healthy_workers_for`])
/// that need to inspect breaker readiness without claiming a half-open
/// probe slot. Calling `allow()` for filtering would leak probe slots
/// to unselected candidates and starve dispatch: the policy would
/// enumerate a worker as "healthy", then the proxy's `allow()` at
/// dispatch time would see `probe_in_flight=true` and reject.
///
/// Semantics:
/// - `Closed` → `true`
/// - `Open` past `cool_down` → `true` (a probe slot is available)
/// - `Open` within `cool_down` → `false`
/// - `HalfOpen { probe_in_flight: true }` → `false`
/// - `HalfOpen { probe_in_flight: false }` → `true`
pub fn would_allow(&self) -> bool {
let g = self.inner.lock().unwrap();
match g.state {
State::Closed => true,
State::Open { opened_at } => opened_at.elapsed() >= self.config.cool_down,
State::HalfOpen { probe_in_flight } => !probe_in_flight,
}
}
/// True if a request may proceed. Mutates state when transitioning
/// from Open → HalfOpen.
pub fn allow(&self) -> bool {
let mut g = self.inner.lock().unwrap();
match g.state {
State::Closed => true,
State::Open { opened_at } => {
if opened_at.elapsed() >= self.config.cool_down {
g.state = State::HalfOpen {
probe_in_flight: true,
};
true
} else {
false
}
}
State::HalfOpen { probe_in_flight } => {
if probe_in_flight {
false
} else {
g.state = State::HalfOpen {
probe_in_flight: true,
};
true
}
}
}
}
pub fn record_success(&self) {
let mut g = self.inner.lock().unwrap();
g.consecutive_failures = 0;
g.state = State::Closed;
}
pub fn record_failure(&self) {
let mut g = self.inner.lock().unwrap();
match g.state {
State::Closed | State::HalfOpen { .. } => {
g.consecutive_failures += 1;
if g.consecutive_failures >= self.config.threshold.get() {
g.state = State::Open {
opened_at: Instant::now(),
};
}
}
State::Open { .. } => {
// Already open: ticking consecutive_failures or refreshing opened_at
// would pin us Open during a failure storm. The cool_down is
// measured from first-open; failures during Open are ignored.
}
}
}
}
impl Default for CircuitBreaker {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,4 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
pub mod circuit_breaker;
+18
View File
@@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! sgl-router: slim KV-aware OpenAI-compatible router for SGLang workers.
//!
//! See `~/.claude/projects/-Users-kangyan-zhou-sglang-workspace-sglang/specs/2026-05-14-sgl-router-slim-design.md`
//! for the design roadmap.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub mod config;
pub mod discovery;
pub mod health;
pub mod policies;
pub mod proxy;
pub mod server;
pub mod tokenizer;
pub mod workers;
+239
View File
@@ -0,0 +1,239 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use anyhow::{Context, Result};
use clap::Parser;
use sgl_router::config::LogFormat;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::signal::unix::{signal, Signal, SignalKind};
#[derive(Parser, Debug)]
#[command(name = "sgl-router", version)]
struct Cli {
#[arg(long, env = "SGL_ROUTER_CONFIG")]
config: PathBuf,
}
/// Install the global tracing subscriber.
///
/// Idempotent: a second call returns `Ok` without panicking. When
/// `try_init` errors, some other code has already installed a subscriber,
/// so the `tracing::debug!` below is delivered through THAT subscriber —
/// no recursive init.
///
/// `format` selects the output shape: `Json` emits one JSON record per
/// line (target for production / k8s log aggregators), `Text` is the
/// human-readable default. The `RUST_LOG` environment variable always
/// wins over `default_level`.
fn init_tracing(default_level: &str, format: LogFormat) -> Result<()> {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level));
let install_result = match format {
LogFormat::Json => tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(true)
.json()
.try_init(),
LogFormat::Text => tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(true)
.try_init(),
};
if let Err(e) = install_result {
// A second install attempt; the existing subscriber is fine.
// Surface the attempted default level so an operator can see
// what we tried.
tracing::debug!(
default_level = %default_level,
?format,
error = %e,
"tracing subscriber already installed; continuing"
);
}
Ok(())
}
/// Install a minimal text-format subscriber BEFORE config parsing so a
/// config-load error has somewhere to surface. The real subscriber
/// (driven by `Config.observability`) is installed after; the second
/// `try_init` is a no-op because a subscriber is already present.
/// The bootstrap subscriber respects `RUST_LOG` so an operator can
/// debug startup with `RUST_LOG=debug` even when the config file is
/// missing or malformed.
fn install_bootstrap_subscriber() {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(true)
.try_init();
}
/// Install SIGTERM and SIGINT handlers up front so a failure here surfaces
/// before `axum::serve` starts. If installation fails (rare: container
/// without signal capability, seccomp policy), we return an error and the
/// process exits cleanly rather than running deaf to k8s termination.
fn install_signal_handlers() -> Result<(Signal, Signal)> {
let sigterm = signal(SignalKind::terminate()).context("install SIGTERM handler")?;
let sigint = signal(SignalKind::interrupt()).context("install SIGINT handler")?;
Ok((sigterm, sigint))
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// Bootstrap subscriber so a Config::from_path error has structured
// output. The configured-format subscriber installs after this and
// becomes a no-op via try_init's idempotency.
install_bootstrap_subscriber();
let cfg = sgl_router::config::Config::from_path(&cli.config)
.with_context(|| format!("load config from {}", cli.config.display()))?;
init_tracing(&cfg.observability.log_level, cfg.observability.log_format)?;
tracing::info!(
"sgl-router {} starting on {}:{}",
env!("CARGO_PKG_VERSION"),
cfg.server.host,
cfg.server.port
);
let tokenizers = Arc::new(
sgl_router::tokenizer::TokenizerRegistry::load_from_config(&cfg)
.context("load tokenizers")?,
);
let registry = Arc::new(sgl_router::workers::WorkerRegistry::default());
// Build the KV-event index up front so the cache-aware-zmq policy can
// share its `HashTree` handle + `BlockSizeOracle`. When no model uses
// `cache_aware_zmq`, the index is still constructed (cheap) but no
// subscribers are ever added.
let block_size_oracle = sgl_router::policies::kv_events::BlockSizeOracle::new();
let kv_index = sgl_router::policies::kv_events::KvEventIndex::new_with_http_and_oracle(
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.expect("default http client builds"),
Arc::clone(&block_size_oracle),
);
let policies = Arc::new(
sgl_router::policies::factory::build_registry(
&cfg,
kv_index.tree(),
Arc::clone(&tokenizers),
Arc::clone(&block_size_oracle),
)
.context("build policy registry")?,
);
// Shared ActiveLoadRegistry + janitor task. The janitor reaps
// request entries whose lifetime exceeded `stale_request_timeout`,
// so a leaked guard (proxy task panic, etc.) does not inflate a
// worker's load forever. The registry is built BEFORE the manager
// is spawned so the manager can call `forget_worker` on
// `DiscoveryEvent::Removed`.
let stale_timeout = std::time::Duration::from_secs(cfg.active_load.stale_request_timeout_secs);
let active_load = sgl_router::policies::active_load::ActiveLoadRegistry::new(
Arc::new(sgl_router::policies::active_load::SystemTimeClock),
stale_timeout,
);
// Sweep cadence is 1/10 of the configured timeout, clamped to
// [1 s, 60 s]. A short timeout (test setting) needs frequent
// sweeps to fire within the test's window; a long timeout
// (production) doesn't need sub-minute checks.
let sweep_interval = std::time::Duration::from_secs(
(cfg.active_load.stale_request_timeout_secs / 10).clamp(1, 60),
);
let janitor_handle =
sgl_router::policies::active_load::spawn_janitor(Arc::clone(&active_load), sweep_interval);
// Spawn discovery + manager tasks.
let (event_rx, discovery_handle) = sgl_router::discovery::spawn_discovery(&cfg)
.await
.context("spawn discovery")?;
let kv_index_opt: Option<Arc<sgl_router::policies::kv_events::KvEventIndex>> =
Some(Arc::clone(&kv_index));
let manager_handle = tokio::spawn(sgl_router::workers::manager::run_with_config(
event_rx,
registry.clone(),
Some(Arc::new(cfg.clone())),
kv_index_opt,
Some(Arc::clone(&active_load)),
));
let proxy = Arc::new(
sgl_router::proxy::Proxy::new(std::time::Duration::from_secs(
cfg.proxy.request_timeout_secs,
))
.context("build proxy client")?,
);
let ctx = Arc::new(
sgl_router::server::app_context::AppContext::with_active_load(
cfg.clone(),
tokenizers,
proxy,
registry,
policies,
active_load,
),
);
ctx.mark_ready();
let app = sgl_router::server::app::build_router(ctx.clone());
let bind = format!("{}:{}", cfg.server.host, cfg.server.port);
let listener = tokio::net::TcpListener::bind(&bind)
.await
.with_context(|| format!("bind {bind}"))?;
tracing::info!("listening on {bind}");
let (sigterm, sigint) = install_signal_handlers()?;
let serve = axum::serve(listener, app).with_graceful_shutdown(shutdown_signal(sigterm, sigint));
let server_result = serve.await.context("axum serve");
// Best-effort: cancel discovery + manager + janitor on shutdown.
// The janitor handle's drop signals cancellation; we additionally
// await `shutdown` so the task joins cleanly before the process
// exits — useful for tracing tail logs.
discovery_handle.abort();
manager_handle.abort();
janitor_handle.shutdown().await;
server_result
}
async fn shutdown_signal(mut sigterm: Signal, mut sigint: Signal) {
tokio::select! {
_ = sigterm.recv() => tracing::info!("got SIGTERM, shutting down"),
_ = sigint.recv() => tracing::info!("got SIGINT, shutting down"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn install_signal_handlers_returns_both() {
// Pins the contract that handler installation works on a standard
// tokio runtime. If this fails on a sandboxed runner, the real
// service would also fail to install — which is the point.
assert!(install_signal_handlers().is_ok());
}
#[test]
fn init_tracing_is_idempotent() {
let _ = init_tracing("info", LogFormat::Text);
let _ = init_tracing("info", LogFormat::Text);
}
#[test]
fn init_tracing_accepts_json_format() {
// Doesn't matter whether we win or lose the race against another
// subscriber install — the function must return Ok either way.
assert!(init_tracing("info", LogFormat::Json).is_ok());
}
}
@@ -0,0 +1,945 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Per-worker active-load tracking with RAII guards and a stale-request
//! janitor.
//!
//! The cache-aware-zmq policy ([`super::cache_aware_zmq`]) needs to combine
//! the hash tree's overlap score with a per-worker load signal. The
//! per-worker `Worker::active_requests` counter tracks one axis — number of
//! in-flight HTTP requests — and is already drop-safe through
//! [`crate::workers::LoadGuard`].
//!
//! This module adds two things on top of that:
//!
//! 1. **Per-request bookkeeping** keyed on a `RequestId` so a background
//! janitor can sweep requests that outlive the configured
//! `stale_request_timeout` and decrement the counters they were holding.
//! Without this, a request whose `LoadGuard` is leaked (proxy task
//! panics before the future drops, server hits a panic-catching
//! middleware, etc.) would inflate a worker's load forever.
//! 2. **Two-axis tracking** so PD-disaggregation can score prefill (token
//! count) separately from decode (block count). The two counters share
//! the same registry shape; we expose them as a single
//! [`ActiveLoadGuard`] holding both so the proxy's hot path mints one
//! guard per request rather than two.
//!
//! # Drop semantics
//!
//! Guards decrement on drop AND remove themselves from the request tracker
//! so the janitor never double-decrements. The implementation uses
//! `Option<RegistryHandle>` inside the guard: the janitor's `expire_now`
//! path takes the handle (rendering subsequent drop a no-op for that
//! request), while normal RAII drop also takes the handle (rendering
//! subsequent janitor sweep a no-op). Either path may run first — the
//! other becomes a no-op. Rust's affine type system makes a literal
//! double-drop of the same guard value unreachable.
//!
//! # Clock injection
//!
//! [`ActiveLoadRegistry::new`] is generic over the clock so tests can drive
//! the janitor deterministically. Production wires a `SystemTimeClock`;
//! tests use a `MockClock`. The `Instant`-based timestamp on registration
//! is sufficient for the timeout comparison (monotonic), so the clock
//! abstraction is just two methods: `now()` and an associated `Instant`
//! type whose `duration_since(other)` returns the wall-clock delta.
use crate::discovery::WorkerId;
use crate::server::metrics::{ActiveLoadKind, MetricsRegistry};
use dashmap::DashMap;
use parking_lot::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
/// Unique identifier for an in-flight request. Minted by
/// [`ActiveLoadRegistry::register`] and carried inside [`ActiveLoadGuard`]
/// so the janitor can address one request at a time.
#[derive(Clone, Eq, Hash, PartialEq, Debug)]
pub struct RequestId(pub Uuid);
impl RequestId {
pub fn new_v4() -> Self {
Self(Uuid::new_v4())
}
}
impl std::fmt::Display for RequestId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Per-worker counters: one for prefill (token) load, one for decode (block)
/// load. The two axes are tracked separately so cache-aware-zmq can score
/// prefill candidates by token load and decode candidates by block load
/// without each axis spamming through the other's counter.
///
/// Production tracks **active requests** as the unit (count of in-flight
/// requests pinning the worker), not raw token / block counts — until the
/// proxy wires real prompt-token / completion-block accounting through to
/// `register`. The two axes will become meaningful once the proxy starts
/// passing `prompt_tokens` and `output_blocks` to it.
#[derive(Debug, Default)]
struct WorkerCounters {
prefill_load: AtomicUsize,
decode_load: AtomicUsize,
}
/// Per-request bookkeeping the janitor consults to find expired requests.
///
/// `cancel` is a [`CancellationToken`] the janitor fires when the entry
/// is swept. The chat handler holds a clone (via
/// [`ActiveLoadGuard::cancel_token`]) and aborts its upstream fetch
/// with `ApiError::StaleRequestExpired` when the token resolves —
/// surfacing the stale-request expiry as a 504 to the client instead
/// of leaving the handler hung on a long-lived upstream.
///
/// `counters` is the **exact** `WorkerCounters` instance that was
/// incremented at register time. Holding the `Arc` directly (instead
/// of re-looking-up `workers.get(&worker)` at sweep time) pins the
/// decrement to the same instance — so a worker that is
/// `forget_worker`-removed and re-added under the same `WorkerId`
/// does not underflow the new (zero-initialized) counters slot.
#[derive(Debug)]
struct RequestEntry {
worker: WorkerId,
/// Worker URL captured at register time. The metrics gauge
/// (`sgl_router_active_load`) is keyed by URL, not by `WorkerId`, so
/// drop / sweep paths need the URL to emit the decremented gauge
/// value. Stored on the entry (not looked up via the worker
/// registry) so a `forget_worker` between register and drop still
/// produces a coherent metric trace.
worker_url: String,
counters: Arc<WorkerCounters>,
prefill_load: usize,
decode_load: usize,
registered_at: Instant,
cancel: CancellationToken,
}
/// Clock abstraction so tests can drive the janitor deterministically.
///
/// We only need `now()`; ordering is via `Instant::duration_since` which
/// already exists on the std type. Production implementers return the
/// monotonic system instant; tests return whatever value `MockClock` is set
/// to via `set_now`.
pub trait Clock: Send + Sync + std::fmt::Debug {
fn now(&self) -> Instant;
}
/// Monotonic system clock used in production.
#[derive(Debug, Default, Clone)]
pub struct SystemTimeClock;
impl Clock for SystemTimeClock {
fn now(&self) -> Instant {
Instant::now()
}
}
/// Test-only clock that returns a caller-controlled instant.
///
/// Wrapped in `parking_lot::Mutex` because tests cross await points; the
/// type is `Send + Sync` so it can be stored behind `Arc<dyn Clock>`.
#[derive(Debug)]
pub struct MockClock {
now: Mutex<Instant>,
}
impl MockClock {
pub fn new(start: Instant) -> Self {
Self {
now: Mutex::new(start),
}
}
/// Advance the clock by `delta`. Returns the new `now`.
pub fn advance(&self, delta: Duration) -> Instant {
let mut guard = self.now.lock();
*guard += delta;
*guard
}
}
impl Clock for MockClock {
fn now(&self) -> Instant {
*self.now.lock()
}
}
/// Registry of in-flight requests + per-worker active-load counters.
///
/// Constructed once per `AppContext`; the cache-aware-zmq policy reads
/// per-worker `prefill_load` / `decode_load` from here when scoring
/// candidates, and the proxy holds an [`ActiveLoadGuard`] per request so
/// counters decrement on drop. A background task periodically calls
/// [`Self::sweep_stale`] to evict requests that outlived
/// `stale_request_timeout`.
#[derive(Debug)]
pub struct ActiveLoadRegistry {
workers: DashMap<WorkerId, Arc<WorkerCounters>>,
requests: DashMap<RequestId, RequestEntry>,
clock: Arc<dyn Clock>,
stale_request_timeout: Duration,
/// Optional Prometheus metrics sink. When attached via
/// [`Self::attach_metrics`] (typically from `AppContext`), every
/// `register` / drop / `sweep_stale` emits the live per-worker
/// `sgl_router_active_load` gauge for both axes. Late binding via
/// `Mutex<Option<...>>` keeps construction order flexible: the
/// registry can be created before the metrics registry exists.
metrics: Mutex<Option<Arc<MetricsRegistry>>>,
}
impl ActiveLoadRegistry {
/// Construct an [`ActiveLoadRegistry`] wrapped in an [`Arc`].
///
/// The registry is always shared (proxy + janitor + selector all hold
/// the same instance), so the public constructor mints the `Arc`
/// directly to remove an easy footgun where callers forget to wrap
/// it. Tests that need the inner type for direct field access also
/// receive `Arc<Self>`.
pub fn new(clock: Arc<dyn Clock>, stale_request_timeout: Duration) -> Arc<Self> {
Arc::new(Self {
workers: DashMap::new(),
requests: DashMap::new(),
clock,
stale_request_timeout,
metrics: Mutex::new(None),
})
}
/// Attach (or replace) the [`MetricsRegistry`] this registry pushes
/// gauge updates into. Idempotent; safe to call multiple times.
/// Production wires this from `AppContext` after the metrics registry
/// is constructed; tests skip it unless they assert on the gauge.
pub fn attach_metrics(&self, metrics: Arc<MetricsRegistry>) {
*self.metrics.lock() = Some(metrics);
}
/// Snapshot the current per-worker load and push it to the metrics
/// gauge (if any). Called from the register / drop / sweep paths
/// after the counter mutation completes. Reading `counters.load()`
/// here (rather than computing from the delta) keeps the gauge
/// eventually-consistent with the canonical counter even under
/// concurrent register + drop interleavings.
fn publish_gauge(&self, counters: &WorkerCounters, worker_url: &str) {
let Some(metrics) = self.metrics.lock().clone() else {
return;
};
metrics.set_active_load(
worker_url,
ActiveLoadKind::PrefillTokens,
counters.prefill_load.load(Ordering::Relaxed) as i64,
);
metrics.set_active_load(
worker_url,
ActiveLoadKind::DecodeBlocks,
counters.decode_load.load(Ordering::Relaxed) as i64,
);
}
/// Default-config registry: monotonic system clock + 10-minute stale
/// timeout. Convenience constructor for production callers; tests use
/// [`Self::new`] with a `MockClock`. Mirrors
/// `default_stale_request_timeout_secs` in `config::types`.
///
/// 10 minutes is comfortable above 99p generation tail latency
/// (including long-queue throughput-focused workloads) while
/// bounding leak-induced load inflation.
pub fn with_defaults() -> Arc<Self> {
Self::new(
Arc::new(SystemTimeClock) as Arc<dyn Clock>,
Duration::from_secs(10 * 60),
)
}
/// Register a new in-flight request and return a guard that holds the
/// active-load counters up. The guard's drop / explicit complete path
/// decrements the counters and removes the request entry.
///
/// `worker_url` is captured on the entry so drop / sweep can emit a
/// coherent gauge update via the attached [`MetricsRegistry`] (if
/// any). Callers in the request path pass `&worker.url`; tests pass a
/// stable placeholder.
pub fn register(
self: &Arc<Self>,
worker: WorkerId,
worker_url: impl Into<String>,
prefill_load: usize,
decode_load: usize,
) -> ActiveLoadGuard {
let worker_url = worker_url.into();
let request_id = RequestId::new_v4();
let counters = self
.workers
.entry(worker.clone())
.or_insert_with(|| Arc::new(WorkerCounters::default()))
.value()
.clone();
counters
.prefill_load
.fetch_add(prefill_load, Ordering::Relaxed);
counters
.decode_load
.fetch_add(decode_load, Ordering::Relaxed);
self.publish_gauge(&counters, &worker_url);
let cancel = CancellationToken::new();
self.requests.insert(
request_id.clone(),
RequestEntry {
worker: worker.clone(),
worker_url,
counters,
prefill_load,
decode_load,
registered_at: self.clock.now(),
cancel: cancel.clone(),
},
);
ActiveLoadGuard {
registry: Some(Arc::clone(self)),
request_id: Some(request_id),
worker,
cancel,
}
}
/// Drop a worker's per-worker counters entry. Called from
/// [`crate::workers::manager`] on `DiscoveryEvent::Removed` so the
/// `WorkerCounters` slot for a now-gone worker does not leak.
///
/// Guards still alive for that worker remain valid; their drop tries
/// `workers.get(&entry.worker)` which returns `None`, and the
/// per-request `requests` entry is still removed cleanly. A subsequent
/// `register` for the same `WorkerId` reinitializes the slot to 0 —
/// the in-flight guards' loads are NOT re-added, by design (the
/// worker is gone, those loads no longer mean anything).
pub fn forget_worker(&self, id: &WorkerId) {
self.workers.remove(id);
}
/// Returns `true` if the registry currently has a per-worker
/// counters entry for `id`. Cheap; intended for tests + diagnostics.
pub fn is_known(&self, id: &WorkerId) -> bool {
self.workers.contains_key(id)
}
/// Current prefill load (sum across in-flight requests) for a worker.
pub fn prefill_load(&self, worker: &WorkerId) -> usize {
self.workers
.get(worker)
.map(|c| c.prefill_load.load(Ordering::Relaxed))
.unwrap_or(0)
}
/// Current decode load (sum across in-flight requests) for a worker.
pub fn decode_load(&self, worker: &WorkerId) -> usize {
self.workers
.get(worker)
.map(|c| c.decode_load.load(Ordering::Relaxed))
.unwrap_or(0)
}
/// Number of in-flight requests tracked (cheap; useful for tests +
/// metrics).
pub fn inflight_count(&self) -> usize {
self.requests.len()
}
/// Sweep entries whose `registered_at + stale_request_timeout` is in
/// the past. Returns the number of entries expired.
///
/// Decrements both axes' worker counters for each expired entry. Safe
/// to call concurrently with `register` and with guard drops — each
/// `remove` operation is atomic and the per-worker counters are
/// `AtomicUsize` so partial visibility cannot under-decrement.
pub fn sweep_stale(&self) -> usize {
let now = self.clock.now();
let mut expired_ids: Vec<RequestId> = Vec::new();
for entry in self.requests.iter() {
if now.duration_since(entry.value().registered_at) >= self.stale_request_timeout {
expired_ids.push(entry.key().clone());
}
}
let mut count = 0;
for id in expired_ids {
// Use `remove`: if the guard's drop concurrently removed the
// entry between our scan and this point, the second remove
// returns `None` and we skip (no double-decrement).
if let Some((_, entry)) = self.requests.remove(&id) {
// Decrement the **captured** counters Arc — the same
// instance the register call incremented. This stays
// correct across `forget_worker` + re-register cycles:
// even if `self.workers[&entry.worker]` now points at a
// brand-new `WorkerCounters`, our decrement targets the
// original one (still alive via this Arc clone).
entry
.counters
.prefill_load
.fetch_sub(entry.prefill_load, Ordering::Relaxed);
entry
.counters
.decode_load
.fetch_sub(entry.decode_load, Ordering::Relaxed);
self.publish_gauge(&entry.counters, &entry.worker_url);
// Wake the chat handler awaiting this request so it can
// return `ApiError::StaleRequestExpired` to the client.
// Cancellation is idempotent; if the handler already
// finished and dropped the guard, the token is already
// dropped and `cancel()` is a no-op for everyone.
entry.cancel.cancel();
count += 1;
tracing::warn!(
request_id = %id,
worker = %entry.worker,
prefill_load = entry.prefill_load,
decode_load = entry.decode_load,
"stale request swept by active-load janitor",
);
}
}
count
}
}
/// Spawn a background janitor task that periodically calls
/// [`ActiveLoadRegistry::sweep_stale`].
///
/// Returns a [`JanitorHandle`] that owns the join handle and a cancellation
/// token. Dropping the handle cancels the task; calling
/// [`JanitorHandle::shutdown`] cancels and awaits the join.
///
/// `interval` is the wall-clock cadence of the sweep. A sensible default
/// is half the configured `stale_request_timeout` so an expired entry is
/// reaped within 1.5× the timeout in the worst case. Pass a fresh
/// `Arc<ActiveLoadRegistry>` (cloned from the shared one held in
/// `AppContext`).
pub fn spawn_janitor(registry: Arc<ActiveLoadRegistry>, interval: Duration) -> JanitorHandle {
let cancel = CancellationToken::new();
let cancel_for_task = cancel.clone();
let join = tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
biased;
_ = cancel_for_task.cancelled() => {
tracing::debug!("active-load janitor: shutdown requested");
return;
}
_ = ticker.tick() => {
let n = registry.sweep_stale();
if n > 0 {
tracing::info!(
swept = n,
"active-load janitor: removed stale requests",
);
}
}
}
}
});
JanitorHandle {
cancel,
join: Some(join),
}
}
/// Owner handle for the background janitor task. Dropping the handle
/// cancels the task; calling [`Self::shutdown`] cancels AND awaits join,
/// giving callers a clean shutdown path.
#[must_use = "JanitorHandle owns the background task; dropping it cancels the janitor"]
pub struct JanitorHandle {
cancel: CancellationToken,
join: Option<tokio::task::JoinHandle<()>>,
}
impl JanitorHandle {
pub async fn shutdown(mut self) {
self.cancel.cancel();
if let Some(j) = self.join.take() {
// 2 s ceiling guards against a runtime-teardown hang; the
// janitor exits within one tick of `cancelled()`.
let _ = tokio::time::timeout(Duration::from_secs(2), j).await;
}
}
}
impl Drop for JanitorHandle {
fn drop(&mut self) {
self.cancel.cancel();
}
}
/// RAII guard returned by [`ActiveLoadRegistry::register`].
///
/// `#[must_use]`: a statement-form `registry.register(...)` would drop the
/// guard on the same line and decrement the counter before the request
/// actually executed, defeating the purpose. The compile-time warning
/// catches that misuse.
#[must_use = "ActiveLoadGuard must be held for the request's lifetime; dropping it immediately decrements counters"]
#[derive(Debug)]
pub struct ActiveLoadGuard {
registry: Option<Arc<ActiveLoadRegistry>>,
/// `None` after the janitor expired this request — drop becomes a
/// no-op in that case. The guard keeps only the `RequestId`; the
/// per-axis amounts (and the captured `Arc<WorkerCounters>`) live
/// in the registry's `RequestEntry` so drop and the janitor
/// consult the same source of truth.
request_id: Option<RequestId>,
worker: WorkerId,
/// Cancellation token mirrored from `RequestEntry::cancel`. The
/// chat handler awaits `cancel.cancelled()` in a `tokio::select!`
/// branch so the janitor can interrupt an upstream fetch and force
/// the handler to return `ApiError::StaleRequestExpired` (HTTP 504).
cancel: CancellationToken,
}
impl ActiveLoadGuard {
/// Read-only accessor (mainly for tests + diagnostic logging).
pub fn worker(&self) -> &WorkerId {
&self.worker
}
/// Borrow the cancellation token. The chat handler clones it for
/// the `tokio::select!` branch (`token.cancelled().await`) so the
/// guard itself can still move into the SSE pump task (or stay in
/// the buffered-response scope) without losing the wake-up channel.
pub fn cancel_token(&self) -> &CancellationToken {
&self.cancel
}
}
impl Drop for ActiveLoadGuard {
fn drop(&mut self) {
// If the janitor already expired this request (or `expire_now` was
// called explicitly), `request_id` is `None` and we skip — the
// janitor already decremented the counters.
let (Some(registry), Some(id)) = (self.registry.take(), self.request_id.take()) else {
return;
};
// `remove` returns `Some` exactly once; if the janitor races us
// and wins, we skip the decrement here. Decrement the **same**
// counters Arc the register call incremented (see
// `ActiveLoadGuard::counters`) — pinning the decrement to a
// specific WorkerCounters instance keeps the math correct
// across `forget_worker` + re-register cycles.
if let Some((_, entry)) = registry.requests.remove(&id) {
entry
.counters
.prefill_load
.fetch_sub(entry.prefill_load, Ordering::Relaxed);
entry
.counters
.decode_load
.fetch_sub(entry.decode_load, Ordering::Relaxed);
registry.publish_gauge(&entry.counters, &entry.worker_url);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn registry_with_mock_clock(timeout: Duration) -> (Arc<ActiveLoadRegistry>, Arc<MockClock>) {
let clock = Arc::new(MockClock::new(Instant::now()));
let registry = ActiveLoadRegistry::new(Arc::clone(&clock) as Arc<dyn Clock>, timeout);
(registry, clock)
}
#[test]
fn single_worker_increment_decrement_round_trip() {
let (registry, _) = registry_with_mock_clock(Duration::from_secs(60));
let w = WorkerId("w0".into());
assert_eq!(registry.prefill_load(&w), 0);
assert_eq!(registry.decode_load(&w), 0);
let g = registry.register(w.clone(), "test://100-5", 100, 5);
assert_eq!(registry.prefill_load(&w), 100);
assert_eq!(registry.decode_load(&w), 5);
assert_eq!(registry.inflight_count(), 1);
drop(g);
assert_eq!(registry.prefill_load(&w), 0);
assert_eq!(registry.decode_load(&w), 0);
assert_eq!(registry.inflight_count(), 0);
}
#[test]
fn two_concurrent_guards_increment_to_2_then_drop_to_0() {
let (registry, _) = registry_with_mock_clock(Duration::from_secs(60));
let w = WorkerId("w0".into());
let g1 = registry.register(w.clone(), "test://10-1", 10, 1);
let g2 = registry.register(w.clone(), "test://20-2", 20, 2);
assert_eq!(registry.prefill_load(&w), 30);
assert_eq!(registry.decode_load(&w), 3);
assert_eq!(registry.inflight_count(), 2);
drop(g1);
assert_eq!(registry.prefill_load(&w), 20);
assert_eq!(registry.decode_load(&w), 2);
drop(g2);
assert_eq!(registry.prefill_load(&w), 0);
assert_eq!(registry.decode_load(&w), 0);
}
#[test]
fn guard_decrements_on_implicit_drop_via_scope_exit() {
let (registry, _) = registry_with_mock_clock(Duration::from_secs(60));
let w = WorkerId("w0".into());
{
let _g = registry.register(w.clone(), "test://7-1", 7, 1);
assert_eq!(registry.prefill_load(&w), 7);
}
assert_eq!(registry.prefill_load(&w), 0);
}
#[test]
fn distinct_workers_are_isolated() {
let (registry, _) = registry_with_mock_clock(Duration::from_secs(60));
let w0 = WorkerId("w0".into());
let w1 = WorkerId("w1".into());
let _g0 = registry.register(w0.clone(), "test://5-0", 5, 0);
let _g1 = registry.register(w1.clone(), "test://11-0", 11, 0);
assert_eq!(registry.prefill_load(&w0), 5);
assert_eq!(registry.prefill_load(&w1), 11);
}
/// Gap closer #2: double-drop safety.
///
/// Rust's affine type system makes a literal double-drop of the same
/// `ActiveLoadGuard` value impossible — the compiler rejects
/// `drop(g); drop(g);`. The interesting property is that the
/// registry's own bookkeeping never under-decrements, even if the
/// janitor and a guard's drop race. We assert that by simulating the
/// race: the janitor wins (entry removed via `sweep_stale`), then the
/// guard's drop runs — must be a no-op.
#[test]
fn janitor_then_guard_drop_does_not_underflow() {
let (registry, clock) = registry_with_mock_clock(Duration::from_secs(1));
let w = WorkerId("w0".into());
let g = registry.register(w.clone(), "test://50-5", 50, 5);
clock.advance(Duration::from_secs(2));
let n = registry.sweep_stale();
assert_eq!(n, 1);
assert_eq!(registry.prefill_load(&w), 0);
assert_eq!(registry.decode_load(&w), 0);
// Janitor already removed entry; guard's drop must not under-flow.
drop(g);
assert_eq!(registry.prefill_load(&w), 0);
assert_eq!(registry.decode_load(&w), 0);
assert_eq!(registry.inflight_count(), 0);
}
/// Gap closer #4: stale-request janitor expiry zeroes counters.
#[test]
fn janitor_expires_stale_requests() {
let (registry, clock) = registry_with_mock_clock(Duration::from_secs(5));
let w = WorkerId("w0".into());
let _g = registry.register(w.clone(), "test://100-4", 100, 4);
assert_eq!(registry.prefill_load(&w), 100);
// Just below the threshold — no expiry.
clock.advance(Duration::from_secs(4));
assert_eq!(registry.sweep_stale(), 0);
assert_eq!(registry.prefill_load(&w), 100);
// Past the threshold — expires.
clock.advance(Duration::from_secs(2));
assert_eq!(registry.sweep_stale(), 1);
assert_eq!(registry.prefill_load(&w), 0);
assert_eq!(registry.decode_load(&w), 0);
}
#[test]
fn janitor_is_idempotent_on_double_run() {
let (registry, clock) = registry_with_mock_clock(Duration::from_secs(1));
let w = WorkerId("w0".into());
let _g = registry.register(w.clone(), "test://7-0", 7, 0);
clock.advance(Duration::from_secs(2));
assert_eq!(registry.sweep_stale(), 1);
// Second run finds nothing to do.
assert_eq!(registry.sweep_stale(), 0);
assert_eq!(registry.prefill_load(&w), 0);
}
#[test]
fn janitor_leaves_fresh_requests_alone() {
let (registry, clock) = registry_with_mock_clock(Duration::from_secs(60));
let w = WorkerId("w0".into());
let _g = registry.register(w.clone(), "test://50-0", 50, 0);
clock.advance(Duration::from_secs(1));
assert_eq!(registry.sweep_stale(), 0);
assert_eq!(registry.prefill_load(&w), 50);
}
/// Spawned janitor sweeps stale entries on its periodic tick. Uses
/// real (short) sleeps so that the tokio interval timer fires; the
/// registry's clock is the real `SystemTimeClock` so both views of
/// "now" advance together. 200 ms total wait is comfortably above
/// the 30 ms timeout we configure.
#[tokio::test]
async fn spawn_janitor_sweeps_stale_entries() {
let clock: Arc<dyn Clock> = Arc::new(SystemTimeClock);
let registry = ActiveLoadRegistry::new(clock, Duration::from_millis(30));
let w = WorkerId("w0".into());
let _g = registry.register(w.clone(), "test://50-2", 50, 2);
assert_eq!(registry.inflight_count(), 1);
let handle = spawn_janitor(Arc::clone(&registry), Duration::from_millis(20));
// Wait long enough for at least one sweep to find the entry
// past the 30 ms timeout. 200 ms allows ~9 ticks of slack.
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(registry.inflight_count(), 0, "janitor should have swept");
assert_eq!(registry.prefill_load(&w), 0);
assert_eq!(registry.decode_load(&w), 0);
handle.shutdown().await;
}
/// Shutdown is idempotent — calling `shutdown` once must cleanly
/// terminate the janitor without hanging.
#[tokio::test]
async fn spawn_janitor_shutdown_is_clean() {
let clock: Arc<dyn Clock> = Arc::new(SystemTimeClock);
let registry = ActiveLoadRegistry::new(clock, Duration::from_secs(60));
let handle = spawn_janitor(Arc::clone(&registry), Duration::from_millis(100));
// Verify shutdown completes within a generous bound.
let r = tokio::time::timeout(Duration::from_secs(2), handle.shutdown()).await;
assert!(r.is_ok(), "janitor shutdown timed out");
}
/// Task B: `forget_worker` drops the per-worker counters entry so a
/// disappeared worker does not leak a `WorkerCounters` slot.
/// Existing guards still drop cleanly (no underflow / panic).
#[test]
fn forget_worker_drops_counters_entry() {
let (registry, _) = registry_with_mock_clock(Duration::from_secs(60));
let w = WorkerId("w0".into());
let g = registry.register(w.clone(), "test://7-2", 7, 2);
assert!(registry.is_known(&w), "worker is known after register");
assert_eq!(registry.prefill_load(&w), 7);
registry.forget_worker(&w);
assert!(
!registry.is_known(&w),
"worker counters entry must be removed after forget_worker",
);
// Per-worker counters are gone, so the load query reads 0.
assert_eq!(registry.prefill_load(&w), 0);
assert_eq!(registry.decode_load(&w), 0);
// The guard still has a live request entry pointing at the
// now-forgotten worker. Drop must NOT panic; the registry's
// worker map being empty for this id is treated as the
// "already-cleaned-up" terminal state.
drop(g);
assert_eq!(
registry.inflight_count(),
0,
"guard's drop must still tear down the request entry",
);
}
/// Task B: forgetting an unknown worker is a no-op (idempotent).
/// The manager calls `forget_worker` unconditionally on `Removed`,
/// so a double-Removed event or a Removed for a never-seen worker
/// must not panic.
#[test]
fn forget_unknown_worker_is_noop() {
let (registry, _) = registry_with_mock_clock(Duration::from_secs(60));
registry.forget_worker(&WorkerId("never-registered".into()));
// No assertion beyond "did not panic"; the body of the test
// exercises the contract.
}
/// Task B regression: an in-flight guard must NOT underflow the
/// counters of a freshly re-registered worker that reuses its
/// predecessor's `WorkerId`. This is the exact scenario `forget_
/// worker` is supposed to make safe — and it requires Drop to
/// decrement the **captured** `Arc<WorkerCounters>`, not the
/// current `workers[worker]` lookup.
#[test]
fn forget_then_reregister_does_not_underflow_new_counters() {
let (registry, _) = registry_with_mock_clock(Duration::from_secs(60));
let w = WorkerId("w0".into());
let old_guard = registry.register(w.clone(), "test://7-2", 7, 2);
registry.forget_worker(&w);
// Re-register under the same id → brand-new WorkerCounters
// slot. Fresh load of 0 (we mint a no-op guard to materialize
// the slot without bumping any counters).
let _new_guard = registry.register(w.clone(), "test://0-0", 0, 0);
assert_eq!(registry.prefill_load(&w), 0);
assert_eq!(registry.decode_load(&w), 0);
// Dropping the OLD guard must subtract from the OLD counters
// (which are now orphaned but kept alive via the Arc captured
// in the RequestEntry). The NEW counters' values are unaffected.
drop(old_guard);
assert_eq!(
registry.prefill_load(&w),
0,
"new worker's prefill_load must NOT underflow when old guard drops",
);
assert_eq!(
registry.decode_load(&w),
0,
"new worker's decode_load must NOT underflow when old guard drops",
);
}
/// Task D: janitor expiry fires the guard's cancellation token so
/// the in-flight handler can return `StaleRequestExpired`.
#[tokio::test]
async fn janitor_expiry_fires_guard_cancel_token() {
let (registry, clock) = registry_with_mock_clock(Duration::from_secs(1));
let w = WorkerId("w0".into());
let g = registry.register(w.clone(), "test://50-5", 50, 5);
let cancel = g.cancel_token().clone();
assert!(
!cancel.is_cancelled(),
"fresh guard's cancel token must not be cancelled",
);
clock.advance(Duration::from_secs(2));
assert_eq!(registry.sweep_stale(), 1);
// The sweep must have fired the token. We don't await
// `cancelled()` because the test is single-threaded and the
// token resolves synchronously after `cancel.cancel()`.
assert!(
cancel.is_cancelled(),
"stale sweep must cancel the guard's token",
);
// Drop the guard last so the test exits cleanly.
drop(g);
}
/// Task D: normal completion (guard drop) does NOT cancel the token.
/// The chat handler's `select!` branch is meant to fire only on a
/// janitor expiry — successful completion drops the guard without
/// touching the token, so the request returns 200 OK.
#[test]
fn guard_drop_does_not_cancel_token() {
let (registry, _) = registry_with_mock_clock(Duration::from_secs(60));
let w = WorkerId("w0".into());
let g = registry.register(w, "test://1-1", 1, 1);
let cancel = g.cancel_token().clone();
drop(g);
assert!(
!cancel.is_cancelled(),
"normal guard drop must NOT cancel the token (sweep-only signal)",
);
}
/// When a [`MetricsRegistry`] is attached, the per-worker active-load
/// gauge mirrors the live counter on register / drop / sweep.
/// Regression: prior code exposed [`MetricsRegistry::set_active_load`]
/// but nothing in the request hot path ever called it, leaving
/// `sgl_router_active_load` permanently at 0 in production.
#[test]
fn metrics_gauge_tracks_active_load_on_register_and_drop() {
use crate::server::metrics::MetricsRegistry;
let (registry, _) = registry_with_mock_clock(Duration::from_secs(60));
let metrics = MetricsRegistry::new();
registry.attach_metrics(Arc::clone(&metrics));
let w = WorkerId("w0".into());
let url = "http://w0:30000";
// Before any register, the gauge isn't surfaced (no entry yet).
let rendered = metrics.render();
assert!(
!rendered.contains("sgl_router_active_load{worker_url=\"http://w0:30000\""),
"no gauge entry expected before first register; got:\n{rendered}"
);
// Register a request with prefill_load=100, decode_load=5.
let g = registry.register(w.clone(), url, 100, 5);
let rendered = metrics.render();
assert!(
rendered.contains(
"sgl_router_active_load{worker_url=\"http://w0:30000\",kind=\"prefill_tokens\"} 100"
),
"expected prefill_tokens=100 gauge, got:\n{rendered}"
);
assert!(
rendered.contains(
"sgl_router_active_load{worker_url=\"http://w0:30000\",kind=\"decode_blocks\"} 5"
),
"expected decode_blocks=5 gauge, got:\n{rendered}"
);
// Drop the guard → gauge returns to 0.
drop(g);
let rendered = metrics.render();
assert!(
rendered.contains(
"sgl_router_active_load{worker_url=\"http://w0:30000\",kind=\"prefill_tokens\"} 0"
),
"expected prefill_tokens=0 after drop, got:\n{rendered}"
);
assert!(
rendered.contains(
"sgl_router_active_load{worker_url=\"http://w0:30000\",kind=\"decode_blocks\"} 0"
),
"expected decode_blocks=0 after drop, got:\n{rendered}"
);
}
#[test]
fn metrics_gauge_tracks_active_load_on_sweep_stale() {
use crate::server::metrics::MetricsRegistry;
let (registry, clock) = registry_with_mock_clock(Duration::from_millis(50));
let metrics = MetricsRegistry::new();
registry.attach_metrics(Arc::clone(&metrics));
let w = WorkerId("w1".into());
let url = "http://w1:30000";
// `register` returns a guard but we don't drop it — janitor sweeps it.
let _g = registry.register(w, url, 200, 10);
let rendered = metrics.render();
assert!(
rendered.contains(
"sgl_router_active_load{worker_url=\"http://w1:30000\",kind=\"prefill_tokens\"} 200"
),
"register emits gauge; got:\n{rendered}"
);
// Advance past timeout and sweep.
clock.advance(Duration::from_millis(60));
let swept = registry.sweep_stale();
assert_eq!(swept, 1, "exactly one entry should have expired");
let rendered = metrics.render();
assert!(
rendered.contains(
"sgl_router_active_load{worker_url=\"http://w1:30000\",kind=\"prefill_tokens\"} 0"
),
"sweep emits decremented gauge; got:\n{rendered}"
);
}
/// Concurrent stress: many guards on the same worker should leave the
/// counter back at zero once all guards drop.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_register_drop_returns_to_zero() {
let (registry, _) = registry_with_mock_clock(Duration::from_secs(60));
let w = WorkerId("w0".into());
let mut set = tokio::task::JoinSet::new();
for _ in 0..100 {
let r = Arc::clone(&registry);
let wid = w.clone();
set.spawn(async move {
for _ in 0..100 {
let _g = r.register(wid.clone(), "test://1-1", 1, 1);
// Yield occasionally so tasks interleave.
tokio::task::yield_now().await;
}
});
}
while set.join_next().await.is_some() {}
assert_eq!(registry.prefill_load(&w), 0);
assert_eq!(registry.decode_load(&w), 0);
assert_eq!(registry.inflight_count(), 0);
}
}
@@ -0,0 +1,675 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Cache-aware-ZMQ selection policy.
//!
//! Combines the KV-event-fed [`HashTree`] with active-load scoring and
//! tokenizer-driven block-hash lookup to pick the worker most likely to
//! already hold the request's prefix in its KV cache.
//!
//! # Selection algorithm
//!
//! Given `workers` (already filtered to healthy + matching pool by the
//! caller) and a `SelectionContext` carrying the JSON request body:
//!
//! 1. **Load-imbalance fast-path.** If `max_load - min_load >
//! balance_abs_threshold` AND `max_load > min_load *
//! balance_rel_threshold`, skip the cache lookup and pick the
//! lowest-load worker. This prevents one hot worker from dominating
//! cache-aware selection while every other worker idles.
//! 2. **Tokenize.** Pull the prompt text out of the JSON body (`messages` or
//! `prompt` field), run it through the per-model tokenizer. On any
//! failure (no body, no tokenizer, encode error, empty tokens), fall
//! through to step 4 (min-load fallback).
//! 3. **Hash + match.** Compute block hashes via
//! [`super::kv_events::compute_block_hashes`], query the shared hash tree
//! for the longest matching prefix. If `match_rate > cache_threshold`,
//! pick the lowest-load worker whose `url` appears in the match result.
//! Otherwise, fall through.
//! 4. **Min-load fallback.** Pick the lowest-load worker by
//! `Worker::active_load()`.
//!
//! The implementation never returns `None` for a non-empty `workers` slice;
//! a misconfigured tree or tokenizer degrades to round-robin-with-load
//! tiebreak, not a routing failure.
use crate::config::CacheAwareConfig;
use crate::discovery::ModelId;
use crate::policies::kv_events::{compute_block_hashes, BlockSizeOracle, HashTree};
use crate::policies::{Policy, SelectionContext};
use crate::tokenizer::{adapter, TokenizerRegistry};
use crate::workers::Worker;
use std::sync::Arc;
/// Selection policy that scores candidates by tree-overlap with the
/// request's prefix and falls back to load-based picking when the tree
/// doesn't have useful signal.
pub struct CacheAwareZmqPolicy {
config: CacheAwareConfig,
/// Per-process KV-event hash tree, fed by the indexer. Cheap to
/// clone an `Arc`; we never write to the tree from here.
tree: Arc<HashTree>,
/// Tokenizer registry — selection reads `model_id` from the context
/// and looks up the per-model tokenizer.
tokenizers: Arc<TokenizerRegistry>,
/// Worker-sourced block size, shared with the `KvEventIndex` that
/// seeds it on worker registration. Read once per request; if
/// `None` (no worker has reported a `page_size` yet) the policy
/// degrades to min-load — the router cannot hash a prompt without
/// a block size that matches what the worker publishes.
block_size_oracle: Arc<BlockSizeOracle>,
}
impl std::fmt::Debug for CacheAwareZmqPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CacheAwareZmqPolicy")
.field("config", &self.config)
.field("tree_nodes", &self.tree.node_count())
.finish()
}
}
impl CacheAwareZmqPolicy {
pub fn new(
config: CacheAwareConfig,
tree: Arc<HashTree>,
tokenizers: Arc<TokenizerRegistry>,
block_size_oracle: Arc<BlockSizeOracle>,
) -> Self {
Self {
config,
tree,
tokenizers,
block_size_oracle,
}
}
/// Lowest-load worker — ties broken by stable iteration order (which
/// is the order the registry returned, i.e. dashmap-undefined). For
/// production traffic the ties are rare; tests pin the load skew.
fn pick_min_load(workers: &[Arc<Worker>]) -> Option<Arc<Worker>> {
workers
.iter()
.min_by_key(|w| w.active_load())
.map(Arc::clone)
}
/// Detect load imbalance. Returns `true` when the spread between max
/// and min load is large enough that cache-aware routing would dump
/// even more on the hot worker.
fn is_imbalanced(&self, workers: &[Arc<Worker>]) -> bool {
let (min_load, max_load) = workers.iter().fold((usize::MAX, 0usize), |(mn, mx), w| {
let l = w.active_load();
(mn.min(l), mx.max(l))
});
let min_load = if min_load == usize::MAX { 0 } else { min_load };
let abs_diff = max_load.saturating_sub(min_load);
let rel_threshold = (min_load as f32 * self.config.balance_rel_threshold) as usize;
abs_diff > self.config.balance_abs_threshold && max_load > rel_threshold
}
/// Extract a prompt-text candidate from a JSON request body. Returns
/// `None` if the body isn't valid JSON or doesn't contain a routable
/// text field; the caller falls back to non-cache-aware routing.
///
/// Supported shapes (in priority order):
/// 1. `"prompt": "..."` — `/v1/completions`-style.
/// 2. `"prompt": ["...", "..."]` — `/v1/completions` array form;
/// concatenated with `"\n"`.
/// 3. `"messages": [{"content": "..."}]` — `/v1/chat/completions`
/// with string content; concatenated with `"\n"`.
/// 4. `"messages": [{"content": [{"text": "..."}]}]` — chat with
/// multimodal content blocks; text-only blocks concatenated.
/// 5. `"text": "..."` — SGLang `/generate` native form.
///
/// Anything else yields `None`.
fn extract_prompt_text(body: &[u8]) -> Option<String> {
let v: serde_json::Value = serde_json::from_slice(body).ok()?;
if let Some(s) = v.get("prompt").and_then(|p| p.as_str()) {
return Some(s.to_string());
}
if let Some(arr) = v.get("prompt").and_then(|p| p.as_array()) {
let parts: Vec<&str> = arr.iter().filter_map(|x| x.as_str()).collect();
if !parts.is_empty() {
return Some(parts.join("\n"));
}
}
if let Some(msgs) = v.get("messages").and_then(|m| m.as_array()) {
let mut buf = String::new();
for m in msgs {
match m.get("content") {
Some(serde_json::Value::String(s)) => {
if !buf.is_empty() {
buf.push('\n');
}
buf.push_str(s);
}
Some(serde_json::Value::Array(parts)) => {
for part in parts {
if let Some(t) = part.get("text").and_then(|t| t.as_str()) {
if !buf.is_empty() {
buf.push('\n');
}
buf.push_str(t);
}
}
}
_ => {}
}
}
if !buf.is_empty() {
return Some(buf);
}
}
if let Some(s) = v.get("text").and_then(|t| t.as_str()) {
return Some(s.to_string());
}
None
}
/// Tokenize `text` for `model_id`. Returns `None` if no tokenizer is
/// loaded (the model_id may be misconfigured) or if encoding fails.
/// Errors log at debug — they degrade routing but are not fatal.
fn tokenize(&self, model_id: &ModelId, text: &str) -> Option<Vec<u32>> {
let tokenizer = self.tokenizers.get(&model_id.0)?;
match adapter::encode(&tokenizer, text) {
Ok(ids) if !ids.is_empty() => Some(ids),
Ok(_) => None,
Err(e) => {
tracing::debug!(
model = %model_id,
error = %e,
"cache-aware-zmq: tokenize failed; falling back to min-load",
);
None
}
}
}
}
impl Policy for CacheAwareZmqPolicy {
fn select(&self, workers: &[Arc<Worker>], ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> {
if workers.is_empty() {
return None;
}
// 1. Load-imbalance fast-path: even the best cache hit gets
// dropped in favour of evening out load.
if self.is_imbalanced(workers) {
return Self::pick_min_load(workers);
}
// 2. Extract the prompt text.
let body = match ctx.request_body() {
Some(b) if !b.is_empty() => b,
_ => return Self::pick_min_load(workers),
};
let Some(text) = Self::extract_prompt_text(body) else {
return Self::pick_min_load(workers);
};
// 3. Tokenize + hash + match.
let Some(tokens) = self.tokenize(ctx.model(), &text) else {
return Self::pick_min_load(workers);
};
// Source block_size from the worker — the router can only hash
// prompts at the block size the workers publish at. If no worker
// has registered yet (oracle empty), cache-aware routing has no
// ground truth to score against; fall back to min-load.
let Some(block_size) = self.block_size_oracle.get() else {
return Self::pick_min_load(workers);
};
let block_hashes = compute_block_hashes(&tokens, block_size as usize);
if block_hashes.is_empty() {
return Self::pick_min_load(workers);
}
let matched = self.tree.match_prefix(None, &block_hashes);
let match_rate = matched.matched_blocks as f32 / block_hashes.len() as f32;
tracing::debug!(
model = %ctx.model(),
n_blocks = block_hashes.len(),
matched_blocks = matched.matched_blocks,
match_rate,
cache_threshold = self.config.cache_threshold,
"cache-aware-zmq match_prefix",
);
if match_rate <= self.config.cache_threshold || matched.workers.is_empty() {
return Self::pick_min_load(workers);
}
// Among workers in the matched set, pick the lowest-load one.
let matched_urls: std::collections::HashSet<&str> =
matched.workers.iter().map(|kw| kw.url.as_str()).collect();
let best_matched: Option<Arc<Worker>> = workers
.iter()
.filter(|w| matched_urls.contains(w.url.as_str()))
.min_by_key(|w| w.active_load())
.map(Arc::clone);
best_matched.or_else(|| Self::pick_min_load(workers))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::CacheAwareConfig;
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use crate::policies::kv_events::tree::KvWorkerId;
use crate::policies::kv_events::HashTree;
fn cfg_default() -> CacheAwareConfig {
CacheAwareConfig {
cache_threshold: 0.5,
balance_abs_threshold: 32,
balance_rel_threshold: 1.1,
}
}
/// Helper: build a `BlockSizeOracle` already primed to the test's
/// canonical block size (4). Mirrors what `KvEventIndex::add_worker`
/// would do when the first real worker registers.
fn oracle_for_tests(block_size: u32) -> Arc<BlockSizeOracle> {
let o = BlockSizeOracle::new();
o.try_set(block_size)
.expect("fresh oracle accepts first set");
o
}
fn worker(url: &str, model_id: &str) -> Arc<Worker> {
Arc::new(Worker::new(WorkerSpec {
id: WorkerId(url.into()),
url: url.into(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId(model_id.into())],
bootstrap_port: None,
}))
}
fn tokenizer_registry_with_tiny() -> Arc<TokenizerRegistry> {
let cfg = crate::config::Config {
server: crate::config::ServerConfig {
host: "0".into(),
port: 0,
},
observability: Default::default(),
models: vec![crate::config::ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: crate::config::PolicyKind::RoundRobin,
circuit_breaker: None,
cache_aware: None,
}],
discovery: crate::config::DiscoveryConfig {
backend: crate::config::DiscoveryBackend::StaticUrls(
crate::config::StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
},
),
},
proxy: crate::config::ProxyConfig::default(),
active_load: crate::config::ActiveLoadConfig::default(),
};
Arc::new(TokenizerRegistry::load_from_config(&cfg).expect("load tiny tokenizer"))
}
/// Empty workers list returns None (parity with other policies).
#[test]
fn empty_workers_returns_none() {
let tree = Arc::new(HashTree::new());
let policy = CacheAwareZmqPolicy::new(
cfg_default(),
tree,
tokenizer_registry_with_tiny(),
oracle_for_tests(4),
);
let model = ModelId("tiny".into());
let ctx = SelectionContext::new(&model, Some(b"{\"prompt\":\"hi\"}"));
assert!(policy.select(&[], &ctx).is_none());
}
/// Empty tree: no overlap signal anywhere, fall through to min-load.
#[test]
fn empty_tree_falls_back_to_min_load() {
let tree = Arc::new(HashTree::new());
let policy = CacheAwareZmqPolicy::new(
cfg_default(),
tree,
tokenizer_registry_with_tiny(),
oracle_for_tests(4),
);
let w0 = worker("http://w0:30000", "tiny");
let w1 = worker("http://w1:30000", "tiny");
// Bump w0's load so min-load picks w1 deterministically.
let _g = w0.load_guard();
let _g2 = w0.load_guard();
let workers = vec![Arc::clone(&w0), Arc::clone(&w1)];
let model = ModelId("tiny".into());
let body = br#"{"prompt":"hello world"}"#;
let ctx = SelectionContext::new(&model, Some(body));
let chosen = policy.select(&workers, &ctx).expect("must pick");
assert_eq!(chosen.url, "http://w1:30000");
}
/// Tree contains w0's prefix; cache-aware selection picks w0 even
/// though w1 has lower load (the load skew is below the imbalance
/// threshold, so cache wins).
#[test]
fn non_empty_tree_highest_overlap_wins() {
let tree = Arc::new(HashTree::new());
// Insert w0's tokens into the tree. The tiny tokenizer's hash
// chain for our input is whatever `compute_block_hashes` returns;
// we mimic the policy's hashing path so the test stays
// deterministic against tokenizer changes.
let registry = tokenizer_registry_with_tiny();
let text = "hello world hello world hello world"; // longer → more blocks
let tok = registry.get("tiny").unwrap();
let ids = adapter::encode(&tok, text).unwrap();
let block_size = 4u32;
let hashes = compute_block_hashes(&ids, block_size as usize);
assert!(
!hashes.is_empty(),
"tiny tokenizer must produce at least one full block",
);
tree.insert(&KvWorkerId::new("http://w0:30000".into(), 0), None, &hashes);
let policy = CacheAwareZmqPolicy::new(
CacheAwareConfig {
cache_threshold: 0.0, // any match counts
balance_abs_threshold: 32,
balance_rel_threshold: 1.1,
},
tree,
registry,
oracle_for_tests(4),
);
let w0 = worker("http://w0:30000", "tiny");
let w1 = worker("http://w1:30000", "tiny");
let workers = vec![Arc::clone(&w0), Arc::clone(&w1)];
let model = ModelId("tiny".into());
let body = serde_json::to_vec(&serde_json::json!({"prompt": text})).unwrap();
let ctx = SelectionContext::new(&model, Some(&body));
let chosen = policy.select(&workers, &ctx).expect("must pick");
assert_eq!(chosen.url, "http://w0:30000");
}
/// Two workers both hold the prefix; the lower-load one wins.
#[test]
fn tie_break_by_lowest_active_load() {
let tree = Arc::new(HashTree::new());
let registry = tokenizer_registry_with_tiny();
let text = "hello world hello world hello world";
let tok = registry.get("tiny").unwrap();
let ids = adapter::encode(&tok, text).unwrap();
let block_size = 4u32;
let hashes = compute_block_hashes(&ids, block_size as usize);
assert!(!hashes.is_empty());
// Both workers hold the prefix.
tree.insert(&KvWorkerId::new("http://w0:30000".into(), 0), None, &hashes);
tree.insert(&KvWorkerId::new("http://w1:30000".into(), 0), None, &hashes);
let policy = CacheAwareZmqPolicy::new(
CacheAwareConfig {
cache_threshold: 0.0,
balance_abs_threshold: 32,
balance_rel_threshold: 1.1,
},
tree,
registry,
oracle_for_tests(4),
);
let w0 = worker("http://w0:30000", "tiny");
let w1 = worker("http://w1:30000", "tiny");
// Bump w0 to load=1; w1 is at 0 — tiebreak picks w1.
let _g = w0.load_guard();
let workers = vec![Arc::clone(&w0), Arc::clone(&w1)];
let model = ModelId("tiny".into());
let body = serde_json::to_vec(&serde_json::json!({"prompt": text})).unwrap();
let ctx = SelectionContext::new(&model, Some(&body));
let chosen = policy.select(&workers, &ctx).expect("must pick");
assert_eq!(chosen.url, "http://w1:30000");
}
/// w0 holds the prefix but is heavily overloaded → imbalance branch
/// skips cache-aware and picks w1.
#[test]
fn imbalanced_pool_skips_cache_check() {
let tree = Arc::new(HashTree::new());
let registry = tokenizer_registry_with_tiny();
let text = "hello world hello world hello world";
let tok = registry.get("tiny").unwrap();
let ids = adapter::encode(&tok, text).unwrap();
let block_size = 4u32;
let hashes = compute_block_hashes(&ids, block_size as usize);
tree.insert(&KvWorkerId::new("http://w0:30000".into(), 0), None, &hashes);
let policy = CacheAwareZmqPolicy::new(
CacheAwareConfig {
cache_threshold: 0.0, // would normally always match
balance_abs_threshold: 5,
balance_rel_threshold: 2.0,
},
tree,
registry,
oracle_for_tests(4),
);
let w0 = worker("http://w0:30000", "tiny");
let w1 = worker("http://w1:30000", "tiny");
// Bump w0 well above the imbalance threshold.
let mut guards = Vec::new();
for _ in 0..20 {
guards.push(w0.load_guard());
}
let workers = vec![Arc::clone(&w0), Arc::clone(&w1)];
let model = ModelId("tiny".into());
let body = serde_json::to_vec(&serde_json::json!({"prompt": text})).unwrap();
let ctx = SelectionContext::new(&model, Some(&body));
let chosen = policy.select(&workers, &ctx).expect("must pick");
assert_eq!(chosen.url, "http://w1:30000", "imbalance must dominate");
}
/// Tokenizer is missing for the requested model → fall back to
/// min-load (no panic, no error).
#[test]
fn missing_tokenizer_falls_back_to_min_load() {
let tree = Arc::new(HashTree::new());
let empty_registry = Arc::new(TokenizerRegistry::default());
let policy =
CacheAwareZmqPolicy::new(cfg_default(), tree, empty_registry, oracle_for_tests(4));
let w0 = worker("http://w0:30000", "tiny");
let w1 = worker("http://w1:30000", "tiny");
let _g = w0.load_guard();
let _g2 = w0.load_guard();
let workers = vec![Arc::clone(&w0), Arc::clone(&w1)];
let model = ModelId("tiny".into());
let body = br#"{"prompt":"hello"}"#;
let ctx = SelectionContext::new(&model, Some(body));
let chosen = policy.select(&workers, &ctx).expect("must pick");
assert_eq!(chosen.url, "http://w1:30000");
}
/// Missing body → fall back to min-load.
#[test]
fn missing_request_body_falls_back_to_min_load() {
let tree = Arc::new(HashTree::new());
let policy = CacheAwareZmqPolicy::new(
cfg_default(),
tree,
tokenizer_registry_with_tiny(),
oracle_for_tests(4),
);
let w0 = worker("http://w0:30000", "tiny");
let w1 = worker("http://w1:30000", "tiny");
let _g = w0.load_guard();
let _g2 = w0.load_guard();
let workers = vec![Arc::clone(&w0), Arc::clone(&w1)];
let model = ModelId("tiny".into());
let ctx = SelectionContext::new(&model, None);
let chosen = policy.select(&workers, &ctx).expect("must pick");
assert_eq!(chosen.url, "http://w1:30000");
}
/// Body present but no recognizable prompt field → fall back.
#[test]
fn body_without_prompt_field_falls_back_to_min_load() {
let tree = Arc::new(HashTree::new());
let policy = CacheAwareZmqPolicy::new(
cfg_default(),
tree,
tokenizer_registry_with_tiny(),
oracle_for_tests(4),
);
let w0 = worker("http://w0:30000", "tiny");
let w1 = worker("http://w1:30000", "tiny");
let _g = w0.load_guard();
let _g2 = w0.load_guard();
let workers = vec![Arc::clone(&w0), Arc::clone(&w1)];
let model = ModelId("tiny".into());
let body = br#"{"frobnicate":42}"#;
let ctx = SelectionContext::new(&model, Some(body));
let chosen = policy.select(&workers, &ctx).expect("must pick");
assert_eq!(chosen.url, "http://w1:30000");
}
/// Body has a non-text shape that yields zero tokens → fall back.
/// (Tokenizer always returns ≥0 ids; an empty string yields the
/// empty vec, then `compute_block_hashes` returns empty too.)
#[test]
fn empty_text_falls_back_to_min_load() {
let tree = Arc::new(HashTree::new());
let policy = CacheAwareZmqPolicy::new(
cfg_default(),
tree,
tokenizer_registry_with_tiny(),
oracle_for_tests(4),
);
let w0 = worker("http://w0:30000", "tiny");
let w1 = worker("http://w1:30000", "tiny");
let _g = w0.load_guard();
let _g2 = w0.load_guard();
let workers = vec![Arc::clone(&w0), Arc::clone(&w1)];
let model = ModelId("tiny".into());
let body = br#"{"prompt":""}"#;
let ctx = SelectionContext::new(&model, Some(body));
let chosen = policy.select(&workers, &ctx).expect("must pick");
assert_eq!(chosen.url, "http://w1:30000");
}
/// Match rate below the threshold → fall back. Threshold = 0.99
/// means the tree must match every single block; we insert an
/// UNRELATED chain so the rate is 0.
#[test]
fn low_match_rate_falls_back_to_min_load() {
let tree = Arc::new(HashTree::new());
// Tree contains a chain unrelated to the test's request.
tree.insert(
&KvWorkerId::new("http://w0:30000".into(), 0),
None,
&[999, 998, 997],
);
let policy = CacheAwareZmqPolicy::new(
CacheAwareConfig {
cache_threshold: 0.99,
balance_abs_threshold: 32,
balance_rel_threshold: 1.1,
},
tree,
tokenizer_registry_with_tiny(),
oracle_for_tests(4),
);
let w0 = worker("http://w0:30000", "tiny");
let w1 = worker("http://w1:30000", "tiny");
let _g = w0.load_guard();
let _g2 = w0.load_guard();
let workers = vec![Arc::clone(&w0), Arc::clone(&w1)];
let model = ModelId("tiny".into());
let body = br#"{"prompt":"hello world hello world hello world"}"#;
let ctx = SelectionContext::new(&model, Some(body));
let chosen = policy.select(&workers, &ctx).expect("must pick");
assert_eq!(chosen.url, "http://w1:30000");
}
/// Chat completions shape with `messages[*].content` string.
#[test]
fn extract_prompt_chat_string_content() {
let body = br#"{"model":"x","messages":[{"role":"user","content":"hello"}]}"#;
let s = CacheAwareZmqPolicy::extract_prompt_text(body).unwrap();
assert_eq!(s, "hello");
}
/// Chat completions shape with multimodal content blocks (text parts).
#[test]
fn extract_prompt_chat_block_content() {
let body = br#"{"messages":[{"role":"user","content":[{"type":"text","text":"hi"},{"type":"image_url","image_url":"x"}]}]}"#;
let s = CacheAwareZmqPolicy::extract_prompt_text(body).unwrap();
assert_eq!(s, "hi");
}
/// `/v1/completions` array form is joined with newlines.
#[test]
fn extract_prompt_completions_array() {
let body = br#"{"prompt":["a","b","c"]}"#;
let s = CacheAwareZmqPolicy::extract_prompt_text(body).unwrap();
assert_eq!(s, "a\nb\nc");
}
/// SGLang native `text` field.
#[test]
fn extract_prompt_sglang_text_field() {
let body = br#"{"text":"abc"}"#;
let s = CacheAwareZmqPolicy::extract_prompt_text(body).unwrap();
assert_eq!(s, "abc");
}
/// Unknown shape → None.
#[test]
fn extract_prompt_unknown_shape_returns_none() {
let body = br#"{"frobnicate":42}"#;
assert!(CacheAwareZmqPolicy::extract_prompt_text(body).is_none());
}
/// Lifecycle: removing a worker from the tree via `clear_worker`
/// makes subsequent matches miss; the policy then falls back to
/// min-load.
#[test]
fn lifecycle_clear_worker_removes_overlap() {
let tree = Arc::new(HashTree::new());
let registry = tokenizer_registry_with_tiny();
let text = "hello world hello world hello world";
let tok = registry.get("tiny").unwrap();
let ids = adapter::encode(&tok, text).unwrap();
let block_size = 4u32;
let hashes = compute_block_hashes(&ids, block_size as usize);
let kw0 = KvWorkerId::new("http://w0:30000".into(), 0);
tree.insert(&kw0, None, &hashes);
let policy = CacheAwareZmqPolicy::new(
CacheAwareConfig {
cache_threshold: 0.0,
balance_abs_threshold: 32,
balance_rel_threshold: 1.1,
},
tree.clone(),
registry,
oracle_for_tests(4),
);
let w0 = worker("http://w0:30000", "tiny");
let w1 = worker("http://w1:30000", "tiny");
let workers = vec![Arc::clone(&w0), Arc::clone(&w1)];
let model = ModelId("tiny".into());
let body = serde_json::to_vec(&serde_json::json!({"prompt": text})).unwrap();
// Before clear: w0 wins.
let ctx = SelectionContext::new(&model, Some(&body));
let chosen = policy.select(&workers, &ctx).expect("must pick");
assert_eq!(chosen.url, "http://w0:30000");
// After clear: tree no longer attributes the prefix to w0.
tree.clear_worker(&kw0);
// Bump w0's load so min-load fallback distinguishes from w1.
let _g = w0.load_guard();
let _g2 = w0.load_guard();
let chosen2 = policy.select(&workers, &ctx).expect("must pick");
assert_eq!(chosen2.url, "http://w1:30000");
}
}
@@ -0,0 +1,186 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use crate::config::{Config, ModelConfig, PolicyKind};
use crate::discovery::ModelId;
use crate::policies::{
cache_aware_zmq::CacheAwareZmqPolicy,
kv_events::{BlockSizeOracle, HashTree},
power_of_two::PowerOfTwoChoicesPolicy,
random::RandomPolicy,
round_robin::RoundRobinPolicy,
Policy, PolicyRegistry,
};
use crate::tokenizer::TokenizerRegistry;
use anyhow::Result;
use std::sync::Arc;
/// Construct a policy for a single model from its [`ModelConfig`] and the
/// process-shared `HashTree` + `TokenizerRegistry` + `BlockSizeOracle`.
///
/// The tree, tokenizer registry, and oracle are only consulted by the
/// cache-aware-zmq variant; other policies ignore them. Callers building
/// all policies for the same process pass the same instances to every
/// model.
pub fn build_policy(
model: &ModelConfig,
tree: Arc<HashTree>,
tokenizers: Arc<TokenizerRegistry>,
block_size_oracle: Arc<BlockSizeOracle>,
) -> Arc<dyn Policy> {
match model.policy {
PolicyKind::RoundRobin => Arc::new(RoundRobinPolicy::new()),
PolicyKind::Random => Arc::new(RandomPolicy::new()),
PolicyKind::PowerOfTwo => Arc::new(PowerOfTwoChoicesPolicy::new()),
PolicyKind::CacheAwareZmq => {
let cache_cfg = model.cache_aware.unwrap_or_default();
Arc::new(CacheAwareZmqPolicy::new(
cache_cfg,
tree,
tokenizers,
block_size_oracle,
))
}
}
}
/// Compatibility shim used by tests + non-cache-aware code paths. Builds
/// a policy without wiring the cache-aware dependencies; rejects
/// `CacheAwareZmq` to keep the call sites that don't have a `HashTree` /
/// `TokenizerRegistry` to hand from accidentally compiling.
#[cfg(test)]
pub fn build_policy_kind_only(kind: PolicyKind) -> Arc<dyn Policy> {
match kind {
PolicyKind::RoundRobin => Arc::new(RoundRobinPolicy::new()),
PolicyKind::Random => Arc::new(RandomPolicy::new()),
PolicyKind::PowerOfTwo => Arc::new(PowerOfTwoChoicesPolicy::new()),
PolicyKind::CacheAwareZmq => {
// Provide an empty tree + empty tokenizer registry + fresh
// oracle so the test policy is constructible. Production
// callers go through `build_policy` with the real
// process-shared instances.
Arc::new(CacheAwareZmqPolicy::new(
crate::config::CacheAwareConfig::default(),
Arc::new(HashTree::new()),
Arc::new(TokenizerRegistry::default()),
BlockSizeOracle::new(),
))
}
}
}
pub fn build_registry(
cfg: &Config,
tree: Arc<HashTree>,
tokenizers: Arc<TokenizerRegistry>,
block_size_oracle: Arc<BlockSizeOracle>,
) -> Result<PolicyRegistry> {
let reg = PolicyRegistry::default();
for m in &cfg.models {
reg.insert(
ModelId(m.id.clone()),
build_policy(
m,
Arc::clone(&tree),
Arc::clone(&tokenizers),
Arc::clone(&block_size_oracle),
),
);
}
Ok(reg)
}
/// Convenience for tests + non-cache-aware callers: builds a registry with
/// a fresh, empty `HashTree` and an empty `TokenizerRegistry`. The
/// cache-aware-zmq policy will then degrade to min-load (no tokenizer +
/// no worker-published block size → fallback) — which is exactly what
/// the legacy tests assume.
///
/// Production callers go through [`build_registry`] with the real
/// process-shared instances.
pub fn build_registry_with_defaults(cfg: &Config) -> Result<PolicyRegistry> {
build_registry(
cfg,
Arc::new(HashTree::new()),
Arc::new(TokenizerRegistry::default()),
BlockSizeOracle::new(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
ActiveLoadConfig, Config, DiscoveryBackend, DiscoveryConfig, ModelConfig, ProxyConfig,
ServerConfig, StaticUrlsDiscoveryConfig,
};
use crate::config::PolicyKind;
fn cfg_with_models(policies: &[(&str, PolicyKind)]) -> Config {
Config {
server: ServerConfig {
host: "0".into(),
port: 0,
},
observability: Default::default(),
models: policies
.iter()
.map(|(id, p)| ModelConfig {
id: (*id).into(),
tokenizer_path: "/tmp/x".into(),
policy: *p,
circuit_breaker: None,
cache_aware: None,
})
.collect(),
discovery: DiscoveryConfig {
backend: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
}),
},
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
}
}
#[test]
fn build_policy_kind_only_covers_all_variants() {
// Trivially total — the match is exhaustive over `PolicyKind`.
let _ = build_policy_kind_only(PolicyKind::RoundRobin);
let _ = build_policy_kind_only(PolicyKind::Random);
let _ = build_policy_kind_only(PolicyKind::PowerOfTwo);
let _ = build_policy_kind_only(PolicyKind::CacheAwareZmq);
}
#[test]
fn registry_assigns_per_model() {
let cfg = cfg_with_models(&[
("qwen", PolicyKind::RoundRobin),
("deepseek", PolicyKind::Random),
]);
let tree = Arc::new(HashTree::new());
let tokenizers = Arc::new(TokenizerRegistry::default());
let reg = build_registry(&cfg, tree, tokenizers, BlockSizeOracle::new()).unwrap();
assert!(reg.get(&ModelId("qwen".into())).is_some());
assert!(reg.get(&ModelId("deepseek".into())).is_some());
assert!(reg.get(&ModelId("missing".into())).is_none());
}
#[test]
fn cache_aware_zmq_builds_via_factory() {
let cfg = cfg_with_models(&[("modelA", PolicyKind::CacheAwareZmq)]);
let tree = Arc::new(HashTree::new());
let tokenizers = Arc::new(TokenizerRegistry::default());
let reg = build_registry(&cfg, tree, tokenizers, BlockSizeOracle::new()).unwrap();
let p = reg.get(&ModelId("modelA".into())).unwrap();
// Down-cast probe via Debug — cheaper than carrying a type-tag
// on the trait. Pinning the debug repr is fine because the field
// name is part of the file's public test surface.
let dbg = format!("{p:?}");
assert!(
dbg.contains("CacheAwareZmqPolicy"),
"expected CacheAwareZmqPolicy debug repr, got: {dbg}",
);
}
}
@@ -0,0 +1,150 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Process-shared per-(cache-aware-zmq) `block_size`, sourced from the
//! workers themselves.
//!
//! # Why an oracle instead of a config field?
//!
//! `compute_block_hashes` must hash with the **same** block size the
//! worker uses to publish KV-cache events; otherwise every cache-aware
//! lookup misses silently. The worker advertises its `page_size` via
//! `/server_info` (parsed into [`crate::policies::kv_events::EventConfig::block_size`]).
//! Earlier versions of sgl-router carried a static `block_size` field on
//! `CacheAwareConfig`; nothing reconciled it with the worker-reported
//! value, so a mismatch silently destroyed cache-hit routing.
//!
//! Dynamo's design treats `kv_cache_block_size` as a property of the
//! `ModelDeploymentCard` populated by the worker registrar (see
//! `~/dynamo/components/src/dynamo/sglang/register.py`); a mismatch
//! across workers for the same model is rejected loudly
//! (`lib/kv-router/src/standalone_indexer/registry.rs::bail!`). The
//! oracle here is the sgl-router analog — first worker establishes the
//! value, mismatches are refused.
//!
//! # Single oracle vs per-model
//!
//! For now the oracle is process-wide. Realistic deployments use one
//! `page_size` across the cluster, so a single value suffices and
//! mismatches across models indicate misconfiguration the operator
//! should see. A per-model oracle would require threading `ModelId`
//! through `KvEventIndex::add_worker`; that refactor can land later
//! without changing the oracle's public surface.
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
/// First-wins, idempotent block-size publisher.
///
/// Internally an `AtomicU32` where 0 means "not yet known". Use
/// [`Self::try_set`] to publish a worker-reported value and
/// [`Self::get`] to read at routing time.
#[derive(Debug, Default)]
pub struct BlockSizeOracle {
value: AtomicU32,
}
/// Returned by [`BlockSizeOracle::try_set`] when the candidate disagrees
/// with the already-established value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BlockSizeMismatch {
pub established: u32,
pub candidate: u32,
}
impl BlockSizeOracle {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Returns the established block size, or `None` if no worker has
/// reported one yet. Routing-time consumers (`CacheAwareZmqPolicy`)
/// fall back to min-load when this is `None`, because they cannot
/// hash a prompt without a block size.
pub fn get(&self) -> Option<u32> {
let v = self.value.load(Ordering::Relaxed);
if v == 0 {
None
} else {
Some(v)
}
}
/// Publish a candidate block size. Returns the established value on
/// success (idempotent: same candidate as already set is `Ok`);
/// returns `Err(BlockSizeMismatch)` when the candidate disagrees.
///
/// `candidate == 0` is rejected because 0 is reserved as the "not
/// yet known" sentinel.
pub fn try_set(&self, candidate: u32) -> Result<u32, BlockSizeMismatch> {
if candidate == 0 {
return Err(BlockSizeMismatch {
established: self.value.load(Ordering::Relaxed),
candidate,
});
}
match self
.value
.compare_exchange(0, candidate, Ordering::Relaxed, Ordering::Relaxed)
{
Ok(_) => Ok(candidate),
Err(existing) if existing == candidate => Ok(existing),
Err(existing) => Err(BlockSizeMismatch {
established: existing,
candidate,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_oracle_returns_none() {
let oracle = BlockSizeOracle::new();
assert_eq!(oracle.get(), None);
}
#[test]
fn first_set_establishes_the_value() {
let oracle = BlockSizeOracle::new();
assert_eq!(oracle.try_set(64), Ok(64));
assert_eq!(oracle.get(), Some(64));
}
#[test]
fn matching_set_is_idempotent() {
let oracle = BlockSizeOracle::new();
assert_eq!(oracle.try_set(64), Ok(64));
assert_eq!(oracle.try_set(64), Ok(64));
assert_eq!(oracle.try_set(64), Ok(64));
assert_eq!(oracle.get(), Some(64));
}
#[test]
fn mismatching_set_fails_without_changing_state() {
let oracle = BlockSizeOracle::new();
oracle.try_set(64).unwrap();
assert_eq!(
oracle.try_set(128),
Err(BlockSizeMismatch {
established: 64,
candidate: 128
})
);
assert_eq!(
oracle.get(),
Some(64),
"mismatched candidate must not overwrite established value"
);
}
#[test]
fn zero_candidate_is_rejected() {
let oracle = BlockSizeOracle::new();
assert!(oracle.try_set(0).is_err());
assert_eq!(oracle.get(), None);
}
}
@@ -0,0 +1,404 @@
//! Per-worker KV-event publisher discovery.
//!
//! Calls the worker's `/server_info` endpoint (extended on the SGLang
//! Python side) to learn where to connect its ZMQ KV-event publisher.
//! Returns an [`EventConfig`] on success or `Ok(None)` when the worker
//! is reachable but explicitly does not run an event publisher (older
//! SGLang, `kv-events-config` unset, `null` publisher, etc.).
//!
//! # Failure semantics
//!
//! - Network errors and 5xx responses are **transient** and retried
//! inside [`fetch_event_config`] up to [`FETCH_MAX_ATTEMPTS`] with
//! exponential backoff. If every attempt fails, the call returns
//! `Err(_)` so the caller can distinguish "definitely not publishing"
//! (`Ok(None)`) from "we couldn't tell" (`Err`).
//! - 4xx responses are non-retriable (the worker answered
//! authoritatively) and surface as `Err`.
//! - Caller behaviour: [`super::index::KvEventIndex::add_worker`] logs
//! the error and skips subscription, but the worker remains in the
//! broader router registry. Future re-discovery may retry.
use std::time::Duration;
use anyhow::{anyhow, Result};
use serde::Deserialize;
use tracing::{debug, warn};
use url::Url;
/// Per-worker KV-event publisher configuration, resolved to something the
/// gateway can directly use to open ZMQ SUB sockets.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventConfig {
/// The host the gateway should connect to. If the worker reports a
/// wildcard bind host (`*`, `0.0.0.0`, `::`) this is replaced by the
/// host parsed out of the worker URL; otherwise the explicit
/// `endpoint_host` is kept verbatim.
pub host: String,
/// Base port for rank 0. Per-rank port = `port_base + dp_rank`.
pub port_base: u16,
/// ZMQ topic prefix the gateway should SUBSCRIBE to.
pub topic: String,
/// Worker-reported `page_size`. Callers MUST compare against their
/// own configured `block_size`; a mismatch produces silent
/// miscompute since [`super::hash::compute_block_hashes`] is keyed
/// on the caller's value, not on this one.
pub block_size: u32,
/// Number of attention-DP ranks publishing. The gateway opens this
/// many SUB connections (one per rank), skipping any rank whose
/// `port_base + dp_rank` overflows `u16`.
pub dp_size: u32,
}
/// Default timeout for the `/server_info` introspection request. The
/// worker is on the same network as the gateway in production; 2 seconds
/// is generous and still bounds gateway-startup latency.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
/// Bounded retry for transient `/server_info` failures. A worker that just
/// booted may need a few hundred ms before its HTTP server accepts
/// requests; retry absorbs the race without permanently disabling
/// cache-aware routing for that worker.
const FETCH_MAX_ATTEMPTS: u32 = 3;
const FETCH_BACKOFF_BASE: Duration = Duration::from_millis(100);
/// Fetch the worker's KV-event publisher config via `/server_info`.
///
/// Returns:
/// - `Ok(Some(cfg))` when the worker exposed a usable `kv_events` block.
/// - `Ok(None)` when the worker is **reachable** but explicitly does not
/// expose one (older SGLang, `kv-events-config` unset, `null`
/// publisher, etc.). Cache-aware routing is disabled for that worker.
/// - `Err(_)` when `worker_url` cannot be parsed, OR when every transient
/// attempt failed (network error or 5xx). Caller decides whether to
/// retry; the worker is still added to the registry but cache-aware
/// routing is disabled until a future re-discovery.
pub async fn fetch_event_config(
worker_url: &str,
client: &reqwest::Client,
) -> Result<Option<EventConfig>> {
let parsed =
Url::parse(worker_url).map_err(|e| anyhow!("invalid worker_url {worker_url}: {e}"))?;
let worker_host = parsed
.host_str()
.ok_or_else(|| anyhow!("worker_url {worker_url} has no host"))?
.to_owned();
let server_info_url = format!("{}/server_info", worker_url.trim_end_matches('/'));
let body = fetch_with_retry(&server_info_url, worker_url, client).await?;
let block = match body.kv_events {
Some(b) => b,
None => {
debug!(
worker_url = worker_url,
"kv-events discovery: /server_info has no kv_events block; worker is not publishing"
);
return Ok(None);
}
};
// Wildcard bind hosts mean "any interface" on the worker side — the
// gateway has to connect to a routable address, which it learns from
// the worker URL.
let host = if matches!(
block.endpoint_host.as_str(),
"*" | "0.0.0.0" | "::" | "[::]"
) {
worker_host
} else {
block.endpoint_host
};
Ok(Some(EventConfig {
host,
port_base: block.endpoint_port_base,
topic: block.topic,
block_size: block.block_size,
dp_size: block.dp_size,
}))
}
/// Issue the `/server_info` request with bounded retry on transient errors
/// (network failures, 5xx). 4xx responses and JSON-parse errors are
/// non-retriable: the worker answered, just not with what we expect.
async fn fetch_with_retry(
server_info_url: &str,
worker_url: &str,
client: &reqwest::Client,
) -> Result<ServerInfoResponse> {
let mut last_err: Option<String> = None;
let mut delay = FETCH_BACKOFF_BASE;
for attempt in 1..=FETCH_MAX_ATTEMPTS {
match client
.get(server_info_url)
.timeout(DEFAULT_TIMEOUT)
.send()
.await
{
Err(e) => {
last_err = Some(format!("network error: {e}"));
warn!(
worker_url = worker_url,
attempt,
error = %e,
"kv-events discovery: /server_info request failed; will retry"
);
}
Ok(resp) if resp.status().is_server_error() => {
last_err = Some(format!("server error: {}", resp.status()));
warn!(
worker_url = worker_url,
attempt,
status = resp.status().as_u16(),
"kv-events discovery: /server_info returned 5xx; will retry"
);
}
Ok(resp) if !resp.status().is_success() => {
// 4xx — worker answered authoritatively, retrying won't help.
return Err(anyhow!(
"/server_info returned {} (non-retriable)",
resp.status()
));
}
Ok(resp) => {
return resp
.json::<ServerInfoResponse>()
.await
.map_err(|e| anyhow!("/server_info JSON parse failed: {e}"));
}
}
if attempt < FETCH_MAX_ATTEMPTS {
tokio::time::sleep(delay).await;
delay *= 2;
}
}
Err(anyhow!(
"/server_info failed after {} attempts: {}",
FETCH_MAX_ATTEMPTS,
last_err.unwrap_or_else(|| "unknown".into()),
))
}
#[derive(Deserialize)]
struct ServerInfoResponse {
#[serde(default)]
kv_events: Option<KvEventsBlock>,
}
#[derive(Deserialize)]
struct KvEventsBlock {
// `publisher` is captured for forward-compatibility but unused: the
// only publisher implementation supported on the gateway side is
// ZMQ. Keeping the field optional means a future SGLang that adds a
// non-ZMQ publisher string won't fail this deserialize; the
// resulting subscriber will still try to open a ZMQ connection on
// `endpoint_host:endpoint_port_base` and fail visibly there.
#[allow(dead_code)]
#[serde(default)]
publisher: Option<String>,
endpoint_host: String,
endpoint_port_base: u16,
#[serde(default)]
topic: String,
block_size: u32,
dp_size: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{routing::get, Json, Router};
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
/// Spin up a tiny axum server that returns `body` on GET /server_info.
/// Returns the base URL (`http://127.0.0.1:<port>`) and a shutdown handle.
async fn spawn_fake_worker(body: Arc<Value>) -> (String, oneshot::Sender<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let body_clone = body.clone();
let app = Router::new().route(
"/server_info",
get(move || {
let body = body_clone.clone();
async move { Json((*body).clone()) }
}),
);
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
(format!("http://127.0.0.1:{port}"), tx)
}
fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(1))
.build()
.unwrap()
}
/// Happy path: worker advertises a ZMQ publisher; gateway substitutes
/// `*` with the worker host.
#[tokio::test]
async fn fetch_returns_event_config_when_block_present() {
let body = Arc::new(json!({
"kv_events": {
"publisher": "zmq",
"endpoint_host": "*",
"endpoint_port_base": 5557,
"topic": "kv",
"block_size": 64,
"dp_size": 2,
}
}));
let (url, _shutdown) = spawn_fake_worker(body).await;
let got = fetch_event_config(&url, &client()).await.unwrap();
assert_eq!(
got,
Some(EventConfig {
host: "127.0.0.1".to_string(),
port_base: 5557,
topic: "kv".to_string(),
block_size: 64,
dp_size: 2,
})
);
}
/// Worker reports a specific bind host (not wildcard): gateway must
/// honour it instead of overwriting from the URL.
#[tokio::test]
async fn fetch_keeps_explicit_bind_host() {
let body = Arc::new(json!({
"kv_events": {
"publisher": "zmq",
"endpoint_host": "10.1.2.3",
"endpoint_port_base": 6000,
"topic": "",
"block_size": 128,
"dp_size": 1,
}
}));
let (url, _shutdown) = spawn_fake_worker(body).await;
let got = fetch_event_config(&url, &client()).await.unwrap();
assert_eq!(got.unwrap().host, "10.1.2.3");
}
/// Worker reachable but the `kv_events` field is null / missing:
/// caller should fall back to its static config.
#[tokio::test]
async fn fetch_returns_none_when_block_null() {
let body = Arc::new(json!({ "kv_events": null }));
let (url, _shutdown) = spawn_fake_worker(body).await;
let got = fetch_event_config(&url, &client()).await.unwrap();
assert!(got.is_none());
}
/// Worker is reachable but its `/server_info` response doesn't even
/// have a `kv_events` field (older SGLang).
#[tokio::test]
async fn fetch_returns_none_when_field_absent() {
let body = Arc::new(json!({ "other_stuff": 1 }));
let (url, _shutdown) = spawn_fake_worker(body).await;
let got = fetch_event_config(&url, &client()).await.unwrap();
assert!(got.is_none());
}
/// Connection-refused: no server at the URL. The retry loop exhausts
/// every attempt and propagates `Err`. The caller (KvEventIndex) logs
/// + skips the subscriber so a single flaky worker doesn't poison
/// startup, but the failure remains distinguishable from "worker
/// reachable but not publishing" (`Ok(None)`) so future re-discovery
/// can retry.
#[tokio::test]
async fn fetch_returns_err_on_connection_failure() {
let url = "http://127.0.0.1:1"; // port 1 is reserved / refused
let got = fetch_event_config(url, &client_fast_retry()).await;
assert!(got.is_err(), "expected Err on permanent connect refused");
}
/// HTTP client with a short timeout so the connection-failure tests don't
/// pay the full 2s × FETCH_MAX_ATTEMPTS budget.
fn client_fast_retry() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_millis(100))
.build()
.unwrap()
}
/// Invalid worker URL is the one case we propagate as Err — there's
/// nothing to fall back to and the operator config is broken.
#[tokio::test]
async fn fetch_returns_err_on_invalid_url() {
let got = fetch_event_config("not a url", &client()).await;
assert!(got.is_err());
}
/// Multi-DP publisher contract: a worker reporting `dp_size = 8`
/// produces an `EventConfig` with `dp_size = 8` and the base port
/// preserved. The subscriber is responsible for opening 8 SUB
/// sockets at `port_base + 0..8`; discovery just carries the values.
#[tokio::test]
async fn fetch_handles_multi_dp_publisher_dp_size_eight() {
let body = Arc::new(json!({
"kv_events": {
"publisher": "zmq",
"endpoint_host": "*",
"endpoint_port_base": 5557,
"topic": "kv",
"block_size": 64,
"dp_size": 8,
}
}));
let (url, _shutdown) = spawn_fake_worker(body).await;
let got = fetch_event_config(&url, &client()).await.unwrap().unwrap();
assert_eq!(got.dp_size, 8);
assert_eq!(got.port_base, 5557);
// Verify the implicit port range fits in u16.
let max_port = u32::from(got.port_base) + got.dp_size - 1;
assert!(
max_port <= u32::from(u16::MAX),
"max per-rank port {max_port} must fit in u16",
);
}
/// Documents the discovery-layer contract for ports near the u16 ceiling:
/// discovery does NOT validate `port_base + dp_size` overflow. The
/// subscriber MUST defend against `port_base + dp_rank > u16::MAX`
/// when opening sockets. Pinning this so that a future addition of
/// validation at the discovery layer is a deliberate design change,
/// not an accident.
#[tokio::test]
async fn fetch_accepts_high_port_base_near_u16_max() {
let body = Arc::new(json!({
"kv_events": {
"publisher": "zmq",
"endpoint_host": "*",
// u16::MAX = 65535. With dp_size = 4, ranks 2 and 3 would
// overflow. Discovery still returns the EventConfig as-is.
"endpoint_port_base": 65533,
"topic": "kv",
"block_size": 64,
"dp_size": 4,
}
}));
let (url, _shutdown) = spawn_fake_worker(body).await;
let got = fetch_event_config(&url, &client()).await.unwrap().unwrap();
assert_eq!(got.port_base, 65533);
assert_eq!(got.dp_size, 4);
let last_rank = u32::from(got.port_base) + got.dp_size - 1;
assert!(
last_rank > u32::from(u16::MAX),
"test fixture must put the last rank's port past u16::MAX so subscriber-level overflow handling is exercised by its own tests",
);
}
}
@@ -0,0 +1,259 @@
//! Block-hash compute matching SGLang's `radix_cache` worker.
//!
//! This is the gateway-side mirror of SGLang's per-page SHA256 chaining used
//! to derive `BlockStored.block_hashes` on workers (Python:
//! `python/sglang/srt/mem_cache/radix_cache.py::hash_page` and
//! `python/sglang/srt/mem_cache/utils.py::hash_str_to_int64`).
//!
//! ### Algorithm
//!
//! For each page (chunk of `block_size` tokens, last page possibly short):
//! 1. Initialize a SHA256 hasher.
//! 2. If a prior page exists, feed the prior page's **full 32-byte SHA256
//! digest** (raw bytes, not the truncated i64) into the hasher.
//! 3. Feed each token in the page as 4 little-endian unsigned bytes.
//! 4. Take the 32-byte digest as the new "prior" for the next page.
//! 5. Truncate the digest to a signed i64 by reading the first 16 hex chars
//! (top 64 bits) and reinterpreting as signed.
//!
//! ### Why no `parent_hash: Option<i64>` argument
//!
//! SGLang's worker chains on the **full 32-byte digest** of the parent block,
//! not on the i64 truncation. An `Option<i64>` is lossy — you cannot
//! reconstruct 32 bytes of SHA256 from 64 bits — so accepting one as a
//! "starting point" would silently produce hashes that disagree with the
//! worker.
//!
//! In the gateway we only need to compute hashes for an entire request from
//! scratch (i.e. starting with no parent). That matches the Python emission
//! path where the first page of a freshly-stored node may have a parent block
//! hash for the radix tree key, but the **page-hash computation itself**
//! starts from the parent's **full hex digest** (`node.parent.hash_value[-1]`).
//! For request-side hashing on the routing path, there is no parent, so we
//! expose the "from-scratch" entry point only.
//!
//! ### Bigram mode
//!
//! Not supported in v1. SGLang's bigram mode interleaves overlapping
//! `(t_i, t_{i+1})` pairs into the hash. The gateway does not need this
//! today; if/when it does, add a separate `compute_block_hashes_bigram` rather
//! than complicating the non-bigram fast path.
use sha2::{Digest, Sha256};
/// Compute per-block i64 hashes for a token sequence, matching SGLang's
/// worker emission for a chain that starts with no parent block.
///
/// The returned `Vec<i64>` has `ceil(token_ids.len() / block_size)` entries,
/// each being the i64 truncation (top 64 bits, signed) of the per-page
/// SHA256 digest as defined in [`Self`-module docs](self).
///
/// # Panics
///
/// Panics if `block_size == 0`. Callers are expected to validate this once
/// up-front against the worker-published `block_size`; an invalid value is
/// a programmer/config bug, not a runtime input we should swallow.
pub fn compute_block_hashes(token_ids: &[u32], block_size: usize) -> Vec<i64> {
assert!(block_size > 0, "block_size must be positive");
if token_ids.is_empty() {
return Vec::new();
}
let n = token_ids.len();
let num_blocks = n.div_ceil(block_size);
let mut out = Vec::with_capacity(num_blocks);
let mut prior: Option<[u8; 32]> = None;
let mut start = 0;
while start < n {
let end = (start + block_size).min(n);
let digest = chain_block(prior.as_ref(), &token_ids[start..end]);
out.push(sha256_to_i64(&digest));
prior = Some(digest);
start = end;
}
out
}
/// Hash a single page, optionally chained to a parent block's full 32-byte
/// SHA256 digest. Returns the new 32-byte digest.
#[inline]
fn chain_block(parent_digest: Option<&[u8; 32]>, block_tokens: &[u32]) -> [u8; 32] {
let mut hasher = Sha256::new();
if let Some(parent) = parent_digest {
hasher.update(parent);
}
for t in block_tokens {
hasher.update(t.to_le_bytes());
}
hasher.finalize().into()
}
/// Convert a full 32-byte SHA256 digest to the signed i64 truncation that
/// SGLang publishes on the wire (top 64 bits, big-endian, reinterpreted as
/// signed).
///
/// Mirrors Python's `hash_str_to_int64`:
/// ```text
/// uint64_val = int(hash_str[:16], 16)
/// return uint64_val - 2**64 if uint64_val >= 2**63 else uint64_val
/// ```
/// which is equivalent to `i64::from_be_bytes(digest[..8])`.
#[inline]
pub fn sha256_to_i64(digest: &[u8; 32]) -> i64 {
let mut top = [0u8; 8];
top.copy_from_slice(&digest[..8]);
i64::from_be_bytes(top)
}
#[cfg(test)]
mod tests {
use super::*;
/// Helper for tests: derive the expected i64 from a list of tokens
/// chained against an optional parent digest. This mirrors `chain_block`
/// but is duplicated here so a regression in the production helper
/// cannot also hide itself in the test oracle.
fn oracle_block_digest(parent: Option<&[u8; 32]>, tokens: &[u32]) -> [u8; 32] {
let mut h = Sha256::new();
if let Some(p) = parent {
h.update(p);
}
for t in tokens {
h.update(t.to_le_bytes());
}
h.finalize().into()
}
fn oracle_i64(digest: &[u8; 32]) -> i64 {
let mut top = [0u8; 8];
top.copy_from_slice(&digest[..8]);
i64::from_be_bytes(top)
}
#[test]
fn empty_input_returns_empty_vec() {
assert!(compute_block_hashes(&[], 4).is_empty());
assert!(compute_block_hashes(&[], 1).is_empty());
}
#[test]
#[should_panic(expected = "block_size must be positive")]
fn zero_block_size_panics() {
let _ = compute_block_hashes(&[1, 2, 3], 0);
}
#[test]
fn single_full_block() {
// Independent oracle: SHA256 of the LE bytes of [1,2,3,4], take top 8 bytes.
let expected_digest = oracle_block_digest(None, &[1, 2, 3, 4]);
let expected_i64 = oracle_i64(&expected_digest);
let got = compute_block_hashes(&[1, 2, 3, 4], 4);
assert_eq!(got, vec![expected_i64]);
}
#[test]
fn partial_last_block_chains_against_first_block_digest() {
// 5 tokens, block_size 4 → block 0 = [1,2,3,4], block 1 = [5] chained
// against block 0's full 32-byte digest.
let d0 = oracle_block_digest(None, &[1, 2, 3, 4]);
let d1 = oracle_block_digest(Some(&d0), &[5]);
let expected = vec![oracle_i64(&d0), oracle_i64(&d1)];
let got = compute_block_hashes(&[1, 2, 3, 4, 5], 4);
assert_eq!(got, expected);
}
#[test]
fn multi_block_chain() {
// 8 tokens, block_size 2 → 4 blocks, each chained against the
// previous block's full 32-byte digest.
let tokens: [u32; 8] = [10, 20, 30, 40, 50, 60, 70, 80];
let d0 = oracle_block_digest(None, &tokens[0..2]);
let d1 = oracle_block_digest(Some(&d0), &tokens[2..4]);
let d2 = oracle_block_digest(Some(&d1), &tokens[4..6]);
let d3 = oracle_block_digest(Some(&d2), &tokens[6..8]);
let expected = vec![
oracle_i64(&d0),
oracle_i64(&d1),
oracle_i64(&d2),
oracle_i64(&d3),
];
let got = compute_block_hashes(&tokens, 2);
assert_eq!(got, expected);
}
#[test]
fn sha256_to_i64_handles_top_bit_set() {
// sha256("") = e3b0c44298fc1c14 9afbf4c8996fb924 27ae41e4649b934c a495991b7852b855
// Top 8 bytes = e3b0c44298fc1c14 → uint64 0xe3b0c44298fc1c14
// Top bit set → signed value = uint64 - 2**64 = -2039914840885289964
let digest: [u8; 32] = Sha256::digest(b"").into();
assert_eq!(sha256_to_i64(&digest), -2039914840885289964_i64);
}
/// Cross-language goldens: values produced by a Python script that
/// mirrors `radix_cache.hash_page` (non-bigram path) and
/// `mem_cache.utils.hash_str_to_int64`. These are the contract with the
/// SGLang worker and lock down algorithmic equivalence regardless of
/// changes to the Rust-internal helpers.
///
/// Reproducer (saved temporarily to `/tmp/sglang_hash_oracle.py` during
/// development; not committed):
/// ```python
/// import hashlib
/// def hash_page(prior, toks):
/// h = hashlib.sha256()
/// if prior:
/// h.update(bytes.fromhex(prior))
/// for t in toks:
/// h.update(int(t).to_bytes(4, "little", signed=False))
/// return h.hexdigest()
/// def hash_str_to_int64(s):
/// v = int(s[:16], 16)
/// return v - 2**64 if v >= 2**63 else v
/// def chain(tokens, bs):
/// out, prior = [], None
/// for i in range(0, len(tokens), bs):
/// hx = hash_page(prior, tokens[i:i+bs])
/// out.append(hash_str_to_int64(hx)); prior = hx
/// return out
/// ```
#[test]
fn cross_language_golden_single_block() {
// Python: chain([1,2,3,4], 4) -> [-3488128144981237669]
let got = compute_block_hashes(&[1, 2, 3, 4], 4);
assert_eq!(got, vec![-3488128144981237669_i64]);
}
#[test]
fn cross_language_golden_partial_last_block() {
// Python: chain([1,2,3,4,5], 4)
// -> [-3488128144981237669, -3787494577174227566]
let got = compute_block_hashes(&[1, 2, 3, 4, 5], 4);
assert_eq!(
got,
vec![-3488128144981237669_i64, -3787494577174227566_i64]
);
}
#[test]
fn cross_language_golden_multi_block() {
// Python: chain([10,20,30,40,50,60,70,80], 2)
// -> [978178666101069530, -895308556211281782,
// -8033692805846017938, 835415944263129316]
let got = compute_block_hashes(&[10, 20, 30, 40, 50, 60, 70, 80], 2);
assert_eq!(
got,
vec![
978178666101069530_i64,
-895308556211281782_i64,
-8033692805846017938_i64,
835415944263129316_i64,
]
);
}
}
@@ -0,0 +1,682 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Lifecycle bundle for the KV-event index.
//!
//! Couples the three submodules that are independent in their own right but
//! always operate together in production:
//!
//! - [`HashTree`] — the cache-aware routing index keyed by SGLang block hash.
//! - [`KvEventSubscriberRegistry`] — one ZMQ SUB connection per `(worker_url,
//! dp_rank)`.
//! - A pump task that drains [`WorkerEvent`]s from the subscriber and applies
//! them to the tree.
//!
//! `add_worker` / `remove_worker` are driven from the worker manager on every
//! `DiscoveryEvent::Added` / `DiscoveryEvent::Removed`.
//!
//! # Race avoidance
//!
//! The pump runs independently of the lifecycle calls, so an event can sit in
//! the mpsc buffer while `remove_worker` is in progress. To prevent stale
//! events from re-inserting tree state for a worker that was just torn down,
//! [`KvEventIndex`] maintains a `live_workers` set; entries are removed
//! **before** the subscriber tasks are joined, and the pump filters every
//! event through this set before mutating the tree.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use parking_lot::Mutex;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use super::block_size_oracle::BlockSizeOracle;
use super::discovery::{fetch_event_config, EventConfig};
use super::subscriber::{KvEventSubscriberRegistry, WorkerEvent};
use super::tree::{HashTree, KvWorkerId};
use super::wire::KvCacheEvent;
/// Channel buffer between the subscriber registry and the pump task.
///
/// Bounded so a misbehaving publisher cannot exhaust memory. Realistic
/// per-worker event rates are < 1 kHz; a 1024-deep buffer absorbs a
/// half-second burst at 2 kHz before back-pressuring the SUB sockets.
const EVENT_CHANNEL_BUFFER: usize = 1024;
/// Per-worker bookkeeping kept inside [`KvEventIndex`] so `remove_worker`
/// knows which DP ranks were actually subscribed (not the advertised
/// `dp_size`, which may overflow `u16` and skip ranks).
#[derive(Debug, Clone)]
struct WorkerEntry {
/// DP ranks that were successfully spawned for this worker. Used by
/// `remove_worker` to know which `(url, dp_rank)` cursors and tree
/// states to clear.
dp_ranks: Vec<u32>,
}
/// Bundle of `HashTree` + `KvEventSubscriberRegistry` + pump task.
///
/// Construct one instance per router process and hand it to the worker
/// manager as `Option<Arc<KvEventIndex>>` — `None` disables the cache-aware
/// routing path entirely.
pub struct KvEventIndex {
tree: Arc<HashTree>,
subscribers: Arc<KvEventSubscriberRegistry>,
pump: Mutex<Option<JoinHandle<()>>>,
pump_cancel: CancellationToken,
workers: Mutex<HashMap<String, WorkerEntry>>,
http: reqwest::Client,
/// Set of currently-attached `(worker_url, dp_rank)` pairs. The pump
/// drops any event whose `worker` is not in this set, so a batch
/// queued by a subscriber that was torn down by `remove_worker` does
/// not re-pollute the tree after `clear_worker` ran.
live_workers: Arc<Mutex<HashSet<KvWorkerId>>>,
/// Per-`(worker_url, dp_rank)` last-applied sequence number. The
/// subscriber forwards every batch with no de-dup; this map filters
/// any batch whose `seq` is not strictly greater than the previously
/// applied one. Cleared on `remove_worker` because a re-added worker
/// may legitimately have a fresh publisher whose sequence numbers
/// restart from 1.
cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>>,
/// Worker-sourced `page_size` shared with the cache-aware-zmq policy.
/// `add_worker` calls `try_set(cfg.block_size)` so the first worker
/// establishes the value; subsequent workers that disagree are
/// rejected (logged + not subscribed). The policy reads it at routing
/// time to size its `compute_block_hashes` call.
block_size_oracle: Arc<BlockSizeOracle>,
}
impl KvEventIndex {
/// Build an empty index and spawn the pump task.
pub fn new() -> Arc<Self> {
Self::new_with_http(
reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("default http client builds"),
)
}
/// Constructor used by tests so they can supply a custom timeout.
pub fn new_with_http(http: reqwest::Client) -> Arc<Self> {
Self::new_with_http_and_oracle(http, BlockSizeOracle::new())
}
/// Constructor that lets the caller supply a pre-shared
/// [`BlockSizeOracle`]. Production wires this from `AppContext` so
/// the same oracle the index seeds is the one the cache-aware-zmq
/// policy reads at routing time. Tests use this to pre-populate the
/// oracle and exercise the mismatch-rejection path.
pub fn new_with_http_and_oracle(
http: reqwest::Client,
block_size_oracle: Arc<BlockSizeOracle>,
) -> Arc<Self> {
let tree = Arc::new(HashTree::new());
let (tx, rx) = mpsc::channel::<WorkerEvent>(EVENT_CHANNEL_BUFFER);
let subscribers = Arc::new(KvEventSubscriberRegistry::new(tx));
let cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>> = Arc::new(Mutex::new(HashMap::new()));
let live_workers: Arc<Mutex<HashSet<KvWorkerId>>> = Arc::new(Mutex::new(HashSet::new()));
let pump_cancel = CancellationToken::new();
let pump = tokio::spawn(pump_loop(
tree.clone(),
cursors.clone(),
live_workers.clone(),
pump_cancel.clone(),
rx,
));
Arc::new(Self {
tree,
subscribers,
pump: Mutex::new(Some(pump)),
pump_cancel,
workers: Mutex::new(HashMap::new()),
http,
live_workers,
cursors,
block_size_oracle,
})
}
/// Shared accessor for the per-process block-size oracle. The
/// `CacheAwareZmqPolicy` (via [`crate::policies::factory`]) holds the
/// same `Arc` so the value the index seeds is the value the policy
/// hashes against.
pub fn block_size_oracle(&self) -> Arc<BlockSizeOracle> {
Arc::clone(&self.block_size_oracle)
}
/// Clone the underlying tree handle for cache-aware selection and
/// metrics. The pump is the sole writer; callers should treat the
/// returned handle as read-only.
pub fn tree(&self) -> Arc<HashTree> {
self.tree.clone()
}
/// Register a worker. If `preresolved` is `Some`, the caller has
/// already fetched `/server_info` (worker manager path) and we skip
/// the internal HTTP round-trip; otherwise (standalone callers,
/// e.g. integration tests) we fall back to `fetch_event_config`.
///
/// Opens one ZMQ SUB per advertised DP rank. If the worker is not
/// publishing KV events (older SGLang, opt-out config), this is a
/// logged no-op — the worker still routes via the non-cache-aware
/// policies.
pub async fn add_worker(&self, worker_url: &str, preresolved: Option<EventConfig>) {
let cfg: EventConfig = match preresolved {
Some(c) => c,
None => match fetch_event_config(worker_url, &self.http).await {
Ok(Some(c)) => c,
Ok(None) => {
info!(
worker_url = %worker_url,
"kv-events: worker is not publishing; cache-aware routing disabled for this worker",
);
return;
}
Err(e) => {
warn!(
worker_url = %worker_url,
error = %e,
"kv-events: /server_info introspection failed; skipping subscriber",
);
return;
}
},
};
// Reconcile this worker's `page_size` with the oracle BEFORE
// any subscriber state is created. The first worker establishes
// the value; later workers must agree. A mismatch means the
// router and at least one engine would compute different block
// hashes for the same prompt, silently destroying cache-aware
// routing quality — reject loudly instead.
if let Err(err) = self.block_size_oracle.try_set(cfg.block_size) {
warn!(
worker_url = %worker_url,
established_block_size = err.established,
worker_block_size = err.candidate,
"kv-events: worker page_size disagrees with established block_size; \
skipping worker — cache-aware routing requires every worker to publish \
at the same block size",
);
return;
}
info!(
worker_url = %worker_url,
dp_size = cfg.dp_size,
port_base = cfg.port_base,
block_size = cfg.block_size,
"kv-events: subscribing",
);
// Compute the DP ranks that will actually be subscribed (skip
// ranks whose port overflows u16; the subscriber will warn on
// each skipped rank).
let port_base_u32 = u32::from(cfg.port_base);
let dp_ranks: Vec<u32> = (0..cfg.dp_size)
.filter(|rank| (port_base_u32 + rank) <= u32::from(u16::MAX))
.collect();
if dp_ranks.is_empty() {
warn!(
worker_url = %worker_url,
port_base = cfg.port_base,
dp_size = cfg.dp_size,
"kv-events: every advertised rank's port overflows u16; skipping worker",
);
return;
}
// Mark every rank live BEFORE the subscriber starts so any event
// it queues is accepted by the pump.
{
let mut live = self.live_workers.lock();
for &rank in &dp_ranks {
live.insert(KvWorkerId {
url: worker_url.to_string(),
dp_rank: rank,
});
}
}
self.workers.lock().insert(
worker_url.to_string(),
WorkerEntry {
dp_ranks: dp_ranks.clone(),
},
);
self.subscribers.add_worker(worker_url, &cfg).await;
}
/// Tear down a worker's subscribers and clear it from the tree.
/// Idempotent: a remove for a worker that was never added is a no-op.
///
/// The live-worker entries are dropped **before** the subscriber join,
/// so any event still buffered in the mpsc by the time the pump
/// reaches it is dropped instead of re-inserted into the tree.
pub async fn remove_worker(&self, worker_url: &str) {
let Some(entry) = self.workers.lock().remove(worker_url) else {
return;
};
let ids: Vec<KvWorkerId> = entry
.dp_ranks
.iter()
.map(|&dp_rank| KvWorkerId {
url: worker_url.to_string(),
dp_rank,
})
.collect();
// 1. Mark every rank dead. Any pump-queued events arriving after
// this point will be filtered.
{
let mut live = self.live_workers.lock();
for id in &ids {
live.remove(id);
}
}
// 2. Cancel and join the per-rank subscriber tasks. No further
// events for these ranks will be queued after this returns.
self.subscribers.remove_worker(worker_url).await;
// 3. Drop each rank's tree state and cursor. Any event already in
// the mpsc buffer at this point will be filtered by the
// live-set check inside the pump.
let mut cursors = self.cursors.lock();
for id in &ids {
self.tree.clear_worker(id);
cursors.remove(id);
}
}
/// Number of worker URLs the index is currently subscribed to. The
/// count includes workers whose `/server_info` resolved but excludes
/// any whose discovery returned `Ok(None)` (worker reachable but not
/// publishing) or `Err` (transient discovery failure). Exposed for
/// tests + future metrics; not part of the routing hot path.
pub fn known_worker_count(&self) -> usize {
self.workers.lock().len()
}
/// Shut down the pump task. Cancels the subscriber registry first so no
/// further events are queued, then cancels the pump so any buffered
/// events are discarded and the task exits promptly.
pub async fn shutdown(&self) {
self.subscribers.shutdown().await;
self.pump_cancel.cancel();
let handle = self.pump.lock().take();
if let Some(h) = handle {
// 2s ceiling guards against a pathological tokio runtime
// teardown; under normal operation the pump exits within one
// poll of `pump_cancel.cancelled()`.
match tokio::time::timeout(Duration::from_secs(2), h).await {
Ok(Ok(())) => {}
Ok(Err(e)) => warn!(error = %e, "kv-events pump task did not join cleanly"),
Err(_) => warn!("kv-events pump task did not stop within 2s"),
}
}
}
}
/// Drain `WorkerEvent`s and apply each batch to the tree. Out-of-order
/// (seq ≤ last_applied) and stale (worker not in `live_workers`) batches
/// are skipped. `PublisherReset` events clear the cursor so a publisher
/// restarting from seq=1 (after sending END_SEQ) is not filtered.
async fn pump_loop(
tree: Arc<HashTree>,
cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>>,
live_workers: Arc<Mutex<HashSet<KvWorkerId>>>,
cancel: CancellationToken,
mut rx: mpsc::Receiver<WorkerEvent>,
) {
loop {
let ev = tokio::select! {
biased;
_ = cancel.cancelled() => {
info!("kv-events pump: shutdown requested; exiting");
return;
}
recv = rx.recv() => match recv {
Some(ev) => ev,
None => {
warn!("kv-events pump: receiver closed unexpectedly; exiting");
return;
}
}
};
// Filter events from workers that are no longer attached. This is
// load-bearing: `remove_worker` clears the live set BEFORE joining
// the subscriber task, so any event still buffered when the pump
// reaches it would otherwise re-pollute the tree.
let worker = ev.worker();
if !live_workers.lock().contains(worker) {
debug!(
worker = ?worker,
"kv-events pump: dropping event from detached worker",
);
continue;
}
match ev {
WorkerEvent::PublisherReset { worker } => {
if cursors.lock().remove(&worker).is_some() {
info!(
worker = ?worker,
"kv-events pump: publisher reset; cursor cleared",
);
}
}
WorkerEvent::Batch { worker, seq, batch } => {
let prev = cursors.lock().get(&worker).copied();
if let Some(p) = prev {
if seq <= p {
debug!(
worker = ?worker,
seq,
last_applied = p,
"kv-events pump: out-of-order batch; skipping",
);
continue;
}
}
for event in &batch.events {
match event {
KvCacheEvent::BlockStored(b) => {
tree.insert(&worker, b.parent_block_hash, &b.block_hashes);
}
KvCacheEvent::BlockRemoved(b) => {
tree.remove(&worker, &b.block_hashes);
}
KvCacheEvent::AllBlocksCleared => {
tree.clear_worker(&worker);
}
}
}
cursors.lock().insert(worker, seq);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policies::kv_events::wire::{BlockRemoved, BlockStored, KvEventBatch};
fn worker_id(url: &str, rank: u32) -> KvWorkerId {
KvWorkerId {
url: url.into(),
dp_rank: rank,
}
}
fn batch(events: Vec<KvCacheEvent>) -> KvEventBatch {
KvEventBatch {
ts: 0.0,
events,
attn_dp_rank: None,
}
}
/// Bundle of plumbing returned by `spawn_pump` so individual tests
/// can destructure just the bits they need.
struct PumpHarness {
tree: Arc<HashTree>,
cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>>,
#[allow(dead_code)]
live_set: Arc<Mutex<HashSet<KvWorkerId>>>,
#[allow(dead_code)]
cancel: CancellationToken,
tx: mpsc::Sender<WorkerEvent>,
pump: JoinHandle<()>,
}
/// Build a tree + cursors + live-set wired through `pump_loop` with
/// the given workers pre-marked live.
fn spawn_pump(live: &[KvWorkerId]) -> PumpHarness {
let tree = Arc::new(HashTree::new());
let cursors = Arc::new(Mutex::new(HashMap::new()));
let live_set: Arc<Mutex<HashSet<KvWorkerId>>> =
Arc::new(Mutex::new(live.iter().cloned().collect()));
let cancel = CancellationToken::new();
let (tx, rx) = mpsc::channel(4);
let pump = tokio::spawn(pump_loop(
tree.clone(),
cursors.clone(),
live_set.clone(),
cancel.clone(),
rx,
));
PumpHarness {
tree,
cursors,
live_set,
cancel,
tx,
pump,
}
}
/// Direct test of the pump loop's tree application — no sockets.
#[tokio::test]
async fn pump_applies_block_stored_to_tree() {
let id = worker_id("http://w1", 0);
let h = spawn_pump(std::slice::from_ref(&id));
let (tree, tx, pump) = (h.tree, h.tx, h.pump);
tx.send(WorkerEvent::Batch {
worker: id.clone(),
seq: 1,
batch: batch(vec![KvCacheEvent::BlockStored(BlockStored {
parent_block_hash: None,
block_hashes: vec![10, 20, 30],
token_ids: vec![],
block_size: 64,
lora_id: None,
medium: None,
})]),
})
.await
.unwrap();
drop(tx);
// Don't cancel — let rx.recv() return None naturally so any
// queued events drain first. (The pump's `biased` select would
// otherwise preempt unprocessed events on cancel.)
pump.await.unwrap();
let m = tree.match_prefix(None, &[10, 20, 30]);
assert_eq!(m.matched_blocks, 3);
assert!(m.workers.contains(&id), "tree must hold the worker");
}
/// Out-of-order seq is filtered: a batch with seq <= last_applied is
/// dropped silently and does not mutate the tree.
#[tokio::test]
async fn pump_filters_out_of_order_seq() {
let id = worker_id("http://w1", 0);
let h = spawn_pump(std::slice::from_ref(&id));
let (tree, cursors, tx, pump) = (h.tree, h.cursors, h.tx, h.pump);
// Apply seq=5 with block 10.
tx.send(WorkerEvent::Batch {
worker: id.clone(),
seq: 5,
batch: batch(vec![KvCacheEvent::BlockStored(BlockStored {
parent_block_hash: None,
block_hashes: vec![10],
token_ids: vec![],
block_size: 64,
lora_id: None,
medium: None,
})]),
})
.await
.unwrap();
// Then a duplicate-style seq=3 that tries to remove block 10. Must
// be dropped.
tx.send(WorkerEvent::Batch {
worker: id.clone(),
seq: 3,
batch: batch(vec![KvCacheEvent::BlockRemoved(BlockRemoved {
block_hashes: vec![10],
medium: None,
})]),
})
.await
.unwrap();
drop(tx);
// Don't cancel — let rx.recv() return None naturally so any
// queued events drain first. (The pump's `biased` select would
// otherwise preempt unprocessed events on cancel.)
pump.await.unwrap();
let m = tree.match_prefix(None, &[10]);
assert_eq!(
m.matched_blocks, 1,
"out-of-order remove must not undo the prior insert",
);
assert_eq!(cursors.lock().get(&id).copied(), Some(5));
}
/// AllBlocksCleared wipes the worker's tree state entirely.
#[tokio::test]
async fn pump_handles_all_blocks_cleared() {
let id = worker_id("http://w1", 0);
let h = spawn_pump(std::slice::from_ref(&id));
let (tree, tx, pump) = (h.tree, h.tx, h.pump);
tx.send(WorkerEvent::Batch {
worker: id.clone(),
seq: 1,
batch: batch(vec![KvCacheEvent::BlockStored(BlockStored {
parent_block_hash: None,
block_hashes: vec![1, 2],
token_ids: vec![],
block_size: 64,
lora_id: None,
medium: None,
})]),
})
.await
.unwrap();
tx.send(WorkerEvent::Batch {
worker: id.clone(),
seq: 2,
batch: batch(vec![KvCacheEvent::AllBlocksCleared]),
})
.await
.unwrap();
drop(tx);
// Don't cancel — let rx.recv() return None naturally so any
// queued events drain first. (The pump's `biased` select would
// otherwise preempt unprocessed events on cancel.)
pump.await.unwrap();
let m = tree.match_prefix(None, &[1, 2]);
assert_eq!(
m.matched_blocks, 0,
"AllBlocksCleared must purge the worker"
);
}
/// The pump drops events whose worker is not in `live_workers`. This
/// is the safety net against the remove-then-pump race: an event
/// queued before `remove_worker` clears the live set must not mutate
/// the tree.
#[tokio::test]
async fn pump_drops_events_from_detached_workers() {
let live_id = worker_id("http://live", 0);
let dead_id = worker_id("http://dead", 0);
let h = spawn_pump(std::slice::from_ref(&live_id));
let (tree, tx, pump) = (h.tree, h.tx, h.pump);
// Event from a worker that was never added (or was already
// removed). Must be dropped.
tx.send(WorkerEvent::Batch {
worker: dead_id.clone(),
seq: 1,
batch: batch(vec![KvCacheEvent::BlockStored(BlockStored {
parent_block_hash: None,
block_hashes: vec![42],
token_ids: vec![],
block_size: 64,
lora_id: None,
medium: None,
})]),
})
.await
.unwrap();
// Sanity: a live event still applies.
tx.send(WorkerEvent::Batch {
worker: live_id.clone(),
seq: 1,
batch: batch(vec![KvCacheEvent::BlockStored(BlockStored {
parent_block_hash: None,
block_hashes: vec![99],
token_ids: vec![],
block_size: 64,
lora_id: None,
medium: None,
})]),
})
.await
.unwrap();
drop(tx);
// Don't cancel — let rx.recv() return None naturally so any
// queued events drain first. (The pump's `biased` select would
// otherwise preempt unprocessed events on cancel.)
pump.await.unwrap();
assert_eq!(tree.match_prefix(None, &[42]).matched_blocks, 0);
assert_eq!(tree.match_prefix(None, &[99]).matched_blocks, 1);
}
/// `add_worker` must reject a worker whose `EventConfig.block_size`
/// disagrees with the previously-established oracle value. The
/// router cannot hash prompts simultaneously at two block sizes;
/// silently accepting the mismatched worker would destroy
/// cache-aware routing quality for every request.
#[tokio::test]
async fn add_worker_rejects_block_size_mismatch() {
let index = KvEventIndex::new();
// First worker establishes block_size=64 via the oracle.
index.block_size_oracle().try_set(64).unwrap();
let bad_cfg = EventConfig {
host: "127.0.0.1".into(),
port_base: 30100,
topic: String::new(),
block_size: 128,
dp_size: 1,
};
index
.add_worker("http://127.0.0.1:30100", Some(bad_cfg))
.await;
assert_eq!(
index.known_worker_count(),
0,
"mismatched worker must not be registered"
);
index.shutdown().await;
}
#[tokio::test]
async fn add_worker_seeds_oracle_with_first_block_size() {
// Without any prior priming, the first worker through `add_worker`
// should publish its `EventConfig.block_size` into the oracle so
// subsequent matching workers reconcile and mismatched ones fail.
let index = KvEventIndex::new();
assert_eq!(index.block_size_oracle().get(), None);
// A dp_size=0 cfg short-circuits before the subscriber spawn but
// still runs through the block-size validation.
let cfg = EventConfig {
host: "127.0.0.1".into(),
port_base: 30200,
topic: String::new(),
block_size: 64,
dp_size: 0,
};
index.add_worker("http://127.0.0.1:30200", Some(cfg)).await;
assert_eq!(index.block_size_oracle().get(), Some(64));
index.shutdown().await;
}
}
@@ -0,0 +1,33 @@
//! ZMQ-based KV-cache event indexer for cache-aware routing.
//!
//! Decodes the msgpack wire format emitted by SGLang's `ZmqEventPublisher`
//! (see `python/sglang/srt/disaggregation/kv_events.py`) and maintains the
//! router-side index used for cache-aware request routing.
//!
//! # Submodules
//!
//! - [`wire`] — msgpack types and [`decode_event_batch`]; the contract
//! with the SGLang publisher. Pure decoding; no I/O.
//! - [`hash`] — block-hash compute mirroring SGLang `RadixKey.hash_page`.
//! - [`tree`] — hash-keyed radix tree consumed by the routing path.
//! - [`subscriber`] — per-worker ZMQ SUB tasks.
//! - [`discovery`] — `/server_info` parse → publisher endpoint.
//! - [`index`] — public façade bundling the tree + subscribers + pump.
pub mod block_size_oracle;
pub mod discovery;
pub mod hash;
pub mod index;
pub mod subscriber;
pub mod tree;
pub mod wire;
pub use block_size_oracle::BlockSizeOracle;
pub use discovery::{fetch_event_config, EventConfig};
pub use hash::{compute_block_hashes, sha256_to_i64};
pub use index::KvEventIndex;
pub use subscriber::{KvEventSubscriberRegistry, WorkerEvent};
pub use tree::{HashTree, KvWorkerId, MatchResult};
pub use wire::{
decode_event_batch, BlockRemoved, BlockStored, DecodeError, KvCacheEvent, KvEventBatch,
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,866 @@
//! Wire-format types for SGLang's KV cache event stream.
//!
//! SGLang's `ZmqEventPublisher` (Python:
//! `python/sglang/srt/disaggregation/kv_events.py`) encodes batches with
//! `msgspec.msgpack`. Two struct families are involved:
//!
//! * `EventBatch` (the outer payload) — declared with
//! `array_like=True, omit_defaults=True, gc=False` (no tag).
//! * `KVCacheEvent` (each inner event variant) — additionally declared
//! with `tag=True`.
//!
//! The combined effect on the wire:
//!
//! * Each struct is a msgpack **array** of its fields in declaration
//! order, not a map.
//! * `tag=True` on `KVCacheEvent` prepends a class-name string at index 0
//! of each inner event array, so an event is
//! `[class_name_str, field1, field2, ...]`. The outer `EventBatch`
//! array does **not** carry a tag prefix.
//! * `omit_defaults=True` allows trailing fields whose values equal their
//! declared defaults to be dropped from the array. The decoder therefore
//! accepts variable-length sequences for each struct shape.
//!
//! This module deserializes those bytes into Rust types and exposes a single
//! [`decode_event_batch`] entry point.
use std::fmt;
use serde::de::{self, Deserializer, IgnoredAny, SeqAccess, Visitor};
use serde::Deserialize;
/// Top-level batch payload published by SGLang.
///
/// Wire shape (`EventBatch`, `array_like`):
/// `[ts: f64, events: [...], attn_dp_rank: int_or_nil_or_omitted]`.
/// SGLang declares `attn_dp_rank` as a Python `Optional[int]`; we decode
/// it as `u32` since DP ranks are non-negative and bounded by the
/// publisher's `dp_size`.
#[derive(Debug, Clone, PartialEq)]
pub struct KvEventBatch {
/// Wall-clock timestamp from the publisher (seconds since epoch).
pub ts: f64,
/// Ordered list of cache events in this batch.
pub events: Vec<KvCacheEvent>,
/// Optional DP-attention rank that produced this batch. `None` if the
/// publisher emitted nil or omitted the field via `omit_defaults`.
pub attn_dp_rank: Option<u32>,
}
/// A single KV cache event. The Python base class `KVCacheEvent` uses
/// `tag=True`, so each event on the wire is an array whose first element
/// is the class-name discriminator.
#[derive(Debug, Clone, PartialEq)]
pub enum KvCacheEvent {
/// `["BlockStored", block_hashes, parent_block_hash, token_ids,
/// block_size, lora_id, medium?]`.
BlockStored(BlockStored),
/// `["BlockRemoved", block_hashes, medium?]`.
BlockRemoved(BlockRemoved),
/// `["AllBlocksCleared"]`.
AllBlocksCleared,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BlockStored {
/// 64-bit block hashes in declaration order. Hashes can exceed `i32`
/// range; signedness matches SGLang's Python `int`.
pub block_hashes: Vec<i64>,
/// Hash of the parent block, or `None` for the first block in a chain.
pub parent_block_hash: Option<i64>,
/// Tokens covered by this block. SGLang uses 32-bit token IDs.
pub token_ids: Vec<u32>,
/// Block size (tokens per block).
pub block_size: u32,
/// LoRA adapter ID this block is associated with, if any.
pub lora_id: Option<i64>,
/// Storage tier (`"GPU"`, `"CPU_PINNED"`, `"DISK"`, `"EXTERNAL"`).
/// Optional in the Python schema (`= None` default), so it may be
/// omitted entirely under `omit_defaults`.
pub medium: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BlockRemoved {
pub block_hashes: Vec<i64>,
/// Same semantics as [`BlockStored::medium`].
pub medium: Option<String>,
}
/// Maximum number of block hashes a single decoded `BlockStored` /
/// `BlockRemoved` event may carry. A misbehaving worker (or a corrupted
/// frame) could otherwise prompt a multi-gigabyte allocation in the
/// gateway. Workers are inside the trust boundary, so this is
/// defense-in-depth — but the cost of *not* capping is unbounded memory
/// amplification, so we cap.
pub(crate) const MAX_HASHES_PER_EVENT: usize = 65_536;
/// Same rationale as [`MAX_HASHES_PER_EVENT`], but for `token_ids`. A
/// 1M-token block list is already absurdly larger than any realistic
/// `BlockStored` payload — the cap exists to bound the worst case, not
/// to constrain normal operation.
pub(crate) const MAX_TOKENS_PER_EVENT: usize = 1_048_576;
/// Errors produced by [`decode_event_batch`].
#[derive(thiserror::Error, Debug)]
pub enum DecodeError {
/// The msgpack payload was malformed or did not match the expected schema.
#[error("failed to decode KV event batch: {0}")]
Msgpack(#[from] rmp_serde::decode::Error),
/// A single event's variable-length field exceeded its hard cap. We
/// surface this as an error rather than panicking so a single bad
/// payload only kills its batch, not the consumer task.
#[error("KV event field {field} length {len} exceeds cap {cap}")]
PayloadTooLarge {
field: &'static str,
len: usize,
cap: usize,
},
}
/// Sentinel string a custom visitor uses to encode a "field too large"
/// error through serde's `de::Error::custom` channel. We rewrap as the
/// typed [`DecodeError::PayloadTooLarge`] in [`decode_event_batch`].
const PAYLOAD_TOO_LARGE_TAG: &str = "kv_events::wire::PAYLOAD_TOO_LARGE";
/// Decode a single ZMQ payload frame from SGLang's `ZmqEventPublisher`.
///
/// The payload is the `payload` arg to `_pub.send_multipart((topic, seq,
/// payload))` — the topic and 8-byte big-endian sequence number are separate
/// frames and are NOT part of the msgpack input here.
///
/// Caps the per-event `block_hashes` and `token_ids` lengths
/// ([`MAX_HASHES_PER_EVENT`], [`MAX_TOKENS_PER_EVENT`]) so a misbehaving
/// worker — or a corrupted msgpack length prefix — cannot trigger an
/// unbounded allocation in the gateway.
pub fn decode_event_batch(bytes: &[u8]) -> Result<KvEventBatch, DecodeError> {
match rmp_serde::from_slice::<KvEventBatch>(bytes) {
Ok(b) => Ok(b),
Err(e) => {
// Rewrap the size-cap sentinel into the typed variant. The
// sentinel string is set by `BoundedI64Vec` / `BoundedU32Vec`
// below; everything else is a true msgpack decode failure.
let s = e.to_string();
if let Some(rest) = s.strip_prefix(PAYLOAD_TOO_LARGE_TAG) {
// Format: "<TAG>:<field>:<len>:<cap>"
let mut parts = rest.trim_start_matches(':').split(':');
if let (Some(field), Some(len), Some(cap)) =
(parts.next(), parts.next(), parts.next())
{
if let (Ok(len), Ok(cap)) = (len.parse::<usize>(), cap.parse::<usize>()) {
let field = match field {
"block_hashes" => "block_hashes",
"token_ids" => "token_ids",
// Unknown — fall through to Msgpack.
_ => return Err(DecodeError::Msgpack(e)),
};
return Err(DecodeError::PayloadTooLarge { field, len, cap });
}
}
}
Err(DecodeError::Msgpack(e))
}
}
}
/// Newtype wrapping `Vec<i64>` whose `Deserialize` impl rejects sequences
/// announcing more than [`MAX_HASHES_PER_EVENT`] elements *before* doing
/// the per-element work. Required because `rmp-serde` pre-sizes the
/// destination `Vec` from the msgpack length prefix; a malicious or
/// corrupted prefix would otherwise prompt a multi-gigabyte allocation.
#[derive(Debug, Clone, PartialEq)]
struct BoundedI64Vec(Vec<i64>);
impl<'de> Deserialize<'de> for BoundedI64Vec {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct V;
impl<'de> Visitor<'de> for V {
type Value = Vec<i64>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a msgpack array of i64 values")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Vec<i64>, A::Error>
where
A: SeqAccess<'de>,
{
if let Some(hint) = seq.size_hint() {
if hint > MAX_HASHES_PER_EVENT {
return Err(de::Error::custom(format!(
"{PAYLOAD_TOO_LARGE_TAG}:block_hashes:{hint}:{MAX_HASHES_PER_EVENT}"
)));
}
}
let mut out: Vec<i64> = match seq.size_hint() {
Some(h) => Vec::with_capacity(h),
None => Vec::new(),
};
while let Some(v) = seq.next_element::<i64>()? {
if out.len() >= MAX_HASHES_PER_EVENT {
return Err(de::Error::custom(format!(
"{PAYLOAD_TOO_LARGE_TAG}:block_hashes:{}:{MAX_HASHES_PER_EVENT}",
out.len() + 1
)));
}
out.push(v);
}
Ok(out)
}
}
let v = deserializer.deserialize_seq(V)?;
Ok(BoundedI64Vec(v))
}
}
/// `BoundedI64Vec`'s `u32` twin. Same shape, different cap.
#[derive(Debug, Clone, PartialEq)]
struct BoundedU32Vec(Vec<u32>);
impl<'de> Deserialize<'de> for BoundedU32Vec {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct V;
impl<'de> Visitor<'de> for V {
type Value = Vec<u32>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a msgpack array of u32 values")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Vec<u32>, A::Error>
where
A: SeqAccess<'de>,
{
if let Some(hint) = seq.size_hint() {
if hint > MAX_TOKENS_PER_EVENT {
return Err(de::Error::custom(format!(
"{PAYLOAD_TOO_LARGE_TAG}:token_ids:{hint}:{MAX_TOKENS_PER_EVENT}"
)));
}
}
let mut out: Vec<u32> = match seq.size_hint() {
Some(h) => Vec::with_capacity(h),
None => Vec::new(),
};
while let Some(v) = seq.next_element::<u32>()? {
if out.len() >= MAX_TOKENS_PER_EVENT {
return Err(de::Error::custom(format!(
"{PAYLOAD_TOO_LARGE_TAG}:token_ids:{}:{MAX_TOKENS_PER_EVENT}",
out.len() + 1
)));
}
out.push(v);
}
Ok(out)
}
}
let v = deserializer.deserialize_seq(V)?;
Ok(BoundedU32Vec(v))
}
}
// ---------------------------------------------------------------------------
// Custom Deserialize impls — msgspec encodes these structs as msgpack arrays
// (not maps), and `omit_defaults=True` means trailing optional fields may be
// absent. We therefore implement `Deserialize` by hand against `SeqAccess`.
// ---------------------------------------------------------------------------
impl<'de> Deserialize<'de> for KvEventBatch {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct BatchVisitor;
impl<'de> Visitor<'de> for BatchVisitor {
type Value = KvEventBatch;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a msgpack array [ts, events, attn_dp_rank?]")
}
fn visit_seq<A>(self, mut seq: A) -> Result<KvEventBatch, A::Error>
where
A: SeqAccess<'de>,
{
let ts: f64 = seq
.next_element()?
.ok_or_else(|| de::Error::missing_field("ts"))?;
let events: Vec<KvCacheEvent> = seq
.next_element()?
.ok_or_else(|| de::Error::missing_field("events"))?;
// attn_dp_rank may be present-as-nil, present-as-int, or
// omitted entirely under msgspec's `omit_defaults`.
let attn_dp_rank: Option<u32> = seq.next_element()?.unwrap_or(None);
// Drain any extra trailing fields a future schema might add
// (forward-compat).
while seq.next_element::<IgnoredAny>()?.is_some() {}
Ok(KvEventBatch {
ts,
events,
attn_dp_rank,
})
}
}
deserializer.deserialize_seq(BatchVisitor)
}
}
impl<'de> Deserialize<'de> for KvCacheEvent {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct EventVisitor;
impl<'de> Visitor<'de> for EventVisitor {
type Value = KvCacheEvent;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a tagged msgpack array [class_name, ...fields]")
}
fn visit_seq<A>(self, mut seq: A) -> Result<KvCacheEvent, A::Error>
where
A: SeqAccess<'de>,
{
let tag: String = seq
.next_element()?
.ok_or_else(|| de::Error::missing_field("event tag"))?;
match tag.as_str() {
"BlockStored" => {
let block_hashes: BoundedI64Vec = seq
.next_element()?
.ok_or_else(|| de::Error::missing_field("block_hashes"))?;
let parent_block_hash: Option<i64> = seq.next_element()?.unwrap_or(None);
let token_ids: BoundedU32Vec = seq
.next_element()?
.ok_or_else(|| de::Error::missing_field("token_ids"))?;
let block_size: u32 = seq
.next_element()?
.ok_or_else(|| de::Error::missing_field("block_size"))?;
// `lora_id` is `Optional[int]` with no default — it's
// always emitted, but as nil when absent.
let lora_id: Option<i64> = seq.next_element()?.unwrap_or(None);
// `medium` defaults to None and may be omitted.
let medium: Option<String> = seq.next_element()?.unwrap_or(None);
while seq.next_element::<IgnoredAny>()?.is_some() {}
Ok(KvCacheEvent::BlockStored(BlockStored {
block_hashes: block_hashes.0,
parent_block_hash,
token_ids: token_ids.0,
block_size,
lora_id,
medium,
}))
}
"BlockRemoved" => {
let block_hashes: BoundedI64Vec = seq
.next_element()?
.ok_or_else(|| de::Error::missing_field("block_hashes"))?;
let medium: Option<String> = seq.next_element()?.unwrap_or(None);
while seq.next_element::<IgnoredAny>()?.is_some() {}
Ok(KvCacheEvent::BlockRemoved(BlockRemoved {
block_hashes: block_hashes.0,
medium,
}))
}
"AllBlocksCleared" => {
while seq.next_element::<IgnoredAny>()?.is_some() {}
Ok(KvCacheEvent::AllBlocksCleared)
}
other => Err(de::Error::unknown_variant(
other,
&["BlockStored", "BlockRemoved", "AllBlocksCleared"],
)),
}
}
}
deserializer.deserialize_seq(EventVisitor)
}
}
// ---------------------------------------------------------------------------
// Tests — golden bytes are constructed via the `rmp` low-level encoder so
// they exercise the exact msgpack array layout SGLang emits, independent of
// any Rust-side serializer.
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use rmp::encode as mp;
/// Encode a tagged event header `[tag, ...]` array of `total_len`
/// elements (tag included).
fn write_event_array(buf: &mut Vec<u8>, tag: &str, total_len: u32) {
mp::write_array_len(buf, total_len).unwrap();
mp::write_str(buf, tag).unwrap();
}
fn write_i64_array(buf: &mut Vec<u8>, values: &[i64]) {
mp::write_array_len(buf, values.len() as u32).unwrap();
for v in values {
mp::write_sint(buf, *v).unwrap();
}
}
fn write_u32_array(buf: &mut Vec<u8>, values: &[u32]) {
mp::write_array_len(buf, values.len() as u32).unwrap();
for v in values {
mp::write_uint(buf, *v as u64).unwrap();
}
}
/// Build a full BlockStored event as msgspec would emit it (all 7
/// elements: tag + 6 fields). `medium` may be Some/None.
fn build_block_stored_bytes(
block_hashes: &[i64],
parent: Option<i64>,
token_ids: &[u32],
block_size: u32,
lora_id: Option<i64>,
medium: Option<&str>,
) -> Vec<u8> {
let mut buf = Vec::new();
write_event_array(&mut buf, "BlockStored", 7);
write_i64_array(&mut buf, block_hashes);
match parent {
Some(v) => {
mp::write_sint(&mut buf, v).unwrap();
}
None => mp::write_nil(&mut buf).unwrap(),
}
write_u32_array(&mut buf, token_ids);
mp::write_uint(&mut buf, block_size as u64).unwrap();
match lora_id {
Some(v) => {
mp::write_sint(&mut buf, v).unwrap();
}
None => mp::write_nil(&mut buf).unwrap(),
}
match medium {
Some(s) => mp::write_str(&mut buf, s).unwrap(),
None => mp::write_nil(&mut buf).unwrap(),
}
buf
}
fn build_block_removed_bytes(block_hashes: &[i64], medium: Option<&str>) -> Vec<u8> {
let mut buf = Vec::new();
write_event_array(&mut buf, "BlockRemoved", 3);
write_i64_array(&mut buf, block_hashes);
match medium {
Some(s) => mp::write_str(&mut buf, s).unwrap(),
None => mp::write_nil(&mut buf).unwrap(),
}
buf
}
fn build_all_blocks_cleared_bytes() -> Vec<u8> {
let mut buf = Vec::new();
write_event_array(&mut buf, "AllBlocksCleared", 1);
buf
}
/// Wrap pre-encoded event bytes into a top-level KVEventBatch array
/// `[ts, [event0_bytes, event1_bytes, ...], attn_dp_rank_or_nil]`.
fn build_batch_bytes(
ts: f64,
event_bufs: &[Vec<u8>],
attn_dp_rank: Option<u32>,
include_dp_field: bool,
) -> Vec<u8> {
let mut buf = Vec::new();
let total_len = if include_dp_field { 3 } else { 2 };
mp::write_array_len(&mut buf, total_len).unwrap();
mp::write_f64(&mut buf, ts).unwrap();
mp::write_array_len(&mut buf, event_bufs.len() as u32).unwrap();
for ev in event_bufs {
buf.extend_from_slice(ev);
}
if include_dp_field {
match attn_dp_rank {
Some(v) => {
mp::write_uint(&mut buf, v as u64).unwrap();
}
None => mp::write_nil(&mut buf).unwrap(),
}
}
buf
}
#[test]
fn decodes_block_stored_with_all_fields() {
let event = build_block_stored_bytes(
&[1234567890123_i64, -987654321_i64],
Some(42),
&[10, 20, 30, 40],
4,
Some(7),
Some("GPU"),
);
let bytes = build_batch_bytes(123.456, &[event], Some(2), true);
let batch = decode_event_batch(&bytes).expect("decode");
assert_eq!(batch.ts, 123.456);
assert_eq!(batch.attn_dp_rank, Some(2));
assert_eq!(batch.events.len(), 1);
match &batch.events[0] {
KvCacheEvent::BlockStored(b) => {
assert_eq!(b.block_hashes, vec![1234567890123_i64, -987654321_i64]);
assert_eq!(b.parent_block_hash, Some(42));
assert_eq!(b.token_ids, vec![10, 20, 30, 40]);
assert_eq!(b.block_size, 4);
assert_eq!(b.lora_id, Some(7));
assert_eq!(b.medium.as_deref(), Some("GPU"));
}
other => panic!("expected BlockStored, got {:?}", other),
}
}
#[test]
fn decodes_block_stored_with_nil_optionals() {
let event = build_block_stored_bytes(&[1, 2, 3], None, &[5, 6], 16, None, None);
let bytes = build_batch_bytes(0.0, &[event], None, true);
let batch = decode_event_batch(&bytes).expect("decode");
match &batch.events[0] {
KvCacheEvent::BlockStored(b) => {
assert_eq!(b.parent_block_hash, None);
assert_eq!(b.lora_id, None);
assert_eq!(b.medium, None);
assert_eq!(b.block_size, 16);
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn decodes_block_removed() {
let event = build_block_removed_bytes(&[100, 200], Some("DISK"));
let bytes = build_batch_bytes(1.0, &[event], Some(0), true);
let batch = decode_event_batch(&bytes).expect("decode");
match &batch.events[0] {
KvCacheEvent::BlockRemoved(r) => {
assert_eq!(r.block_hashes, vec![100, 200]);
assert_eq!(r.medium.as_deref(), Some("DISK"));
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn decodes_all_blocks_cleared() {
let event = build_all_blocks_cleared_bytes();
let bytes = build_batch_bytes(2.0, &[event], None, true);
let batch = decode_event_batch(&bytes).expect("decode");
assert_eq!(batch.events.len(), 1);
assert!(matches!(batch.events[0], KvCacheEvent::AllBlocksCleared));
}
#[test]
fn decodes_mixed_batch_preserving_order() {
let stored = build_block_stored_bytes(&[10], Some(1), &[1, 2], 2, None, Some("GPU"));
let removed = build_block_removed_bytes(&[20], None);
let cleared = build_all_blocks_cleared_bytes();
let bytes = build_batch_bytes(99.0, &[stored, removed, cleared], Some(3), true);
let batch = decode_event_batch(&bytes).expect("decode");
assert_eq!(batch.events.len(), 3);
assert!(matches!(batch.events[0], KvCacheEvent::BlockStored(_)));
assert!(matches!(batch.events[1], KvCacheEvent::BlockRemoved(_)));
assert!(matches!(batch.events[2], KvCacheEvent::AllBlocksCleared));
assert_eq!(batch.attn_dp_rank, Some(3));
}
#[test]
fn attn_dp_rank_omitted_decodes_as_none() {
// msgspec's `omit_defaults=True` may drop attn_dp_rank entirely from
// the wire array when it equals its default of None.
let event = build_all_blocks_cleared_bytes();
let bytes = build_batch_bytes(5.0, &[event], None, /* include_dp_field */ false);
let batch = decode_event_batch(&bytes).expect("decode");
assert_eq!(batch.attn_dp_rank, None);
assert_eq!(batch.events.len(), 1);
}
#[test]
fn medium_omitted_in_block_stored_decodes_as_none() {
// BlockStored with `medium` omitted entirely (omit_defaults can drop
// the trailing default-None field). 6 elements instead of 7.
let mut buf = Vec::new();
write_event_array(&mut buf, "BlockStored", 6);
write_i64_array(&mut buf, &[1]);
mp::write_nil(&mut buf).unwrap(); // parent_block_hash
write_u32_array(&mut buf, &[1, 2]);
mp::write_uint(&mut buf, 2).unwrap(); // block_size
mp::write_nil(&mut buf).unwrap(); // lora_id
let bytes = build_batch_bytes(0.0, &[buf], None, true);
let batch = decode_event_batch(&bytes).expect("decode");
match &batch.events[0] {
KvCacheEvent::BlockStored(b) => assert_eq!(b.medium, None),
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn medium_omitted_in_block_removed_decodes_as_none() {
// BlockRemoved with only [tag, block_hashes] (medium omitted).
let mut buf = Vec::new();
write_event_array(&mut buf, "BlockRemoved", 2);
write_i64_array(&mut buf, &[42]);
let bytes = build_batch_bytes(0.0, &[buf], None, true);
let batch = decode_event_batch(&bytes).expect("decode");
match &batch.events[0] {
KvCacheEvent::BlockRemoved(r) => {
assert_eq!(r.block_hashes, vec![42]);
assert_eq!(r.medium, None);
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn unknown_event_tag_is_rejected() {
let mut buf = Vec::new();
write_event_array(&mut buf, "MysteryEvent", 1);
let bytes = build_batch_bytes(0.0, &[buf], None, true);
let err = decode_event_batch(&bytes).expect_err("should reject unknown variant");
let msg = format!("{err}");
assert!(
msg.contains("MysteryEvent") || msg.contains("unknown variant"),
"unexpected error message: {msg}"
);
}
/// Golden bytes captured from the actual SGLang Python publisher
/// (`msgspec.msgpack.Encoder().encode(KVEventBatch(...))`). These
/// hex strings are produced by msgspec 0.21.1 against the schema in
/// `python/sglang/srt/disaggregation/kv_events.py` and lock down the
/// exact wire format the decoder is expected to consume. Regenerated
/// with `python -c '...msgspec.msgpack.Encoder().encode(...)'`.
mod msgspec_golden {
use super::super::*;
fn hex_to_bytes(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
#[test]
fn full_block_stored() {
// EventBatch(ts=123.456, events=[BlockStored([1234567890123, -987654321],
// parent=42, tokens=[10,20,30,40], block_size=4, lora=7, medium="GPU")],
// attn_dp_rank=2)
let bytes = hex_to_bytes(
"93cb405edd2f1a9fbe779197ab426c6f636b53746f72656492cf0000011f71fb04cbd2c521974f2a940a141e280407a347505502",
);
let batch = decode_event_batch(&bytes).expect("decode msgspec golden");
assert_eq!(batch.ts, 123.456);
assert_eq!(batch.attn_dp_rank, Some(2));
assert_eq!(batch.events.len(), 1);
match &batch.events[0] {
KvCacheEvent::BlockStored(b) => {
assert_eq!(b.block_hashes, vec![1234567890123_i64, -987654321_i64]);
assert_eq!(b.parent_block_hash, Some(42));
assert_eq!(b.token_ids, vec![10, 20, 30, 40]);
assert_eq!(b.block_size, 4);
assert_eq!(b.lora_id, Some(7));
assert_eq!(b.medium.as_deref(), Some("GPU"));
}
other => panic!("expected BlockStored, got {:?}", other),
}
}
#[test]
fn block_stored_with_nil_optionals() {
// ts=0.0, BlockStored([1,2,3], parent=None, tokens=[5,6], block_size=16,
// lora=None, medium=None), attn_dp_rank=None
let bytes = hex_to_bytes(
"93cb00000000000000009197ab426c6f636b53746f72656493010203c092050610c0c0c0",
);
let batch = decode_event_batch(&bytes).expect("decode msgspec golden");
assert_eq!(batch.attn_dp_rank, None);
match &batch.events[0] {
KvCacheEvent::BlockStored(b) => {
assert_eq!(b.block_hashes, vec![1, 2, 3]);
assert_eq!(b.parent_block_hash, None);
assert_eq!(b.token_ids, vec![5, 6]);
assert_eq!(b.block_size, 16);
assert_eq!(b.lora_id, None);
assert_eq!(b.medium, None);
}
other => panic!("unexpected: {:?}", other),
}
}
#[test]
fn block_removed_with_medium() {
// ts=1.0, [BlockRemoved([100, 200], medium="DISK")], attn_dp_rank=0
let bytes = hex_to_bytes(
"93cb3ff00000000000009193ac426c6f636b52656d6f7665649264ccc8a44449534b00",
);
let batch = decode_event_batch(&bytes).expect("decode msgspec golden");
assert_eq!(batch.ts, 1.0);
assert_eq!(batch.attn_dp_rank, Some(0));
match &batch.events[0] {
KvCacheEvent::BlockRemoved(r) => {
assert_eq!(r.block_hashes, vec![100, 200]);
assert_eq!(r.medium.as_deref(), Some("DISK"));
}
other => panic!("unexpected: {:?}", other),
}
}
#[test]
fn all_blocks_cleared() {
// ts=2.0, [AllBlocksCleared()], attn_dp_rank=None
let bytes =
hex_to_bytes("93cb40000000000000009191b0416c6c426c6f636b73436c6561726564c0");
let batch = decode_event_batch(&bytes).expect("decode msgspec golden");
assert_eq!(batch.ts, 2.0);
assert_eq!(batch.attn_dp_rank, None);
assert_eq!(batch.events.len(), 1);
assert!(matches!(batch.events[0], KvCacheEvent::AllBlocksCleared));
}
#[test]
fn mixed_batch() {
// ts=99.0, [BlockStored, BlockRemoved, AllBlocksCleared], attn_dp_rank=3
let bytes = hex_to_bytes(
"93cb4058c000000000009397ab426c6f636b53746f726564910a0192010202c0a347505593ac426c6f636b52656d6f7665649114c091b0416c6c426c6f636b73436c656172656403",
);
let batch = decode_event_batch(&bytes).expect("decode msgspec golden");
assert_eq!(batch.ts, 99.0);
assert_eq!(batch.attn_dp_rank, Some(3));
assert_eq!(batch.events.len(), 3);
match &batch.events[0] {
KvCacheEvent::BlockStored(b) => {
assert_eq!(b.block_hashes, vec![10]);
assert_eq!(b.parent_block_hash, Some(1));
assert_eq!(b.token_ids, vec![1, 2]);
assert_eq!(b.block_size, 2);
assert_eq!(b.lora_id, None);
assert_eq!(b.medium.as_deref(), Some("GPU"));
}
other => panic!("unexpected: {:?}", other),
}
match &batch.events[1] {
KvCacheEvent::BlockRemoved(r) => {
assert_eq!(r.block_hashes, vec![20]);
assert_eq!(r.medium, None);
}
other => panic!("unexpected: {:?}", other),
}
assert!(matches!(batch.events[2], KvCacheEvent::AllBlocksCleared));
}
}
#[test]
fn empty_payload_is_an_error() {
let err = decode_event_batch(&[]).expect_err("empty payload should fail");
// Just assert we surfaced a Msgpack decode error.
assert!(matches!(err, DecodeError::Msgpack(_)));
}
/// A `BlockStored` event whose `block_hashes` array prefix exceeds
/// the per-event cap must be rejected with `PayloadTooLarge` so a
/// misbehaving worker (or a corrupted msgpack length prefix) cannot
/// trigger an unbounded allocation in the gateway. We don't fill the
/// whole array — the visitor refuses on the size_hint alone.
#[test]
fn block_stored_with_too_many_hashes_rejected() {
let claimed = (MAX_HASHES_PER_EVENT + 1) as u32;
let mut event = Vec::new();
write_event_array(&mut event, "BlockStored", 7);
// Oversize block_hashes prefix; only one real element. The
// visitor's size_hint check fires before reading anything.
mp::write_array_len(&mut event, claimed).unwrap();
mp::write_sint(&mut event, 0).unwrap();
// Trailing bytes are ignored — decoder errors out earlier.
let bytes = build_batch_bytes(0.0, &[event], None, true);
let err = decode_event_batch(&bytes).expect_err("oversize hashes should fail");
match err {
DecodeError::PayloadTooLarge { field, len, cap } => {
assert_eq!(field, "block_hashes");
assert_eq!(cap, MAX_HASHES_PER_EVENT);
assert_eq!(len, claimed as usize);
}
other => panic!("expected PayloadTooLarge, got {other:?}"),
}
}
/// `token_ids` cap — uses an oversize msgpack array length prefix.
/// rmp-serde reports `size_hint` from the prefix (an `array_len` is a
/// known length), so the visitor refuses before reading any element.
/// We deliberately under-fill the array to keep the test cheap; the
/// decoder rejects on the prefix alone.
#[test]
fn block_stored_oversize_token_ids_prefix_rejected() {
let claimed = (MAX_TOKENS_PER_EVENT + 1) as u32;
let mut event = Vec::new();
write_event_array(&mut event, "BlockStored", 7);
write_i64_array(&mut event, &[42_i64]); // block_hashes (small)
mp::write_nil(&mut event).unwrap(); // parent_block_hash
// Oversize token_ids: announce huge length but only write a
// single element. The visitor's size_hint check fires
// immediately and we never reach the truncated payload.
mp::write_array_len(&mut event, claimed).unwrap();
mp::write_uint(&mut event, 0).unwrap();
// Trailing bytes after the truncated array are ignored — the
// decoder errors out on the size_hint check before reading them.
let bytes = build_batch_bytes(0.0, &[event], None, true);
let err = decode_event_batch(&bytes).expect_err("oversize token prefix should fail");
match err {
DecodeError::PayloadTooLarge { field, len, cap } => {
assert_eq!(field, "token_ids");
assert_eq!(cap, MAX_TOKENS_PER_EVENT);
assert_eq!(len, claimed as usize);
}
other => panic!("expected PayloadTooLarge, got {other:?}"),
}
}
/// `BlockRemoved` is also covered. Uses the `block_hashes` cap.
#[test]
fn block_removed_with_too_many_hashes_rejected() {
let claimed = (MAX_HASHES_PER_EVENT + 1) as u32;
let mut event = Vec::new();
write_event_array(&mut event, "BlockRemoved", 3);
mp::write_array_len(&mut event, claimed).unwrap();
mp::write_sint(&mut event, 0).unwrap();
// Trailing bytes ignored — decoder errors on the size hint.
let bytes = build_batch_bytes(0.0, &[event], None, true);
let err = decode_event_batch(&bytes).expect_err("oversize hashes should fail");
match err {
DecodeError::PayloadTooLarge { field, cap, .. } => {
assert_eq!(field, "block_hashes");
assert_eq!(cap, MAX_HASHES_PER_EVENT);
}
other => panic!("expected PayloadTooLarge, got {other:?}"),
}
}
}
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
pub mod active_load;
pub mod cache_aware_zmq;
pub mod factory;
pub mod kv_events;
pub mod power_of_two;
pub mod random;
pub mod registry;
pub mod round_robin;
use crate::discovery::ModelId;
use crate::workers::Worker;
use dashmap::DashMap;
use std::sync::Arc;
/// Selection input — carries the request body so that cache-aware policies
/// can hash prefix tokens without reshaping the [`Policy`] trait. Today's
/// policies (round-robin, random, power-of-two) only read `workers`.
///
/// Constructed via [`Self::new`]; accessors expose immutable references so
/// callers cannot mutate the model id or swap in a different body without
/// going through the constructor.
pub struct SelectionContext<'a> {
model: &'a ModelId,
request_body: Option<&'a [u8]>,
}
impl<'a> SelectionContext<'a> {
pub fn new(model: &'a ModelId, request_body: Option<&'a [u8]>) -> Self {
Self {
model,
request_body,
}
}
pub fn model(&self) -> &ModelId {
self.model
}
pub fn request_body(&self) -> Option<&[u8]> {
self.request_body
}
}
pub trait Policy: Send + Sync + std::fmt::Debug {
fn select(&self, workers: &[Arc<Worker>], ctx: &SelectionContext<'_>) -> Option<Arc<Worker>>;
}
#[derive(Debug, Default)]
pub struct PolicyRegistry {
by_model: DashMap<ModelId, Arc<dyn Policy>>,
}
impl PolicyRegistry {
pub fn insert(&self, model: ModelId, policy: Arc<dyn Policy>) {
self.by_model.insert(model, policy);
}
pub fn get(&self, model: &ModelId) -> Option<Arc<dyn Policy>> {
self.by_model.get(model).map(|p| p.clone())
}
}
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use crate::policies::{Policy, SelectionContext};
use crate::workers::Worker;
use rand::seq::IteratorRandom;
use std::sync::Arc;
#[derive(Debug, Default)]
pub struct PowerOfTwoChoicesPolicy;
impl PowerOfTwoChoicesPolicy {
pub fn new() -> Self {
Self
}
}
impl Policy for PowerOfTwoChoicesPolicy {
fn select(&self, workers: &[Arc<Worker>], _ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> {
match workers.len() {
0 => None,
1 => Some(workers[0].clone()),
_ => {
let mut rng = rand::thread_rng();
let mut chosen = workers.iter().choose_multiple(&mut rng, 2);
chosen.sort_by_key(|w| w.active_load());
Some(chosen[0].clone())
}
}
}
}
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use crate::policies::{Policy, SelectionContext};
use crate::workers::Worker;
use rand::seq::SliceRandom;
use std::sync::Arc;
#[derive(Debug, Default)]
pub struct RandomPolicy;
impl RandomPolicy {
pub fn new() -> Self {
Self
}
}
impl Policy for RandomPolicy {
fn select(&self, workers: &[Arc<Worker>], _ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> {
workers.choose(&mut rand::thread_rng()).cloned()
}
}
@@ -0,0 +1,741 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Per-model PD pool resolution.
//!
//! Carries forward the fix from `sgl-project/sglang#25184`: in
//! prefill/decode (PD) disaggregation deployments, prefill traffic must
//! never select a decode worker and vice versa. This module is the
//! single chokepoint that classifies a model as PD or non-PD and exposes
//! pool-restricted candidate sets.
//!
//! # Classification
//!
//! A model is **PD-mode** if its [`WorkerRegistry`] contains workers
//! with [`WorkerMode::Prefill`] OR [`WorkerMode::Decode`]. A model is
//! **plain-mode** if it has only `WorkerMode::Plain` workers (or no
//! workers — both queries return empty for an unknown model). The
//! `(prefill, decode, plain)` partition is computed eagerly per call;
//! tests show this is cheaper than maintaining a side-table and
//! avoiding a race against discovery events.
//!
//! # Why not `Worker::mode` directly in the chat handler?
//!
//! Two reasons:
//!
//! 1. The classification is a *cohort* decision (does the model use PD?),
//! not a per-worker decision. Putting it in the handler means every
//! request route reimplements the same "are any of these prefill?"
//! walk. A central [`PdPoolResolver`] returns the same answer with
//! one call.
//! 2. Errors. The handler needs to distinguish "no workers at all"
//! (existing `NoHealthyWorkers`) from "no prefill workers
//! available for a PD-mode model" (new `NoPrefillWorkersAvailable`)
//! — only the resolver has the cohort context to tell which is which.
use crate::discovery::{ModelId, WorkerMode};
use crate::workers::{Worker, WorkerRegistry};
use std::sync::Arc;
/// Multiplier over the median decode-pool load above which a same-host
/// decode peer is considered "too hot" — we fall back to the lowest-load
/// peer outside the affinity preference. Two-times-median keeps short
/// load bursts on the same host (NCCL chatter, GPU sharing) from being
/// treated as overload while still avoiding pinning to a wedged peer.
const AFFINITY_LOAD_TOLERANCE: f64 = 2.0;
/// Resolution result for a single request route. The handler picks
/// `prefill` / `decode` based on whether it is dispatching prefill or
/// decode traffic; `plain` is for non-PD models.
#[derive(Debug)]
pub enum PdPools {
/// Non-PD deployment: the model is served by plain workers.
Plain { workers: Vec<Arc<Worker>> },
/// PD-disaggregation deployment: the model has prefill and/or decode
/// workers. Either OR BOTH pools may be empty (e.g. every prefill
/// worker's circuit breaker is open, or every PD worker on the
/// model is currently unhealthy). The `*_candidates` helpers are
/// the only safe consumers — they map an empty pool to the
/// appropriate `NoPrefillWorkersAvailable` / `NoDecodeWorkersAvailable`
/// error. Callers that read this variant directly MUST treat an
/// empty pool as a transient failure, not as "zero work".
Pd {
prefill: Vec<Arc<Worker>>,
decode: Vec<Arc<Worker>>,
},
}
/// Reason the resolver could not satisfy a request — exposed so the
/// handler can map to the right HTTP error code.
#[derive(Debug, PartialEq, Eq)]
pub enum PdResolveError {
/// The model has no workers registered at all, healthy or not.
/// Surfaced as 503 `no_healthy_workers`.
NoHealthyWorkers,
/// PD-mode deployment whose prefill pool is empty (all
/// breakers-open or no prefill workers ever registered).
/// Surfaced as 503 `no_prefill_workers_available`.
NoPrefillWorkersAvailable,
/// PD-mode deployment whose decode pool is empty.
/// Surfaced as 503 `no_decode_workers_available`.
NoDecodeWorkersAvailable,
}
/// Thin façade over [`WorkerRegistry`] that returns the per-pool
/// candidate sets for a model. Cheap to construct; the registry is
/// shared.
#[derive(Debug, Clone)]
pub struct PdPoolResolver {
workers: Arc<WorkerRegistry>,
}
impl PdPoolResolver {
pub fn new(workers: Arc<WorkerRegistry>) -> Self {
Self { workers }
}
/// Classify a model and return its pool partition over healthy
/// workers. Workers whose circuit breaker is open are filtered out
/// at this layer so the policy never has to re-check.
///
/// Returns `Err(NoHealthyWorkers)` only when the model has zero
/// **registered** workers (healthy or not). When the model is
/// registered as PD but every PD worker is currently unhealthy
/// (any failure path that flips `breaker.allow()` to false),
/// returns `Ok(Pd { prefill: [], decode: [] })` so
/// `prefill_candidates` / `decode_candidates` can surface the more
/// specific `NoPrefillWorkersAvailable` / `NoDecodeWorkersAvailable`
/// code — operators alerting on partial-pool failures see the same
/// code whether the empty pool is empty by registration or by
/// transient health state.
pub fn resolve(&self, model: &ModelId) -> Result<PdPools, PdResolveError> {
let all = self.workers.healthy_workers_for(model);
if all.is_empty() {
// No healthy workers — distinguish "model never registered"
// (true 404-ish, operator misconfiguration) from "PD model
// with all breakers currently open" (transient health
// issue, deserves the per-pool code).
let registered = self.workers.workers_for(model);
let pd_intent = registered
.iter()
.any(|w| matches!(w.mode(), WorkerMode::Prefill | WorkerMode::Decode));
return if pd_intent {
Ok(PdPools::Pd {
prefill: Vec::new(),
decode: Vec::new(),
})
} else {
Err(PdResolveError::NoHealthyWorkers)
};
}
let mut prefill = Vec::new();
let mut decode = Vec::new();
let mut plain = Vec::new();
for w in all {
match w.mode() {
WorkerMode::Prefill => prefill.push(w),
WorkerMode::Decode => decode.push(w),
WorkerMode::Plain => plain.push(w),
}
}
// PD-mode iff any prefill OR any decode worker exists. Mixing
// plain + prefill on the same model_id is a discovery-level
// misconfiguration we do not try to repair here — we treat any
// role tag at all as PD intent. The plain workers in that case
// become unreachable, which is loud enough at the metrics layer
// for operators to notice.
if !prefill.is_empty() || !decode.is_empty() {
Ok(PdPools::Pd { prefill, decode })
} else {
Ok(PdPools::Plain { workers: plain })
}
}
/// Convenience for the prefill dispatch path. Returns the prefill
/// pool for a PD model, or the full plain pool for a non-PD model.
/// Errors when the relevant pool is empty.
pub fn prefill_candidates(&self, model: &ModelId) -> Result<Vec<Arc<Worker>>, PdResolveError> {
match self.resolve(model)? {
PdPools::Plain { workers } => Ok(workers),
PdPools::Pd { prefill, .. } => {
if prefill.is_empty() {
Err(PdResolveError::NoPrefillWorkersAvailable)
} else {
Ok(prefill)
}
}
}
}
/// Convenience for the decode dispatch path. Mirror of
/// [`Self::prefill_candidates`].
pub fn decode_candidates(&self, model: &ModelId) -> Result<Vec<Arc<Worker>>, PdResolveError> {
match self.resolve(model)? {
PdPools::Plain { workers } => Ok(workers),
PdPools::Pd { decode, .. } => {
if decode.is_empty() {
Err(PdResolveError::NoDecodeWorkersAvailable)
} else {
Ok(decode)
}
}
}
}
/// Pick a decode worker for a PD-mode handoff with **host affinity**
/// to the prefill worker. Resolves the decode pool for `model`, then
/// applies the affinity rules in [`select_decode_with_affinity`].
///
/// Returns `Err(NoDecodeWorkersAvailable)` if the decode pool is
/// empty (PD-mode partial failure) — the chat handler then maps to
/// 503 `no_decode_workers_available`. For non-PD (plain) models
/// this is a no-op call — there is no decode peer to find — and
/// the caller should NOT use this helper.
pub fn decode_with_affinity(
&self,
model: &ModelId,
prefill_url: &str,
) -> Result<Arc<Worker>, PdResolveError> {
let candidates = self.decode_candidates(model)?;
select_decode_with_affinity(prefill_url, &candidates)
.ok_or(PdResolveError::NoDecodeWorkersAvailable)
}
}
/// Pick a decode worker from `candidates` preferring the one whose URL
/// shares a host with `prefill_url`. Falls back to lowest-load when no
/// same-host peer exists, when the same-host peer's breaker is open,
/// or when the same-host peer is overloaded relative to the pool.
///
/// # Rules
///
/// 1. **Same-host preference.** Parse the host portion of both URLs
/// (`url::Url::host_str`). If any candidate shares the host AND has
/// a closed circuit breaker AND has `active_load <=
/// AFFINITY_LOAD_TOLERANCE × median(decode_pool_load)`, return it.
/// 2. **Fallback: min-load among closed-breaker candidates.** No
/// same-host peer, or the same-host peer was filtered by rule 1's
/// health/load gates.
/// 3. **Last resort: min-load over ALL candidates.** Every candidate
/// has its breaker open; the next dispatch will likely fail too,
/// but a min-load fallback keeps the selection function total.
/// Callers should observe the breaker-open error and surface it as
/// `BreakerOpen`, not silently retry.
///
/// Returns `None` only when `candidates` is empty.
///
/// # Why a free-standing function vs a `Policy::select` extension?
///
/// The current `Policy` trait carries `(workers, ctx)`; adding an
/// `affinity_hint` argument would touch every policy implementation
/// (`round_robin`, `random`, `power_of_two`, `cache_aware_zmq`).
/// Affinity is a PD-routing concern — orthogonal to the in-pool
/// scoring the trait abstracts — so keeping it as a sibling helper
/// keeps the trait's responsibility narrow.
pub fn select_decode_with_affinity(
prefill_url: &str,
candidates: &[Arc<Worker>],
) -> Option<Arc<Worker>> {
if candidates.is_empty() {
return None;
}
let prefill_host = host_of(prefill_url);
// Build the closed-breaker subset once; both the affinity branch
// and the fallback branch read from it. `would_allow` (non-mutating)
// is the right filter — `allow()` would claim a half-open probe for
// every candidate we look at, including ones we never dispatch to.
let healthy: Vec<&Arc<Worker>> = candidates
.iter()
.filter(|w| w.breaker.would_allow())
.collect();
// Compute the median load over the closed-breaker subset. Empty
// subset → median is 0 (means: every peer's breaker is open; the
// affinity gate is moot, we'll fall through to the last-resort
// branch).
let load_tolerance = if healthy.is_empty() {
0
} else {
let mut loads: Vec<usize> = healthy.iter().map(|w| w.active_load()).collect();
loads.sort_unstable();
let median = loads[loads.len() / 2];
((median as f64) * AFFINITY_LOAD_TOLERANCE).ceil() as usize
};
// Rule 1: same-host AND healthy AND not overloaded.
if let Some(host) = prefill_host.as_deref() {
let affinity_peer = healthy.iter().find(|w| {
host_of(&w.url).as_deref() == Some(host)
&& (load_tolerance == 0 || w.active_load() <= load_tolerance)
});
if let Some(w) = affinity_peer {
return Some(Arc::clone(w));
}
}
// Rule 2: min-load among healthy.
if let Some(w) = healthy.iter().min_by_key(|w| w.active_load()) {
return Some(Arc::clone(w));
}
// Rule 3: last-resort min-load over all candidates (every
// breaker is open). The caller's dispatch will likely fail and
// surface `BreakerOpen`, but the selection function stays total.
candidates.iter().min_by_key(|w| w.active_load()).cloned()
}
/// Parse the host portion of a worker URL. Returns `None` when the URL
/// fails to parse or has no host (rare; discovery emits URLs the proxy
/// has already used at least once for /server_info, so this is mostly
/// defensive).
fn host_of(worker_url: &str) -> Option<String> {
url::Url::parse(worker_url)
.ok()?
.host_str()
.map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::{ModelId, WorkerId, WorkerSpec};
fn spec(id: &str, mode: WorkerMode, model: &str) -> WorkerSpec {
WorkerSpec {
id: WorkerId(id.into()),
url: format!("http://{id}"),
mode,
model_ids: vec![ModelId(model.into())],
bootstrap_port: None,
}
}
fn registry(specs: &[WorkerSpec]) -> Arc<WorkerRegistry> {
let r = Arc::new(WorkerRegistry::default());
for s in specs {
let _ = r.add(s.clone());
}
r
}
/// Model with only Plain workers → Plain partition.
#[test]
fn plain_mode_returns_all_plain_workers() {
let r = registry(&[
spec("w1", WorkerMode::Plain, "m"),
spec("w2", WorkerMode::Plain, "m"),
]);
let res = PdPoolResolver::new(r)
.resolve(&ModelId("m".into()))
.unwrap();
match res {
PdPools::Plain { workers } => assert_eq!(workers.len(), 2),
PdPools::Pd { .. } => panic!("expected Plain"),
}
}
/// Model with prefill + decode → Pd partition, both pools populated.
#[test]
fn pd_mode_returns_distinct_pools() {
let r = registry(&[
spec("p1", WorkerMode::Prefill, "m"),
spec("d1", WorkerMode::Decode, "m"),
spec("d2", WorkerMode::Decode, "m"),
]);
let res = PdPoolResolver::new(r)
.resolve(&ModelId("m".into()))
.unwrap();
match res {
PdPools::Pd { prefill, decode } => {
assert_eq!(prefill.len(), 1);
assert_eq!(decode.len(), 2);
// No cross-contamination: each worker carries the
// right mode.
assert!(prefill.iter().all(|w| w.mode() == WorkerMode::Prefill));
assert!(decode.iter().all(|w| w.mode() == WorkerMode::Decode));
}
PdPools::Plain { .. } => panic!("expected Pd"),
}
}
/// Unknown model → NoHealthyWorkers.
#[test]
fn unknown_model_returns_no_healthy_workers() {
let r = Arc::new(WorkerRegistry::default());
let err = PdPoolResolver::new(r)
.resolve(&ModelId("ghost".into()))
.unwrap_err();
assert_eq!(err, PdResolveError::NoHealthyWorkers);
}
/// Gap closer #1: PD mode with no prefill workers → resolve()
/// returns a Pd partition with an empty prefill pool, and
/// `prefill_candidates()` errors with NoPrefillWorkersAvailable.
#[test]
fn pd_mode_with_no_prefill_errors_on_prefill_dispatch() {
let r = registry(&[
spec("d1", WorkerMode::Decode, "m"),
spec("d2", WorkerMode::Decode, "m"),
]);
let resolver = PdPoolResolver::new(r);
let model = ModelId("m".into());
// resolve() succeeds — we have decode workers.
match resolver.resolve(&model).unwrap() {
PdPools::Pd { prefill, decode } => {
assert!(prefill.is_empty());
assert_eq!(decode.len(), 2);
}
other => panic!("expected Pd, got {other:?}"),
}
// prefill_candidates errors.
let err = resolver.prefill_candidates(&model).unwrap_err();
assert_eq!(err, PdResolveError::NoPrefillWorkersAvailable);
// decode_candidates succeeds.
let decode = resolver.decode_candidates(&model).unwrap();
assert_eq!(decode.len(), 2);
}
/// PD mode where every breaker is open (e.g. the upstream pool went
/// hard down) must NOT collapse to the generic `NoHealthyWorkers`
/// code. Both `prefill_candidates` and `decode_candidates` should
/// still surface the per-pool variant so operators can alert on
/// "prefill tier degraded" independently from "model misconfigured".
#[test]
fn pd_mode_all_breakers_open_keeps_per_pool_codes() {
let r = registry(&[
spec("p1", WorkerMode::Prefill, "m"),
spec("d1", WorkerMode::Decode, "m"),
]);
let resolver = PdPoolResolver::new(r);
let model = ModelId("m".into());
// Trip both breakers. Loop on `allow()` (not a fixed count) so
// the test stays correct if the default `CircuitBreakerConfig`
// threshold ever changes.
for w in resolver.workers.workers_for(&model) {
while w.breaker.allow() {
w.breaker.record_failure();
}
}
// resolve() still returns a PD shape (both pools empty) — the
// PD intent is preserved across the breaker-open state.
match resolver.resolve(&model).unwrap() {
PdPools::Pd { prefill, decode } => {
assert!(prefill.is_empty());
assert!(decode.is_empty());
}
other => panic!("expected Pd, got {other:?}"),
}
// prefill dispatch → NoPrefillWorkersAvailable (not NoHealthyWorkers).
assert_eq!(
resolver.prefill_candidates(&model).unwrap_err(),
PdResolveError::NoPrefillWorkersAvailable,
);
// decode dispatch → NoDecodeWorkersAvailable (not NoHealthyWorkers).
assert_eq!(
resolver.decode_candidates(&model).unwrap_err(),
PdResolveError::NoDecodeWorkersAvailable,
);
}
/// Symmetric: PD mode with no decode workers → decode dispatch
/// errors.
#[test]
fn pd_mode_with_no_decode_errors_on_decode_dispatch() {
let r = registry(&[
spec("p1", WorkerMode::Prefill, "m"),
spec("p2", WorkerMode::Prefill, "m"),
]);
let resolver = PdPoolResolver::new(r);
let model = ModelId("m".into());
let err = resolver.decode_candidates(&model).unwrap_err();
assert_eq!(err, PdResolveError::NoDecodeWorkersAvailable);
}
/// PR #25184 carry-forward: separate models don't cross-contaminate.
/// One model is PD, the other is plain; resolving one must not return
/// workers from the other's pool.
#[test]
fn distinct_models_isolated_across_pd_and_plain() {
let r = registry(&[
spec("plain1", WorkerMode::Plain, "plainmodel"),
spec("p1", WorkerMode::Prefill, "pdmodel"),
spec("d1", WorkerMode::Decode, "pdmodel"),
]);
let resolver = PdPoolResolver::new(r);
match resolver.resolve(&ModelId("plainmodel".into())).unwrap() {
PdPools::Plain { workers } => assert_eq!(workers.len(), 1),
_ => panic!("plainmodel should resolve to Plain"),
}
match resolver.resolve(&ModelId("pdmodel".into())).unwrap() {
PdPools::Pd { prefill, decode } => {
assert_eq!(prefill.len(), 1);
assert_eq!(decode.len(), 1);
}
_ => panic!("pdmodel should resolve to Pd"),
}
}
/// Plain-mode prefill_candidates returns the plain pool (non-PD
/// shorthand: dispatch helpers Just Work for plain models).
#[test]
fn plain_mode_prefill_candidates_returns_plain_pool() {
let r = registry(&[spec("w1", WorkerMode::Plain, "m")]);
let resolver = PdPoolResolver::new(r);
let v = resolver.prefill_candidates(&ModelId("m".into())).unwrap();
assert_eq!(v.len(), 1);
assert_eq!(v[0].mode(), WorkerMode::Plain);
}
// === Decoder affinity (Task C) ===
/// Build a `WorkerSpec` with an explicit URL — the affinity tests
/// distinguish workers by host, so they care about the URL string
/// directly, not the generated `http://{id}` form.
fn spec_with_url(id: &str, url: &str, mode: WorkerMode, model: &str) -> WorkerSpec {
WorkerSpec {
id: WorkerId(id.into()),
url: url.into(),
mode,
model_ids: vec![ModelId(model.into())],
bootstrap_port: None,
}
}
/// Same-host affinity: a request that lands on `prefill@host_a`
/// picks `decode@host_a` even when `decode@host_b` has lower load.
/// Pin: the affinity branch wins over load tiebreak when both
/// candidates are healthy and not overloaded.
#[test]
fn decoder_picks_same_host_when_available() {
let r = registry(&[
spec_with_url("p1", "http://host_a:30000", WorkerMode::Prefill, "m"),
spec_with_url("d1", "http://host_a:30001", WorkerMode::Decode, "m"),
spec_with_url("d2", "http://host_b:30001", WorkerMode::Decode, "m"),
]);
let resolver = PdPoolResolver::new(r);
let prefill_url = "http://host_a:30000";
let chosen = resolver
.decode_with_affinity(&ModelId("m".into()), prefill_url)
.unwrap();
assert_eq!(
chosen.url, "http://host_a:30001",
"same-host decode peer must win over remote peer",
);
}
/// Affinity peer's breaker is open → fall back to the remote
/// healthy peer. Pin: the affinity rule must not pin a request to
/// a known-bad worker just because the host matches.
#[test]
fn decoder_falls_back_when_affinity_peer_breaker_open() {
let r = registry(&[
spec_with_url("p1", "http://host_a:30000", WorkerMode::Prefill, "m"),
spec_with_url("d1", "http://host_a:30001", WorkerMode::Decode, "m"),
spec_with_url("d2", "http://host_b:30001", WorkerMode::Decode, "m"),
]);
let resolver = PdPoolResolver::new(r);
// Trip d1's breaker by saturating record_failure() against the
// default config (threshold = 3). The breaker then denies
// `allow()` until the cooldown elapses.
let d1 = resolver
.workers
.healthy_workers_for(&ModelId("m".into()))
.into_iter()
.find(|w| w.url == "http://host_a:30001")
.unwrap();
for _ in 0..3 {
d1.breaker.record_failure();
}
assert!(!d1.breaker.allow(), "d1 breaker must be open");
let chosen = resolver
.decode_with_affinity(&ModelId("m".into()), "http://host_a:30000")
.unwrap();
assert_eq!(
chosen.url, "http://host_b:30001",
"breaker-open affinity peer must fall back to the remote healthy peer",
);
}
/// Affinity peer is overloaded (load > 2× median) → fall back to
/// the remote lower-load peer. Pin: the load gate prevents a single
/// host's wedged decode worker from absorbing every co-located
/// prefill request.
#[test]
fn decoder_falls_back_when_affinity_peer_load_imbalance() {
let r = registry(&[
spec_with_url("p1", "http://host_a:30000", WorkerMode::Prefill, "m"),
spec_with_url("d1", "http://host_a:30001", WorkerMode::Decode, "m"),
spec_with_url("d2", "http://host_b:30001", WorkerMode::Decode, "m"),
spec_with_url("d3", "http://host_c:30001", WorkerMode::Decode, "m"),
]);
let resolver = PdPoolResolver::new(r);
// Loads: d1=20, d2=2, d3=2. Median = 2. 2× tolerance = 4.
// d1 is overloaded (20 > 4) → affinity rule rejects d1.
let decode_pool = resolver
.workers
.healthy_workers_for(&ModelId("m".into()))
.into_iter()
.filter(|w| w.mode() == WorkerMode::Decode)
.collect::<Vec<_>>();
let d1 = decode_pool
.iter()
.find(|w| w.url == "http://host_a:30001")
.unwrap();
let d2 = decode_pool
.iter()
.find(|w| w.url == "http://host_b:30001")
.unwrap();
let d3 = decode_pool
.iter()
.find(|w| w.url == "http://host_c:30001")
.unwrap();
let mut guards = Vec::new();
for _ in 0..20 {
guards.push(d1.load_guard());
}
for _ in 0..2 {
guards.push(d2.load_guard());
guards.push(d3.load_guard());
}
let chosen = resolver
.decode_with_affinity(&ModelId("m".into()), "http://host_a:30000")
.unwrap();
assert!(
chosen.url == "http://host_b:30001" || chosen.url == "http://host_c:30001",
"overloaded affinity peer must fall back to a remote min-load peer, got: {}",
chosen.url,
);
// Drop guards explicitly so the test cleanup doesn't depend on
// RAII order against the resolver / registry.
drop(guards);
}
/// No same-host decode peer exists → fall back to min-load remote.
#[test]
fn decoder_falls_back_when_no_same_host_peer() {
let r = registry(&[
spec_with_url("p1", "http://host_a:30000", WorkerMode::Prefill, "m"),
spec_with_url("d1", "http://host_b:30001", WorkerMode::Decode, "m"),
spec_with_url("d2", "http://host_c:30001", WorkerMode::Decode, "m"),
]);
let resolver = PdPoolResolver::new(r);
// Bump d1 to 1, d2 stays at 0 — min-load picks d2.
let pool = resolver
.workers
.healthy_workers_for(&ModelId("m".into()))
.into_iter()
.filter(|w| w.mode() == WorkerMode::Decode)
.collect::<Vec<_>>();
let d1 = pool
.iter()
.find(|w| w.url == "http://host_b:30001")
.unwrap();
let _g = d1.load_guard();
let chosen = resolver
.decode_with_affinity(&ModelId("m".into()), "http://host_a:30000")
.unwrap();
assert_eq!(
chosen.url, "http://host_c:30001",
"no same-host peer → min-load fallback over remote candidates",
);
}
/// Empty decode pool → `NoDecodeWorkersAvailable`. The chat
/// handler maps this to 503 `no_decode_workers_available`.
#[test]
fn decoder_with_affinity_returns_error_when_pool_empty() {
let r = registry(&[spec_with_url(
"p1",
"http://host_a:30000",
WorkerMode::Prefill,
"m",
)]);
let resolver = PdPoolResolver::new(r);
let err = resolver
.decode_with_affinity(&ModelId("m".into()), "http://host_a:30000")
.unwrap_err();
assert_eq!(err, PdResolveError::NoDecodeWorkersAvailable);
}
/// Prefill URL is malformed (no host) → still picks a min-load
/// decode peer. Affinity is best-effort; a parse failure must not
/// kill the request.
#[test]
fn decoder_handles_malformed_prefill_url_via_min_load_fallback() {
let r = registry(&[
spec_with_url("d1", "http://host_a:30001", WorkerMode::Decode, "m"),
spec_with_url("d2", "http://host_b:30001", WorkerMode::Decode, "m"),
]);
let resolver = PdPoolResolver::new(r);
let chosen = resolver
.decode_with_affinity(&ModelId("m".into()), "not-a-url")
.unwrap();
// Both d1 and d2 are at load 0 → either is acceptable. The
// assertion is only that the function returns Some, not None
// / panic.
assert!(
chosen.url == "http://host_a:30001" || chosen.url == "http://host_b:30001",
"unexpected decode worker chosen: {}",
chosen.url,
);
}
/// All decode peers' breakers are open → `decode_with_affinity`
/// surfaces `NoDecodeWorkersAvailable` (the per-pool variant), not
/// the generic `NoHealthyWorkers`. The PD intent is preserved
/// through `resolve` so operators alerting on "decode tier down"
/// see the same code regardless of whether the pool is empty by
/// registration or by breaker state.
///
/// The lower-level helper [`select_decode_with_affinity`] is total
/// even when every candidate's breaker is open (rule 3 in the
/// docstring): tests that call it directly with breaker-open
/// candidates get a min-load result.
#[test]
fn decoder_with_affinity_errors_when_all_breakers_open() {
let r = registry(&[
spec_with_url("d1", "http://host_a:30001", WorkerMode::Decode, "m"),
spec_with_url("d2", "http://host_b:30001", WorkerMode::Decode, "m"),
]);
let resolver = PdPoolResolver::new(r);
let pool = resolver
.workers
.workers_for(&ModelId("m".into()))
.into_iter()
.filter(|w| w.mode() == WorkerMode::Decode)
.collect::<Vec<_>>();
// Trip every decode breaker; loop on `allow()` for threshold
// resilience.
for w in &pool {
while w.breaker.allow() {
w.breaker.record_failure();
}
}
// resolver path: healthy_workers_for returns empty, but the
// model is registered as PD (decode peers exist), so resolve()
// preserves PD shape and decode_with_affinity surfaces the
// per-pool code.
let err = resolver
.decode_with_affinity(&ModelId("m".into()), "http://host_a:30000")
.unwrap_err();
assert_eq!(err, PdResolveError::NoDecodeWorkersAvailable);
// helper path with a non-empty (but all-breaker-open) slice
// returns Some via the last-resort branch — selection function
// stays total, caller sees `BreakerOpen` on dispatch.
let any = select_decode_with_affinity("http://host_a:30000", &pool).unwrap();
assert!(
any.url == "http://host_a:30001" || any.url == "http://host_b:30001",
"last-resort path must return some candidate, got: {}",
any.url,
);
}
}
@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use crate::policies::{Policy, SelectionContext};
use crate::workers::Worker;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
#[derive(Debug, Default)]
pub struct RoundRobinPolicy {
counter: AtomicUsize,
}
impl RoundRobinPolicy {
pub fn new() -> Self {
Self::default()
}
}
impl Policy for RoundRobinPolicy {
fn select(&self, workers: &[Arc<Worker>], _ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> {
if workers.is_empty() {
return None;
}
let i = self.counter.fetch_add(1, Ordering::Relaxed) % workers.len();
Some(workers[i].clone())
}
}
+250
View File
@@ -0,0 +1,250 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! HTTP proxy — forwards requests to the upstream SGLang worker.
pub mod sse;
use crate::health::circuit_breaker::CircuitBreaker;
use crate::server::error::ApiError;
use crate::server::header_utils::should_forward_request_header;
use anyhow::Context;
use axum::body::Body;
use axum::http::{HeaderMap, HeaderName, HeaderValue, Response};
use bytes::Bytes;
use reqwest::{Client, Url};
use std::sync::Arc;
use std::time::Duration;
/// Parse a worker URL emitted by discovery. On failure, trip the worker's
/// circuit breaker so the malformed worker drops out of subsequent
/// `healthy_workers_for(...)` selection, then surface the error as
/// `ApiError::WorkerMisconfigured`.
fn parse_worker_url(worker_url: &str, breaker: &CircuitBreaker) -> Result<Url, ApiError> {
Url::parse(worker_url).map_err(|e| {
breaker.record_failure();
ApiError::WorkerMisconfigured {
worker: worker_url.to_string(),
source: anyhow::Error::new(e).context("parse worker URL"),
}
})
}
#[derive(Debug)]
pub struct Proxy {
pub client: Client,
/// Wall-clock timeout applied to non-streaming upstream requests. Streaming
/// requests deliberately do not use this (long generations are valid).
pub request_timeout: Duration,
}
impl Proxy {
/// Build a proxy. `request_timeout` is the per-request wall-clock budget for
/// non-streaming forwards. Connect timeout is hard-coded to 5 s — even a
/// streaming request fails fast at TCP setup if the worker is unreachable.
pub fn new(request_timeout: Duration) -> Result<Self, anyhow::Error> {
let client = Client::builder()
.pool_max_idle_per_host(64)
.connect_timeout(Duration::from_secs(5))
.build()
.context("build reqwest client")?;
Ok(Self {
client,
request_timeout,
})
}
/// Classify a reqwest error into the right `ApiError` variant, given an
/// explicit worker URL. Called from the breaker-gated `forward_*_to`
/// methods, which carry per-request worker URLs (not a single proxy-level
/// URL).
///
/// Walks the full source chain to detect timeouts, because reqwest wraps
/// hyper which wraps `std::io::Error` — a top-level `is_timeout()` check
/// misses both the wrapped reqwest timeout and the `io::ErrorKind::TimedOut`
/// cases.
fn classify_reqwest_error_for(worker: Url, e: reqwest::Error, path: &str) -> ApiError {
let source = anyhow::Error::new(e).context(format!("worker {worker}: post {path}"));
let is_timeout = source.chain().any(|c| {
c.downcast_ref::<reqwest::Error>()
.is_some_and(|r| r.is_timeout())
}) || source.chain().any(|c| {
c.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::TimedOut)
});
if is_timeout {
ApiError::UpstreamTimeout { worker }
} else {
ApiError::UpstreamUnreachable { worker, source }
}
}
/// Breaker-gated JSON POST: checks `breaker.allow()` first, records
/// success/failure based on response status, and returns
/// `ApiError::BreakerOpen` immediately when the breaker is Open.
///
/// `worker_url` is the discovery-emitted worker URL string. It's parsed
/// to [`reqwest::Url`] internally so we can use [`Url::join`] for clean
/// path concatenation (no double-slash) and pass a typed URL to the
/// split error variants (`UpstreamUnreachable` / `UpstreamTimeout` /
/// `UpstreamStatus`).
pub async fn forward_json_to(
&self,
worker_url: &str,
breaker: &CircuitBreaker,
path: &str,
headers: &HeaderMap,
body: Bytes,
) -> Result<Response<Body>, ApiError> {
if !breaker.allow() {
return Err(ApiError::BreakerOpen {
worker: worker_url.to_string(),
});
}
let worker_url = parse_worker_url(worker_url, breaker)?;
let url = worker_url.join(path).map_err(|e| {
ApiError::Internal(anyhow::Error::new(e).context(format!("join worker path {path}")))
})?;
let mut req = self.client.post(url.clone()).body(body);
for (k, v) in headers {
if should_forward_request_header(k) {
req = req.header(k, v);
}
}
req = req
.header("content-type", "application/json")
.timeout(self.request_timeout);
let resp = req.send().await.map_err(|e| {
breaker.record_failure();
Self::classify_reqwest_error_for(worker_url.clone(), e, path)
})?;
let status = resp.status();
// Defer breaker recording until after the body completes — a
// worker that returns 2xx headers and then drops mid-body is
// still failing the request, and crediting it as healthy lets
// a misbehaving worker stay eligible. For 5xx the early bail is
// safe (no body to consume meaningfully), but we still wait
// until after the read attempt to record exactly once.
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(e) => {
tracing::warn!(
upstream = %url,
status = %status,
error = ?e,
"upstream dropped connection mid-body",
);
breaker.record_failure();
return Err(ApiError::UpstreamStatus { status });
}
};
if status.is_server_error() {
breaker.record_failure();
} else {
breaker.record_success();
}
let mut out = Response::new(Body::from(bytes));
*out.status_mut() = status;
out.headers_mut().insert(
HeaderName::from_static("content-type"),
HeaderValue::from_static("application/json"),
);
Ok(out)
}
/// Breaker-gated streaming POST: checks `breaker.allow()` first, records
/// success/failure, and returns `ApiError::BreakerOpen` when Open.
///
/// `stream_guards` — when `Some`, the value is threaded into the SSE
/// pump task and held for the entire body lifetime (headers → last byte
/// / client disconnect). The proxy does not inspect the boxed value; it
/// relies entirely on `Drop` semantics, so callers typically pack
/// `(LoadGuard, ActiveLoadGuard)` here. This keeps both the per-worker
/// `active_requests` counter and the per-request active-load entry alive
/// for the full streaming lifetime — without which a long-running SSE
/// response would under-report load.
pub async fn forward_streaming_to(
&self,
worker_url: &str,
breaker: &Arc<CircuitBreaker>,
path: &str,
headers: &HeaderMap,
body: Bytes,
stream_guards: Option<Box<dyn Send + 'static>>,
) -> Result<Response<Body>, ApiError> {
if !breaker.allow() {
return Err(ApiError::BreakerOpen {
worker: worker_url.to_string(),
});
}
let worker_url = parse_worker_url(worker_url, breaker)?;
let url = worker_url.join(path).map_err(|e| {
ApiError::Internal(anyhow::Error::new(e).context(format!("join worker path {path}")))
})?;
let mut req = self.client.post(url.clone()).body(body);
for (k, v) in headers {
if should_forward_request_header(k) {
req = req.header(k, v);
}
}
req = req
.header("content-type", "application/json")
.header("accept", "text/event-stream");
let resp = req.send().await.map_err(|e| {
breaker.record_failure();
Self::classify_reqwest_error_for(worker_url.clone(), e, path)
})?;
let status = resp.status();
let upstream_ct = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/json")
.to_string();
let content_type = if status.is_success() {
"text/event-stream".to_string()
} else {
upstream_ct
};
// Breaker recording is deferred to the pump's completion hook so
// an upstream that returns 2xx headers and then drops mid-stream
// is recorded as a failure. For 5xx headers we record_failure
// up front and skip the pump hook (the body we surface is the
// error response — its stream completing is not a worker win).
let on_complete: Option<Box<dyn FnOnce(bool) + Send + 'static>> =
if status.is_server_error() {
breaker.record_failure();
None
} else {
let breaker_for_hook = Arc::clone(breaker);
Some(Box::new(move |ok| {
if ok {
breaker_for_hook.record_success();
} else {
breaker_for_hook.record_failure();
}
}))
};
let body = sse::bytes_stream_to_body(resp.bytes_stream(), stream_guards, on_complete);
let mut out = Response::new(body);
*out.status_mut() = status;
out.headers_mut().insert(
HeaderName::from_static("content-type"),
HeaderValue::from_str(&content_type)
.unwrap_or_else(|_| HeaderValue::from_static("application/json")),
);
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[tokio::test]
async fn new_returns_result_not_panic() {
let p = Proxy::new(Duration::from_secs(5)).unwrap();
assert_eq!(p.request_timeout, Duration::from_secs(5));
}
}
+324
View File
@@ -0,0 +1,324 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! SSE passthrough — bridges a reqwest `bytes_stream()` into an axum Body.
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use axum::body::Body;
use bytes::Bytes;
use futures::{FutureExt, StreamExt};
use tokio_stream::wrappers::ReceiverStream;
/// Bridge a byte stream into an axum Body that streams chunks unchanged.
///
/// Spawns one tokio task per stream so the handler can return immediately.
/// Uses a **bounded** 64-slot channel so `tx.send().await` naturally
/// backpressures the upstream read when the client (axum Body consumer) falls
/// behind — an unbounded channel would buffer hundreds of MB for a slow client
/// receiving a long completion.
///
/// # Backpressure note
/// The channel bound of 64 absorbs short bursts while still limiting
/// worst-case outstanding bytes to 64 × chunk_size (typically a few MB).
///
/// # Client disconnect
/// When the axum Body is dropped the receiver is closed; `tx.send()` then
/// returns `Err`, which breaks the loop — no upstream bytes are read after the
/// client disconnects.
///
/// # Panic safety
/// The pump future is wrapped in `AssertUnwindSafe(..).catch_unwind()`. If the
/// upstream stream panics, we surface a loud `io::Error` to the client; without
/// this, the body would EOF cleanly and clients couldn't distinguish that from
/// success — the worst failure class (truncated output that looks complete).
///
/// # Stream guards
/// When `stream_guards` is `Some`, the value is **moved into the spawned task**
/// and held for the entire body lifetime. It is dropped only when the SSE
/// pump finishes (stream exhausted, client disconnects, or upstream errors).
/// The opaque `Box<dyn Send + 'static>` accepts any drop-only payload — most
/// commonly a tuple of [`crate::workers::LoadGuard`] and
/// [`crate::policies::active_load::ActiveLoadGuard`]. The proxy does not
/// inspect the value; it relies entirely on `Drop` semantics, so callers can
/// pack arbitrary cleanup state in. Pass `None` for callers that manage the
/// guard externally (e.g. non-streaming paths where the handler itself is the
/// guard scope).
///
/// # Completion hook
/// When `on_complete` is `Some`, the closure runs exactly once when the
/// pump task finishes. The bool argument is `true` on clean stream end
/// (including a clean client disconnect after at least the headers
/// landed cleanly), `false` on upstream stream error or pump panic.
/// `forward_streaming_to` passes a closure that records the worker's
/// circuit-breaker outcome — without this hook, a worker that returns
/// 2xx headers and then drops the stream mid-flight would stay credited
/// as healthy.
pub fn bytes_stream_to_body<S, E>(
stream: S,
stream_guards: Option<Box<dyn Send + 'static>>,
on_complete: Option<Box<dyn FnOnce(bool) + Send + 'static>>,
) -> Body
where
S: futures::Stream<Item = Result<Bytes, E>> + Send + Unpin + 'static,
E: std::fmt::Display + Send + Sync + 'static,
{
let (tx, rx) = tokio::sync::mpsc::channel(64);
tokio::spawn(async move {
let tx_for_panic = tx.clone();
// Capture the pump's outcome so we can report it through `on_complete`
// AFTER `pump.catch_unwind()` settles. The closure inside owns
// `outcome_setter`; the outer scope reads `outcome_holder` once.
let outcome_holder = Arc::new(parking_lot::Mutex::new(true));
let outcome_setter = Arc::clone(&outcome_holder);
let pump = AssertUnwindSafe(async move {
// Hold the guards for the task's lifetime — dropped when this
// block exits (stream done or client disconnect). Leading
// underscore suppresses the "unused variable" lint while
// keeping intent explicit.
let _hold = stream_guards;
let mut s = stream;
while let Some(chunk) = s.next().await {
let item: Result<Bytes, std::io::Error> = chunk.map_err(|e| {
let msg = e.to_string();
tracing::warn!(error = %msg, "upstream SSE stream errored mid-flight");
std::io::Error::other(msg)
});
let is_err_chunk = item.is_err();
if is_err_chunk {
*outcome_setter.lock() = false;
}
if tx.send(item).await.is_err() {
// Receiver dropped. If we were about to ship an upstream
// error there's nothing left to report; otherwise this is
// a clean client-side disconnect — log at debug since it's
// not a router-side fault.
if !is_err_chunk {
tracing::debug!("SSE client disconnected mid-stream");
}
break;
}
if is_err_chunk {
// Surfaced upstream error to client; stop reading.
break;
}
}
});
let pump_result = pump.catch_unwind().await;
let panicked = pump_result.is_err();
if let Err(panic_payload) = pump_result {
let msg = panic_payload
.downcast_ref::<&'static str>()
.map(|s| (*s).to_string())
.or_else(|| panic_payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "<non-string panic payload>".to_string());
tracing::error!(error = %msg, "SSE pump task panicked");
let _ = tx_for_panic
.send(Err(std::io::Error::other(format!(
"SSE pump panicked: {msg}"
))))
.await;
}
if let Some(hook) = on_complete {
let ok = !panicked && *outcome_holder.lock();
hook(ok);
}
});
Body::from_stream(ReceiverStream::new(rx))
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use futures::stream;
use http_body_util::BodyExt;
#[tokio::test]
async fn passes_through_a_simple_byte_stream() {
let chunks = vec![
Ok::<Bytes, std::io::Error>(Bytes::from_static(b"hello ")),
Ok(Bytes::from_static(b"world")),
];
let s = stream::iter(chunks);
let body = bytes_stream_to_body(s, None, None);
let bytes = body.collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"hello world");
}
#[tokio::test]
async fn upstream_error_surfaces_to_consumer() {
let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
Ok(Bytes::from_static(b"ok-chunk")),
Err(std::io::Error::other("upstream blew up mid-stream")),
];
let s = stream::iter(chunks);
let body = bytes_stream_to_body(s, None, None);
// Collecting a body that terminates with an error must return Err.
let result = body.collect().await;
assert!(
result.is_err(),
"expected body collect to surface upstream error, got Ok"
);
}
/// A stream that yields one Ok chunk on the first poll, then panics on the
/// second poll. Used to exercise the pump's panic-catch path.
struct PanicOnSecondPoll {
polls: usize,
}
impl futures::Stream for PanicOnSecondPoll {
type Item = Result<Bytes, std::io::Error>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.polls += 1;
match self.polls {
1 => std::task::Poll::Ready(Some(Ok(Bytes::from_static(b"first-chunk")))),
_ => panic!("synthetic pump panic from stream poll"),
}
}
}
/// A stream that yields one Ok chunk, then panics with a non-string
/// payload (`i32`). Used to exercise the `<non-string panic payload>`
/// fallback in the downcast ladder — the existing
/// `PanicOnSecondPoll` test only covers the `&'static str` arm.
struct PanicAnyOnSecondPoll {
polls: usize,
}
impl futures::Stream for PanicAnyOnSecondPoll {
type Item = Result<Bytes, std::io::Error>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.polls += 1;
match self.polls {
1 => std::task::Poll::Ready(Some(Ok(Bytes::from_static(b"first-chunk")))),
_ => std::panic::panic_any(42_i32),
}
}
}
#[tokio::test]
async fn bytes_stream_to_body_handles_non_string_panic_payload() {
// `panic_any(42_i32)` skips the formatter entirely — neither the
// `&'static str` nor the `String` downcast arms match, so the
// catch_unwind handler must fall through to the
// `"<non-string panic payload>"` literal. If a refactor deletes
// that arm, the closure unwrap-or-elses would panic itself or
// produce an empty message, which this test catches.
let s = PanicAnyOnSecondPoll { polls: 0 };
let body = bytes_stream_to_body(s, None, None);
let result = body.collect().await;
assert!(
result.is_err(),
"expected body collect to surface non-string panic as Err, got Ok"
);
let err = result.err().unwrap();
let msg = format!("{err}");
assert!(
msg.contains("<non-string panic payload>"),
"expected fallback message for non-string panic payload, got: {msg}"
);
assert!(
msg.contains("SSE pump panicked"),
"expected wrapper message to remain, got: {msg}"
);
}
#[tokio::test]
async fn bytes_stream_to_body_propagates_pump_panic() {
// The pump task panics mid-stream. The client must see a loud Err,
// NOT a silently-truncated success.
let s = PanicOnSecondPoll { polls: 0 };
let body = bytes_stream_to_body(s, None, None);
let result = body.collect().await;
assert!(
result.is_err(),
"expected body collect to surface pump panic as Err, got Ok (silent truncation)"
);
let err = result.err().unwrap();
let msg = format!("{err}");
assert!(
msg.contains("pump panicked") || msg.contains("SSE pump panicked"),
"expected error message to mention pump panic, got: {msg}"
);
}
/// Regression guard for the backpressure-via-disconnect invariant.
///
/// The doc on `bytes_stream_to_body` claims "when the axum Body is dropped
/// the receiver is closed; `tx.send()` then returns `Err`, which breaks the
/// loop — no upstream bytes are read after the client disconnects." This
/// test pins that contract: a refactor that swaps the `if tx.send().await.
/// is_err() { break; }` for `let _ = tx.send().await;` would silently
/// regress (leaked upstream reads on every client cancel, visible only as
/// ops-side memory growth).
#[tokio::test]
async fn bytes_stream_to_body_breaks_on_client_disconnect() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
// A stream that yields N Ok chunks readily, counting polls via a shared
// atomic. After we read 1 chunk and drop the body, the pump must hit
// tx.send-err and break — not drain all 1000 chunks.
struct CountingStream {
polls: Arc<AtomicUsize>,
yielded: usize,
max: usize,
}
impl futures::Stream for CountingStream {
type Item = Result<Bytes, std::io::Error>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.polls.fetch_add(1, Ordering::SeqCst);
if self.yielded >= self.max {
return std::task::Poll::Ready(None);
}
self.yielded += 1;
std::task::Poll::Ready(Some(Ok(Bytes::from_static(b"chunk"))))
}
}
let polls = Arc::new(AtomicUsize::new(0));
let stream = CountingStream {
polls: polls.clone(),
yielded: 0,
max: 1000, // way more than we'll let it consume
};
let body = bytes_stream_to_body(stream, None, None);
// Read exactly one frame, then drop the body to simulate client disconnect.
let mut data_stream = body.into_data_stream();
let first = data_stream.next().await;
assert!(first.is_some(), "expected at least one chunk before drop");
drop(data_stream);
// Give the pump generous time to make additional polls if its break is
// broken. Healthy code: pump fills the 64-slot channel, then on the
// next iteration tx.send().await detects receiver-drop and breaks.
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let final_polls = polls.load(Ordering::SeqCst);
assert!(
final_polls <= 70,
"pump kept polling upstream after client disconnect: {final_polls} polls (expected <=70, channel bound + slack)"
);
// And: the pump must NOT have drained all 1000 chunks.
assert!(
final_polls < 1000,
"pump drained the entire upstream after client disconnect ({final_polls} polls); the break-on-tx.send-err path is dead"
);
}
}
+57
View File
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use crate::server::app_context::AppContext;
use crate::server::routes::chat::MAX_CHAT_BODY_BYTES;
use axum::extract::{DefaultBodyLimit, Request};
use axum::http::StatusCode;
use axum::middleware::{self, Next};
use axum::response::Response;
use axum::routing::{get, post};
use axum::Router;
use std::sync::Arc;
/// Middleware: log 413 PAYLOAD_TOO_LARGE responses with the request method
/// and URI so an operator investigating "client X gets 413s" has a
/// server-side breadcrumb. The 413 is produced by axum's `DefaultBodyLimit`
/// layer BEFORE the handler runs, so without this we would have no record
/// of which request was rejected.
async fn log_413(req: Request, next: Next) -> Response {
let method = req.method().clone();
let uri = req.uri().clone();
let resp = next.run(req).await;
if resp.status() == StatusCode::PAYLOAD_TOO_LARGE {
tracing::warn!(
%method,
%uri,
"request rejected with 413 PAYLOAD_TOO_LARGE (body exceeded route limit)",
);
}
resp
}
pub fn build_router(ctx: Arc<AppContext>) -> Router {
Router::new()
.route("/healthz", get(crate::server::routes::health::healthz))
.route("/readyz", get(crate::server::routes::health::readyz))
.route("/metrics", get(crate::server::routes::metrics::metrics))
.route(
"/v1/models",
get(crate::server::routes::models::list_models),
)
.route(
"/v1/tokenize",
post(crate::server::routes::tokenize::tokenize),
)
.route(
"/v1/detokenize",
post(crate::server::routes::tokenize::detokenize),
)
.route(
"/v1/chat/completions",
post(crate::server::routes::chat::chat_completions)
.layer(DefaultBodyLimit::max(MAX_CHAT_BODY_BYTES))
.layer(middleware::from_fn(log_413)),
)
.with_state(ctx)
}
@@ -0,0 +1,123 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use crate::config::Config;
use crate::policies::active_load::ActiveLoadRegistry;
use crate::policies::PolicyRegistry;
use crate::proxy::Proxy;
use crate::server::metrics::MetricsRegistry;
use crate::tokenizer::TokenizerRegistry;
use crate::workers::WorkerRegistry;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[derive(Debug)]
pub struct AppContext {
pub config: Config,
pub tokenizers: Arc<TokenizerRegistry>,
pub proxy: Arc<Proxy>,
pub registry: Arc<WorkerRegistry>,
pub policies: Arc<PolicyRegistry>,
/// Per-worker active-load bookkeeping. Shared between the proxy
/// (which mints guards on the request hot path), the cache-aware
/// policy (which reads per-worker load when scoring candidates), and
/// the stale-request janitor (which sweeps expired entries).
pub active_load: Arc<ActiveLoadRegistry>,
/// Lightweight Prometheus-format metrics registry served via
/// `/metrics`. Shared with the chat handler (requests_total),
/// cache-aware-zmq policy (overlap_blocks), active-load registry
/// (active_load gauge + stale_requests_total), and PD resolver
/// (decode_affinity_total).
pub metrics: Arc<MetricsRegistry>,
ready: AtomicBool,
}
impl AppContext {
pub fn new(
config: Config,
tokenizers: Arc<TokenizerRegistry>,
proxy: Arc<Proxy>,
registry: Arc<WorkerRegistry>,
policies: Arc<PolicyRegistry>,
) -> Self {
Self::with_active_load(
config,
tokenizers,
proxy,
registry,
policies,
ActiveLoadRegistry::with_defaults(),
)
}
/// Construct an [`AppContext`] with an explicit [`ActiveLoadRegistry`].
/// Production wires the default (5-minute timeout, SystemTimeClock)
/// via [`Self::new`]; tests that exercise the janitor pass a registry
/// built with a `MockClock`.
pub fn with_active_load(
config: Config,
tokenizers: Arc<TokenizerRegistry>,
proxy: Arc<Proxy>,
registry: Arc<WorkerRegistry>,
policies: Arc<PolicyRegistry>,
active_load: Arc<ActiveLoadRegistry>,
) -> Self {
let metrics = MetricsRegistry::new();
// Wire the per-worker active-load gauge so `sgl_router_active_load`
// mirrors the live counter on every register / drop / sweep.
// Without this, the metric is permanently 0 in production even
// though the chat handler is faithfully calling `register`.
active_load.attach_metrics(Arc::clone(&metrics));
Self {
config,
tokenizers,
proxy,
registry,
policies,
active_load,
metrics,
ready: AtomicBool::new(false),
}
}
pub fn mark_ready(&self) {
// Relaxed: this flag does not synchronize other state; readers only
// care about eventual visibility, not happens-before with surrounding ops.
self.ready.store(true, Ordering::Relaxed);
}
pub fn is_ready(&self) -> bool {
self.ready.load(Ordering::Relaxed)
}
#[cfg(test)]
pub fn stub() -> Self {
Self {
config: Config {
server: crate::config::ServerConfig {
host: "x".into(),
port: 0,
},
observability: Default::default(),
models: vec![],
discovery: crate::config::DiscoveryConfig {
backend: crate::config::DiscoveryBackend::StaticUrls(
crate::config::StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
},
),
},
proxy: crate::config::ProxyConfig::default(),
active_load: crate::config::ActiveLoadConfig::default(),
},
tokenizers: Arc::new(TokenizerRegistry::default()),
proxy: Arc::new(Proxy::new(std::time::Duration::from_secs(60)).expect("stub proxy")),
registry: Arc::new(WorkerRegistry::default()),
policies: Arc::new(PolicyRegistry::default()),
active_load: ActiveLoadRegistry::with_defaults(),
metrics: MetricsRegistry::new(),
ready: AtomicBool::new(false),
}
}
}
+426
View File
@@ -0,0 +1,426 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use axum::http::{HeaderName, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
use thiserror::Error;
pub const X_ROUTER_ERROR_CODE: HeaderName = HeaderName::from_static("x-router-error-code");
#[derive(Debug, Error)]
pub enum ApiError {
#[error("bad request: {0}")]
BadRequest(String),
#[error("model not found: {0}")]
ModelNotFound(String),
/// Could not reach the upstream worker (connect refused, DNS, TLS, request
/// build error). `source` captures the full anyhow chain for server-side
/// logging; clients see a generic message.
///
/// `worker` is the typed `reqwest::Url` so we don't re-stringify a value
/// that is already a `Url` at the construction site. Rendering goes
/// through `Display`, which produces the same canonical form as
/// `Url::as_str()`.
#[error("upstream unreachable: worker {worker}")]
UpstreamUnreachable {
worker: reqwest::Url,
#[source]
source: anyhow::Error,
},
/// The worker started a response (status + headers received) but failed
/// to deliver the full body — mid-body socket drop, framing error, etc.
/// Distinct from `UpstreamUnreachable` (no reply at all) and from a
/// well-formed non-2xx (which `Proxy` forwards verbatim with the worker's
/// own body).
#[error("upstream returned status {status}")]
UpstreamStatus { status: StatusCode },
/// Wall-clock timeout exceeded while waiting for the upstream worker's
/// response (per-request `request_timeout`).
///
/// `worker` is the typed `reqwest::Url` for the same reason as
/// `UpstreamUnreachable`.
#[error("upstream timed out: worker {worker}")]
UpstreamTimeout { worker: reqwest::Url },
/// No healthy worker is available for `model`: either none were ever
/// registered, or every candidate's circuit breaker is open. Clients
/// should retry; operators should check discovery + worker health.
#[error("no healthy workers for model {model}")]
NoHealthyWorkers { model: String },
/// PD-mode deployment whose prefill pool has zero healthy workers.
/// Distinct from `NoHealthyWorkers` because the decode pool may
/// still be healthy — the failure is pool-specific, and surfacing
/// the distinct code lets operators alert on prefill-fleet outages
/// independently of full-model outages.
#[error("no prefill workers available for model {model}")]
NoPrefillWorkersAvailable { model: String },
/// PD-mode deployment whose decode pool has zero healthy workers.
/// Mirror of [`Self::NoPrefillWorkersAvailable`].
#[error("no decode workers available for model {model}")]
NoDecodeWorkersAvailable { model: String },
/// A request whose lifetime exceeded `stale_request_timeout` — the
/// active-load janitor force-expired the in-flight bookkeeping
/// AND fired the per-request cancellation token, which the chat
/// handler `select!`-races against the upstream fetch. When the
/// token wins, the handler returns this variant → HTTP 504 →
/// client sees `stale_request_expired`.
///
/// Mapped to 504 (not 503) because the failure is a router-side
/// gateway timeout from the client's perspective: the upstream
/// worker is still potentially fine, the router gave up because
/// the per-request budget elapsed.
#[error("stale request expired for model {model}")]
StaleRequestExpired { model: String },
/// The per-model policy returned `None` despite the candidate set
/// being non-empty. Almost always a router bug or an unsupported
/// policy state; surfaced as 503 (not 500) so retry-on-failure clients
/// can drain through a rotation rather than fail-fast on internal_error.
#[error("policy selected no worker for model {model}")]
PolicySelectionFailed { model: String },
/// The worker's circuit breaker was open at the moment of dispatch.
/// Surfaced post-policy-selection (race with `healthy_workers_for`);
/// the next selection will skip this worker.
#[error("worker circuit breaker open: {worker}")]
BreakerOpen { worker: String },
/// The worker URL emitted by discovery failed to parse. Always a
/// config / discovery-backend bug, not a transient infra issue — but
/// from the client's perspective the worker is unreachable, so 503.
/// The forwarder trips the circuit breaker before returning so the
/// malformed worker drops out of subsequent selection.
#[error("worker misconfigured: {worker}")]
WorkerMisconfigured {
worker: String,
#[source]
source: anyhow::Error,
},
#[error("internal: {0}")]
Internal(#[from] anyhow::Error),
}
impl ApiError {
fn status_and_code(&self) -> (StatusCode, &'static str) {
match self {
ApiError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
ApiError::ModelNotFound(_) => (StatusCode::NOT_FOUND, "model_not_found"),
ApiError::UpstreamUnreachable { .. } => {
(StatusCode::BAD_GATEWAY, "upstream_unreachable")
}
ApiError::UpstreamStatus { .. } => (StatusCode::BAD_GATEWAY, "upstream_status"),
ApiError::UpstreamTimeout { .. } => (StatusCode::BAD_GATEWAY, "upstream_timeout"),
ApiError::NoHealthyWorkers { .. } => {
(StatusCode::SERVICE_UNAVAILABLE, "no_healthy_workers")
}
ApiError::NoPrefillWorkersAvailable { .. } => (
StatusCode::SERVICE_UNAVAILABLE,
"no_prefill_workers_available",
),
ApiError::NoDecodeWorkersAvailable { .. } => (
StatusCode::SERVICE_UNAVAILABLE,
"no_decode_workers_available",
),
ApiError::StaleRequestExpired { .. } => {
(StatusCode::GATEWAY_TIMEOUT, "stale_request_expired")
}
ApiError::PolicySelectionFailed { .. } => {
(StatusCode::SERVICE_UNAVAILABLE, "policy_selection_failed")
}
ApiError::BreakerOpen { .. } => (StatusCode::SERVICE_UNAVAILABLE, "breaker_open"),
ApiError::WorkerMisconfigured { .. } => {
(StatusCode::SERVICE_UNAVAILABLE, "worker_misconfigured")
}
ApiError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
}
}
}
#[derive(Serialize)]
struct ErrorEnvelope<'a> {
error: ErrorBody<'a>,
}
#[derive(Serialize)]
struct ErrorBody<'a> {
#[serde(rename = "type")]
typ: &'static str,
code: &'a str,
message: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, code) = self.status_and_code();
let typ = match status.as_u16() {
400..=499 => "invalid_request_error",
_ => "server_error",
};
// Pick a client-facing message that NEVER leaks worker URLs or raw
// source chains; full structured details are logged server-side.
let message = match &self {
ApiError::Internal(e) => {
// `{:#}` prints the anyhow chain (top error + sources) — `?e`
// would only show the outermost message.
tracing::error!("internal error serving request: {e:#}");
"internal error".to_string()
}
ApiError::UpstreamUnreachable { worker, source } => {
tracing::warn!(
upstream = %worker,
error = %format_args!("{source:#}"),
"upstream worker unreachable",
);
"upstream unavailable".to_string()
}
ApiError::UpstreamStatus { status } => {
tracing::warn!(
upstream_status = %status,
"upstream returned an error status",
);
"upstream returned an error status".to_string()
}
ApiError::UpstreamTimeout { worker } => {
tracing::warn!(upstream = %worker, "upstream request timed out");
"upstream request timed out".to_string()
}
ApiError::NoHealthyWorkers { model } => {
tracing::warn!(model = %model, reason = "no_healthy_workers", "service unavailable");
"no healthy workers for the requested model".to_string()
}
ApiError::NoPrefillWorkersAvailable { model } => {
tracing::warn!(
model = %model,
reason = "no_prefill_workers_available",
"service unavailable",
);
"no prefill workers available for the requested model".to_string()
}
ApiError::NoDecodeWorkersAvailable { model } => {
tracing::warn!(
model = %model,
reason = "no_decode_workers_available",
"service unavailable",
);
"no decode workers available for the requested model".to_string()
}
ApiError::StaleRequestExpired { model } => {
tracing::warn!(
model = %model,
reason = "stale_request_expired",
"stale-request janitor expired in-flight request",
);
"request expired before completion".to_string()
}
ApiError::PolicySelectionFailed { model } => {
tracing::warn!(model = %model, reason = "policy_selection_failed", "service unavailable");
"service unavailable".to_string()
}
ApiError::BreakerOpen { worker } => {
tracing::warn!(upstream = %worker, reason = "breaker_open", "service unavailable");
"service unavailable".to_string()
}
ApiError::WorkerMisconfigured { worker, source } => {
tracing::error!(
upstream = %worker,
error = %format_args!("{source:#}"),
"worker URL emitted by discovery is malformed",
);
"service unavailable".to_string()
}
ApiError::BadRequest(_) | ApiError::ModelNotFound(_) => self.to_string(),
};
let mut resp = (
status,
Json(ErrorEnvelope {
error: ErrorBody { typ, code, message },
}),
)
.into_response();
resp.headers_mut()
.insert(X_ROUTER_ERROR_CODE, HeaderValue::from_static(code));
resp
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::response::IntoResponse;
use http_body_util::BodyExt;
use serde::Deserialize;
fn collect_body(resp: Response) -> String {
let bytes = tokio::runtime::Runtime::new()
.unwrap()
.block_on(async { BodyExt::collect(resp.into_body()).await.unwrap().to_bytes() });
String::from_utf8_lossy(&bytes).into_owned()
}
/// Pin the exact JSON envelope shape that clients see. Renaming any of
/// these fields (or removing one) breaks every downstream consumer
/// silently, so we deserialize into a fixed struct rather than
/// regex-matching the rendered JSON.
#[derive(Deserialize)]
struct ErrEnv {
error: ErrField,
}
#[derive(Deserialize)]
struct ErrField {
#[serde(rename = "type")]
typ: String,
code: String,
message: String,
}
fn parse_envelope(resp: Response) -> (StatusCode, Option<String>, ErrEnv) {
let status = resp.status();
let code_header = resp
.headers()
.get("x-router-error-code")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let body_str = collect_body(resp);
let env: ErrEnv = serde_json::from_str(&body_str)
.unwrap_or_else(|e| panic!("envelope did not match expected shape: {e}: {body_str}"));
(status, code_header, env)
}
#[test]
fn upstream_unreachable_envelope_has_code_and_no_leak() {
let worker_str = "http://10.0.0.42:30000/";
let worker = reqwest::Url::parse(worker_str).unwrap();
let secret = "TLS_HANDSHAKE_FAILED at /etc/secret_ca.pem";
let err = ApiError::UpstreamUnreachable {
worker: worker.clone(),
source: anyhow::anyhow!("{secret}"),
};
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
assert_eq!(
resp.headers()
.get("x-router-error-code")
.and_then(|v| v.to_str().ok()),
Some("upstream_unreachable"),
);
let body = collect_body(resp);
assert!(body.contains("\"code\":\"upstream_unreachable\""), "{body}");
assert!(body.contains("\"type\":\"server_error\""), "{body}");
assert!(
!body.contains(worker_str) && !body.contains(secret),
"client body must NOT leak worker URL or reqwest source chain; got: {body}",
);
}
#[test]
fn upstream_status_envelope_has_code() {
let err = ApiError::UpstreamStatus {
status: StatusCode::INTERNAL_SERVER_ERROR,
};
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
assert_eq!(
resp.headers()
.get("x-router-error-code")
.and_then(|v| v.to_str().ok()),
Some("upstream_status"),
);
let body = collect_body(resp);
assert!(body.contains("\"code\":\"upstream_status\""), "{body}");
}
#[test]
fn upstream_timeout_envelope_has_code_and_no_leak() {
let worker_str = "http://10.0.0.42:30000/";
let worker = reqwest::Url::parse(worker_str).unwrap();
let err = ApiError::UpstreamTimeout {
worker: worker.clone(),
};
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
assert_eq!(
resp.headers()
.get("x-router-error-code")
.and_then(|v| v.to_str().ok()),
Some("upstream_timeout"),
);
let body = collect_body(resp);
assert!(body.contains("\"code\":\"upstream_timeout\""), "{body}");
assert!(
!body.contains(worker_str),
"client body must NOT leak worker URL; got: {body}",
);
}
#[test]
fn bad_request_envelope_has_expected_shape() {
let msg = "invalid_request: body must be an object";
let err = ApiError::BadRequest(msg.into());
let resp = err.into_response();
let (status, code_header, env) = parse_envelope(resp);
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(code_header.as_deref(), Some("bad_request"));
assert_eq!(env.error.code, "bad_request");
assert_eq!(env.error.typ, "invalid_request_error");
assert!(
!env.error.message.is_empty(),
"message must not be empty: {:?}",
env.error.message,
);
assert_ne!(env.error.code, "internal_error");
assert_ne!(env.error.code, "model_not_found");
}
#[test]
fn model_not_found_envelope_has_expected_shape() {
let err = ApiError::ModelNotFound("ghost-7b".into());
let resp = err.into_response();
let (status, code_header, env) = parse_envelope(resp);
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(code_header.as_deref(), Some("model_not_found"));
assert_eq!(env.error.code, "model_not_found");
assert_eq!(env.error.typ, "invalid_request_error");
assert!(
!env.error.message.is_empty(),
"message must not be empty: {:?}",
env.error.message,
);
assert_ne!(env.error.code, "internal_error");
assert_ne!(env.error.code, "bad_request");
}
#[test]
fn internal_error_response_sanitizes_anyhow_chain() {
let secret_msg = "internal /opt/secret/credential.json missing";
let err = ApiError::Internal(anyhow::anyhow!("{secret_msg}"));
let resp = err.into_response();
let body_str = collect_body(resp);
// Generic to client:
assert!(
body_str.contains("\"code\":\"internal_error\""),
"body: {body_str}"
);
assert!(
body_str.contains("\"type\":\"server_error\""),
"body: {body_str}"
);
// No leak of the original anyhow message:
assert!(
!body_str.contains(secret_msg),
"ApiError::Internal must not leak anyhow chain to client; got: {body_str}"
);
}
}
@@ -0,0 +1,117 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Header forwarding whitelist — mirrors SMG semantics.
use axum::http::HeaderName;
/// True if a request header from the inbound client should be forwarded
/// to the upstream worker. Mirrors SMG's whitelist semantics.
pub fn should_forward_request_header(name: &HeaderName) -> bool {
let n = name.as_str();
matches!(
n,
"authorization" | "x-request-id" | "x-correlation-id" | "traceparent" | "tracestate"
) || n.starts_with("x-request-id-")
|| n.starts_with("x-sgl-")
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderName;
#[test]
fn whitelist_basics() {
// Whitelisted headers
assert!(should_forward_request_header(&HeaderName::from_static(
"authorization"
)));
assert!(should_forward_request_header(&HeaderName::from_static(
"x-request-id"
)));
assert!(should_forward_request_header(&HeaderName::from_static(
"x-correlation-id"
)));
assert!(should_forward_request_header(&HeaderName::from_static(
"traceparent"
)));
assert!(should_forward_request_header(&HeaderName::from_static(
"tracestate"
)));
assert!(should_forward_request_header(&HeaderName::from_static(
"x-sgl-route-key"
)));
assert!(should_forward_request_header(&HeaderName::from_static(
"x-request-id-extra"
)));
// Stripped headers
assert!(!should_forward_request_header(&HeaderName::from_static(
"host"
)));
assert!(!should_forward_request_header(&HeaderName::from_static(
"content-length"
)));
assert!(!should_forward_request_header(&HeaderName::from_static(
"cookie"
)));
assert!(!should_forward_request_header(&HeaderName::from_static(
"connection"
)));
assert!(!should_forward_request_header(&HeaderName::from_static(
"transfer-encoding"
)));
}
/// Prefix-match negatives: names that LOOK similar to `x-request-id-*`
/// or `x-sgl-*` but must NOT be forwarded. Guards against a future
/// regression that loosens the rule (e.g., a `contains` instead of
/// `starts_with`, or a missing hyphen anchor).
#[test]
fn whitelist_prefix_negatives() {
// `x-request-id` itself is an exact match and MUST forward —
// pin this so a future "tighten prefix to require trailing hyphen"
// refactor doesn't silently drop the canonical name.
assert!(
should_forward_request_header(&HeaderName::from_static("x-request-id")),
"x-request-id (exact match) must forward",
);
// No trailing hyphen between `id` and the suffix: not a child of
// `x-request-id-*`, must NOT forward.
assert!(
!should_forward_request_header(&HeaderName::from_static("x-request-id2")),
"x-request-id2 (no hyphen separator) must not forward",
);
assert!(
!should_forward_request_header(&HeaderName::from_static("x-request-idfoo")),
"x-request-idfoo (no hyphen separator) must not forward",
);
// Typo of the `x-sgl-` prefix (missing 'l'): must NOT forward.
assert!(
!should_forward_request_header(&HeaderName::from_static("x-sg-foo")),
"x-sg-foo (typo of x-sgl-) must not forward",
);
// Extra leading character: `xx-request-id-foo` does not start with
// `x-request-id-`, must NOT forward.
assert!(
!should_forward_request_header(&HeaderName::from_static("xx-request-id-foo")),
"xx-request-id-foo (extra leading char) must not forward",
);
// Same shape for the x-sgl- family.
assert!(
!should_forward_request_header(&HeaderName::from_static("xx-sgl-foo")),
"xx-sgl-foo (extra leading char) must not forward",
);
// Substring-but-not-prefix: must NOT forward (guards against a
// `contains`-based regression).
assert!(
!should_forward_request_header(&HeaderName::from_static("foo-x-sgl-bar")),
"foo-x-sgl-bar (substring, not prefix) must not forward",
);
}
}
@@ -0,0 +1,551 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Lightweight in-process Prometheus exposition.
//!
//! We deliberately do NOT pull in the `metrics` + `metrics-exporter-prometheus`
//! crates: the observability surface is small enough that a hand-written
//! counter + histogram + gauge family is cheaper than a new dependency, and
//! it lets us label/serialise exactly the way the convergence and PD-affinity
//! tests want.
//!
//! All operations are concurrent — counters and gauges use
//! [`std::sync::atomic`], histograms use a [`Mutex<Vec<u64>>`] over a
//! fixed bucket set. Tests sub-second; production scrapes are 15s
//! cadence. Lock contention is not a concern at these rates.
//!
//! # Metrics surface
//!
//! | Metric | Type | Labels |
//! |---|---|---|
//! | `sgl_router_requests_total` | Counter | `worker_url`, `model_id`, `mode`, `outcome` |
//! | `sgl_router_overlap_blocks` | Histogram | `model_id` |
//! | `sgl_router_active_load` | Gauge | `worker_url`, `kind` |
//! | `sgl_router_stale_requests_total` | Counter | `outcome` |
//! | `sgl_router_decode_affinity_total` | Counter | `outcome` |
//!
//! The exposition is text/plain; version=0.0.4 per the Prometheus spec.
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
use std::sync::Arc;
/// Histogram bucket upper bounds for `sgl_router_overlap_blocks`. Chosen to
/// span 0 → ~1k blocks: blocks are 3264 tokens each, and our `MAX_CHAT_BODY_BYTES`
/// cap (1 MiB ≈ 250 k tokens) implies an upper bound around 48 k blocks for
/// a maximum-length context. The `+Inf` bucket catches everything beyond
/// 1000.
const OVERLAP_BLOCKS_BUCKETS: &[f64] = &[
0.0, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0, 512.0, 1000.0,
];
/// Recordable outcome for a request — narrowed to a handful of variants so
/// the label cardinality stays bounded.
#[derive(Debug, Clone, Copy)]
pub enum RequestOutcome {
Success,
Error,
Cancelled,
}
impl RequestOutcome {
fn as_str(self) -> &'static str {
match self {
Self::Success => "success",
Self::Error => "error",
Self::Cancelled => "cancelled",
}
}
}
/// Worker dispatch mode label — narrowed to the three modes the policy
/// resolver distinguishes. The `Plain` variant covers the non-PD case.
#[derive(Debug, Clone, Copy)]
pub enum WorkerModeLabel {
Prefill,
Decode,
Plain,
}
impl WorkerModeLabel {
fn as_str(self) -> &'static str {
match self {
Self::Prefill => "prefill",
Self::Decode => "decode",
Self::Plain => "plain",
}
}
}
/// Decode-affinity outcome — see `select_decode_with_affinity` for the
/// three reasons the affinity may not be honored.
#[derive(Debug, Clone, Copy)]
pub enum DecodeAffinityOutcome {
SameHostPicked,
FallbackBreaker,
FallbackLoadImbalance,
}
impl DecodeAffinityOutcome {
fn as_str(self) -> &'static str {
match self {
Self::SameHostPicked => "same_host_picked",
Self::FallbackBreaker => "fallback_breaker",
Self::FallbackLoadImbalance => "fallback_load_imbalance",
}
}
}
/// Stale-request outcome label.
#[derive(Debug, Clone, Copy)]
pub enum StaleRequestOutcome {
Expired,
}
impl StaleRequestOutcome {
fn as_str(self) -> &'static str {
match self {
Self::Expired => "expired",
}
}
}
/// Active-load kind label — separates the two axes of per-worker load.
#[derive(Debug, Clone, Copy)]
pub enum ActiveLoadKind {
PrefillTokens,
DecodeBlocks,
}
impl ActiveLoadKind {
fn as_str(self) -> &'static str {
match self {
Self::PrefillTokens => "prefill_tokens",
Self::DecodeBlocks => "decode_blocks",
}
}
}
/// The shared metrics registry, held on `AppContext`. Cheap to clone — all
/// internal state is `Arc`/`Atomic`/`Mutex`-protected.
#[derive(Debug, Default)]
pub struct MetricsRegistry {
requests_total: Mutex<HashMap<RequestKey, Arc<AtomicU64>>>,
overlap_blocks: Mutex<HashMap<String, Histogram>>,
active_load: Mutex<HashMap<ActiveLoadKey, Arc<AtomicI64>>>,
stale_requests_total: Mutex<HashMap<&'static str, Arc<AtomicU64>>>,
decode_affinity_total: Mutex<HashMap<&'static str, Arc<AtomicU64>>>,
}
#[derive(Debug, Hash, Eq, PartialEq, Clone)]
struct RequestKey {
worker_url: String,
model_id: String,
mode: &'static str,
outcome: &'static str,
}
#[derive(Debug, Hash, Eq, PartialEq, Clone)]
struct ActiveLoadKey {
worker_url: String,
kind: &'static str,
}
#[derive(Debug)]
struct Histogram {
/// One counter per bucket boundary in [`OVERLAP_BLOCKS_BUCKETS`], plus
/// one for `+Inf`. Buckets are cumulative on render but stored as
/// non-cumulative counts here.
buckets: Vec<u64>,
sum: f64,
count: u64,
}
impl Histogram {
fn new() -> Self {
Self {
buckets: vec![0; OVERLAP_BLOCKS_BUCKETS.len() + 1],
sum: 0.0,
count: 0,
}
}
fn observe(&mut self, value: f64) {
let mut placed = false;
for (i, &bound) in OVERLAP_BLOCKS_BUCKETS.iter().enumerate() {
if value <= bound {
self.buckets[i] += 1;
placed = true;
break;
}
}
if !placed {
// +Inf bucket
let last = self.buckets.len() - 1;
self.buckets[last] += 1;
}
self.sum += value;
self.count += 1;
}
}
impl MetricsRegistry {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Bump `sgl_router_requests_total` for the given worker / model / mode / outcome.
pub fn record_request(
&self,
worker_url: &str,
model_id: &str,
mode: WorkerModeLabel,
outcome: RequestOutcome,
) {
let key = RequestKey {
worker_url: worker_url.to_owned(),
model_id: model_id.to_owned(),
mode: mode.as_str(),
outcome: outcome.as_str(),
};
let mut guard = self.requests_total.lock();
let counter = guard
.entry(key)
.or_insert_with(|| Arc::new(AtomicU64::new(0)))
.clone();
drop(guard);
counter.fetch_add(1, Ordering::Relaxed);
}
/// Observe an overlap-blocks count for `sgl_router_overlap_blocks`.
pub fn observe_overlap_blocks(&self, model_id: &str, blocks: u64) {
let mut guard = self.overlap_blocks.lock();
let hist = guard
.entry(model_id.to_owned())
.or_insert_with(Histogram::new);
hist.observe(blocks as f64);
}
/// Set `sgl_router_active_load` for the given worker + kind. Replaces the
/// previous value (gauge semantics).
pub fn set_active_load(&self, worker_url: &str, kind: ActiveLoadKind, value: i64) {
let key = ActiveLoadKey {
worker_url: worker_url.to_owned(),
kind: kind.as_str(),
};
let mut guard = self.active_load.lock();
let gauge = guard
.entry(key)
.or_insert_with(|| Arc::new(AtomicI64::new(0)))
.clone();
drop(guard);
gauge.store(value, Ordering::Relaxed);
}
/// Bump `sgl_router_stale_requests_total{outcome}`.
pub fn record_stale_request(&self, outcome: StaleRequestOutcome) {
let mut guard = self.stale_requests_total.lock();
let counter = guard
.entry(outcome.as_str())
.or_insert_with(|| Arc::new(AtomicU64::new(0)))
.clone();
drop(guard);
counter.fetch_add(1, Ordering::Relaxed);
}
/// Bump `sgl_router_decode_affinity_total{outcome}`.
pub fn record_decode_affinity(&self, outcome: DecodeAffinityOutcome) {
let mut guard = self.decode_affinity_total.lock();
let counter = guard
.entry(outcome.as_str())
.or_insert_with(|| Arc::new(AtomicU64::new(0)))
.clone();
drop(guard);
counter.fetch_add(1, Ordering::Relaxed);
}
/// Render the registry as a Prometheus 0.0.4 exposition-format string.
pub fn render(&self) -> String {
let mut out = String::new();
// requests_total
out.push_str(
"# HELP sgl_router_requests_total Total chat-completions requests dispatched to a worker.\n",
);
out.push_str("# TYPE sgl_router_requests_total counter\n");
let guard = self.requests_total.lock();
// Sort for stable output — easier for tests.
let mut entries: Vec<(&RequestKey, u64)> = guard
.iter()
.map(|(k, v)| (k, v.load(Ordering::Relaxed)))
.collect();
entries.sort_by(|a, b| {
(&a.0.worker_url, &a.0.model_id, a.0.mode, a.0.outcome).cmp(&(
&b.0.worker_url,
&b.0.model_id,
b.0.mode,
b.0.outcome,
))
});
for (key, value) in entries {
out.push_str(&format!(
"sgl_router_requests_total{{worker_url=\"{}\",model_id=\"{}\",mode=\"{}\",outcome=\"{}\"}} {}\n",
escape_label(&key.worker_url),
escape_label(&key.model_id),
key.mode,
key.outcome,
value,
));
}
drop(guard);
// overlap_blocks histogram
out.push_str(
"# HELP sgl_router_overlap_blocks Overlap-block count observed at cache-aware-zmq policy selection.\n",
);
out.push_str("# TYPE sgl_router_overlap_blocks histogram\n");
let guard = self.overlap_blocks.lock();
let mut models: Vec<&String> = guard.keys().collect();
models.sort();
for model_id in models {
let hist = guard.get(model_id).unwrap();
let mut cumulative: u64 = 0;
for (i, &bound) in OVERLAP_BLOCKS_BUCKETS.iter().enumerate() {
cumulative += hist.buckets[i];
out.push_str(&format!(
"sgl_router_overlap_blocks_bucket{{model_id=\"{}\",le=\"{}\"}} {}\n",
escape_label(model_id),
bound,
cumulative,
));
}
cumulative += hist.buckets[OVERLAP_BLOCKS_BUCKETS.len()];
out.push_str(&format!(
"sgl_router_overlap_blocks_bucket{{model_id=\"{}\",le=\"+Inf\"}} {}\n",
escape_label(model_id),
cumulative,
));
out.push_str(&format!(
"sgl_router_overlap_blocks_sum{{model_id=\"{}\"}} {}\n",
escape_label(model_id),
hist.sum,
));
out.push_str(&format!(
"sgl_router_overlap_blocks_count{{model_id=\"{}\"}} {}\n",
escape_label(model_id),
hist.count,
));
}
drop(guard);
// active_load gauge
out.push_str(
"# HELP sgl_router_active_load Per-worker active load (prefill_tokens or decode_blocks).\n",
);
out.push_str("# TYPE sgl_router_active_load gauge\n");
let guard = self.active_load.lock();
let mut entries: Vec<(&ActiveLoadKey, i64)> = guard
.iter()
.map(|(k, v)| (k, v.load(Ordering::Relaxed)))
.collect();
entries.sort_by(|a, b| (&a.0.worker_url, a.0.kind).cmp(&(&b.0.worker_url, b.0.kind)));
for (key, value) in entries {
out.push_str(&format!(
"sgl_router_active_load{{worker_url=\"{}\",kind=\"{}\"}} {}\n",
escape_label(&key.worker_url),
key.kind,
value,
));
}
drop(guard);
// stale_requests_total
out.push_str(
"# HELP sgl_router_stale_requests_total Total stale-request cancellations fired by the janitor.\n",
);
out.push_str("# TYPE sgl_router_stale_requests_total counter\n");
let guard = self.stale_requests_total.lock();
let mut entries: Vec<(&&str, u64)> = guard
.iter()
.map(|(k, v)| (k, v.load(Ordering::Relaxed)))
.collect();
entries.sort_by_key(|e| *e.0);
for (outcome, value) in entries {
out.push_str(&format!(
"sgl_router_stale_requests_total{{outcome=\"{}\"}} {}\n",
outcome, value,
));
}
drop(guard);
// decode_affinity_total
out.push_str(
"# HELP sgl_router_decode_affinity_total Decode-affinity outcomes from select_decode_with_affinity.\n",
);
out.push_str("# TYPE sgl_router_decode_affinity_total counter\n");
let guard = self.decode_affinity_total.lock();
let mut entries: Vec<(&&str, u64)> = guard
.iter()
.map(|(k, v)| (k, v.load(Ordering::Relaxed)))
.collect();
entries.sort_by_key(|e| *e.0);
for (outcome, value) in entries {
out.push_str(&format!(
"sgl_router_decode_affinity_total{{outcome=\"{}\"}} {}\n",
outcome, value,
));
}
drop(guard);
out
}
}
/// Prometheus label-value escape rule per
/// https://prometheus.io/docs/instrumenting/exposition_formats/.
/// We only escape `\`, `"`, and newline — the three characters the
/// reference parser rejects unescaped.
fn escape_label(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str(r"\\"),
'"' => out.push_str(r#"\""#),
'\n' => out.push_str(r"\n"),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_registry_renders_only_help_lines() {
let reg = MetricsRegistry::new();
let out = reg.render();
// Should at least carry HELP / TYPE for every metric family.
assert!(out.contains("# TYPE sgl_router_requests_total counter"));
assert!(out.contains("# TYPE sgl_router_overlap_blocks histogram"));
assert!(out.contains("# TYPE sgl_router_active_load gauge"));
assert!(out.contains("# TYPE sgl_router_stale_requests_total counter"));
assert!(out.contains("# TYPE sgl_router_decode_affinity_total counter"));
}
#[test]
fn record_request_emits_labelled_counter_line() {
let reg = MetricsRegistry::new();
reg.record_request(
"http://worker-a:30000",
"tiny",
WorkerModeLabel::Prefill,
RequestOutcome::Success,
);
reg.record_request(
"http://worker-a:30000",
"tiny",
WorkerModeLabel::Prefill,
RequestOutcome::Success,
);
let out = reg.render();
assert!(
out.contains(r#"sgl_router_requests_total{worker_url="http://worker-a:30000",model_id="tiny",mode="prefill",outcome="success"} 2"#),
"render did not include the expected counter line; got:\n{out}",
);
}
#[test]
fn observe_overlap_blocks_writes_buckets_and_count() {
let reg = MetricsRegistry::new();
reg.observe_overlap_blocks("tiny", 3);
reg.observe_overlap_blocks("tiny", 9);
reg.observe_overlap_blocks("tiny", 50);
let out = reg.render();
// 3 observations -> count=3, sum=62
assert!(out.contains(r#"sgl_router_overlap_blocks_count{model_id="tiny"} 3"#));
assert!(out.contains(r#"sgl_router_overlap_blocks_sum{model_id="tiny"} 62"#));
// The le=64 bucket is cumulative: 3 is <=4, 9 is <=16, 50 is <=64.
assert!(
out.contains(r#"sgl_router_overlap_blocks_bucket{model_id="tiny",le="64"} 3"#),
"bucket le=64 should be 3 (cumulative); got:\n{out}",
);
// The le=4 bucket should include only the 3.
assert!(
out.contains(r#"sgl_router_overlap_blocks_bucket{model_id="tiny",le="4"} 1"#),
"bucket le=4 should be 1; got:\n{out}",
);
}
#[test]
fn set_active_load_gauge_overwrites() {
let reg = MetricsRegistry::new();
reg.set_active_load("http://w:30000", ActiveLoadKind::PrefillTokens, 100);
reg.set_active_load("http://w:30000", ActiveLoadKind::PrefillTokens, 250);
let out = reg.render();
assert!(out.contains(
r#"sgl_router_active_load{worker_url="http://w:30000",kind="prefill_tokens"} 250"#,
));
// First write must NOT appear.
assert!(!out.contains(
r#"sgl_router_active_load{worker_url="http://w:30000",kind="prefill_tokens"} 100"#,
));
}
#[test]
fn stale_request_counter_increments() {
let reg = MetricsRegistry::new();
reg.record_stale_request(StaleRequestOutcome::Expired);
reg.record_stale_request(StaleRequestOutcome::Expired);
reg.record_stale_request(StaleRequestOutcome::Expired);
let out = reg.render();
assert!(out.contains(r#"sgl_router_stale_requests_total{outcome="expired"} 3"#));
}
#[test]
fn decode_affinity_counter_emits_three_outcomes() {
let reg = MetricsRegistry::new();
reg.record_decode_affinity(DecodeAffinityOutcome::SameHostPicked);
reg.record_decode_affinity(DecodeAffinityOutcome::SameHostPicked);
reg.record_decode_affinity(DecodeAffinityOutcome::FallbackBreaker);
reg.record_decode_affinity(DecodeAffinityOutcome::FallbackLoadImbalance);
let out = reg.render();
assert!(out.contains(r#"sgl_router_decode_affinity_total{outcome="same_host_picked"} 2"#));
assert!(out.contains(r#"sgl_router_decode_affinity_total{outcome="fallback_breaker"} 1"#));
assert!(out
.contains(r#"sgl_router_decode_affinity_total{outcome="fallback_load_imbalance"} 1"#,));
}
#[test]
fn label_values_escape_quotes_and_backslashes() {
let reg = MetricsRegistry::new();
reg.record_request(
r#"http://"weird":30000"#,
r"back\slash",
WorkerModeLabel::Plain,
RequestOutcome::Error,
);
let out = reg.render();
assert!(
out.contains(r#"worker_url="http://\"weird\":30000""#),
"render did not escape double-quote; got:\n{out}",
);
assert!(
out.contains(r#"model_id="back\\slash""#),
"render did not escape backslash; got:\n{out}",
);
}
#[test]
fn histogram_plus_inf_bucket_catches_overflow() {
let reg = MetricsRegistry::new();
// 1001 is just above the last finite bucket (1000); it should land
// in +Inf only.
reg.observe_overlap_blocks("m", 1001);
let out = reg.render();
assert!(out.contains(r#"sgl_router_overlap_blocks_bucket{model_id="m",le="1000"} 0"#));
assert!(out.contains(r#"sgl_router_overlap_blocks_bucket{model_id="m",le="+Inf"} 1"#));
}
}
@@ -0,0 +1,9 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
pub mod app;
pub mod app_context;
pub mod error;
pub mod header_utils;
pub mod metrics;
pub mod routes;
@@ -0,0 +1,678 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use crate::discovery::{ModelId, WorkerMode};
use crate::policies::registry::{PdPoolResolver, PdResolveError};
use crate::policies::SelectionContext;
use crate::server::app_context::AppContext;
use crate::server::error::ApiError;
use crate::server::metrics::{RequestOutcome, StaleRequestOutcome, WorkerModeLabel};
use crate::workers::{LoadGuard, Worker};
use axum::body::Body;
use axum::extract::State;
use axum::http::{HeaderMap, HeaderName, HeaderValue, Response};
use bytes::Bytes;
use serde::de::IgnoredAny;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
/// Observability header carrying the decode-pool URL selected via host
/// affinity for a PD-disaggregated request. The router fans the
/// bootstrap-injected request body to BOTH the prefill and the decode
/// worker concurrently; this header lets the prefill log the chosen
/// peer, and is mirrored onto the response so sidecars / tests can
/// observe affinity without sniffing the proxy hop. The `x-sgl-`
/// prefix matches `x-sgl-router-error-code` so router-emitted metadata
/// stays grouped.
const X_SGL_DECODE_URL: HeaderName = HeaderName::from_static("x-sgl-decode-url");
/// Coarse char-count → token-count divisor used to estimate prefill load
/// from the request body when no real tokenizer count is available. Four
/// bytes per token is the standard SGLang upstream estimate; it
/// overcounts ASCII and undercounts CJK but stays within an order of
/// magnitude of the real token count, which is plenty for load
/// scoring. The active-load counters' role is relative ordering across
/// workers — not absolute accuracy — so the estimate is fit for
/// purpose.
const CHARS_PER_TOKEN_ESTIMATE: usize = 4;
/// Per-route body-size cap on `/v1/chat/completions`. 1 MiB is comfortable
/// for normal chat traffic (a 200 k-token context tokenized as JSON is well
/// under this) while preventing a hostile client from forcing the router to
/// heap-allocate hundreds of MiB before forwarding. The cap is wired in
/// `crate::server::app::build_router` as a route-level `DefaultBodyLimit`
/// layer; axum's `Bytes` extractor enforces it and returns 413
/// PAYLOAD_TOO_LARGE before this handler runs.
pub const MAX_CHAT_BODY_BYTES: usize = 1 << 20;
/// Minimal probe over the request body — we only need the `stream` field
/// and the `model` field to decide between buffered vs SSE forwarding and
/// to select a worker. Deserializing into this struct (vs `serde_json::Value`)
/// does two things:
///
/// 1. Avoids the per-field heap allocation of `Value` for a 1 MiB body.
/// 2. Pins the contract: the body MUST be a JSON object. Degenerate
/// shapes (`null`, `[]`, `"hi"`) fail at this step rather than being
/// silently forwarded with `stream=false`.
///
/// All other fields are ignored — the worker is authoritative for the
/// full request schema.
#[derive(Debug, Deserialize)]
struct RequestProbe {
#[serde(default)]
stream: Option<bool>,
#[serde(default)]
model: Option<String>,
}
/// POST /v1/chat/completions — parse model from body, select a healthy
/// worker via the per-model policy, then proxy the request. If the
/// request opts into streaming (`stream: true`), we pipe SSE bytes back;
/// otherwise buffer.
pub async fn chat_completions(
State(ctx): State<Arc<AppContext>>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response<Body>, ApiError> {
let probe = parse_probe(&body)?;
let streaming = probe.stream.unwrap_or(false);
let model_str = probe
.model
.ok_or_else(|| ApiError::BadRequest("missing `model` field".into()))?;
let model_id = ModelId(model_str.clone());
// PD pool isolation: for PD-mode deployments, prefill traffic
// selects from the prefill pool only. Plain-mode deployments fall
// through to the full candidate set. Partial-failure errors
// (`no_prefill_workers_available`) are surfaced as 503 with a
// distinct error code so operators can alert independently.
let resolver = PdPoolResolver::new(Arc::clone(&ctx.registry));
let workers = resolver
.prefill_candidates(&model_id)
.map_err(|e| match e {
PdResolveError::NoHealthyWorkers => ApiError::NoHealthyWorkers {
model: model_str.clone(),
},
PdResolveError::NoPrefillWorkersAvailable => ApiError::NoPrefillWorkersAvailable {
model: model_str.clone(),
},
PdResolveError::NoDecodeWorkersAvailable => ApiError::NoDecodeWorkersAvailable {
model: model_str.clone(),
},
})?;
let policy = ctx
.policies
.get(&model_id)
.ok_or_else(|| ApiError::ModelNotFound(model_str.clone()))?;
let selection_ctx = SelectionContext::new(&model_id, Some(&body));
let worker =
policy
.select(&workers, &selection_ctx)
.ok_or_else(|| ApiError::PolicySelectionFailed {
model: model_str.clone(),
})?;
// PD-mode decoder affinity. When the selected prefill worker is
// part of a PD-disagg deployment, also resolve the matching decode
// peer (same host where possible, falling back to min-load via
// `select_decode_with_affinity`). Both workers receive the SAME
// request body — augmented with the three flat `bootstrap_*`
// fields below — so the SGLang engine can match incoming KV
// transfers via `bootstrap_room`.
//
// Plain-mode workers skip the decode resolution entirely (no
// decode peer to find). PD-mode requests that fail to resolve a
// decode peer (`NoDecodeWorkersAvailable`) bubble up as 503 so
// operators can alert on prefill-vs-decode pool imbalance.
let decode_peer: Option<Arc<Worker>> = if worker.mode() == WorkerMode::Prefill {
Some(
resolver
.decode_with_affinity(&model_id, &worker.url)
.map_err(|e| match e {
PdResolveError::NoHealthyWorkers => ApiError::NoHealthyWorkers {
model: model_str.clone(),
},
PdResolveError::NoDecodeWorkersAvailable => {
ApiError::NoDecodeWorkersAvailable {
model: model_str.clone(),
}
}
PdResolveError::NoPrefillWorkersAvailable => {
ApiError::NoPrefillWorkersAvailable {
model: model_str.clone(),
}
}
})?,
)
} else {
None
};
let decode_hint_url: Option<String> = decode_peer.as_ref().map(|d| d.url.clone());
let mut request_headers = headers;
if let Some(url) = &decode_hint_url {
match HeaderValue::from_str(url) {
Ok(v) => {
request_headers.insert(X_SGL_DECODE_URL, v);
}
Err(e) => {
// Discovery emits URLs the proxy has already used; a
// header-value parse failure here means the URL
// contains a control character (e.g. CR / LF) — drop
// the header but keep the request: bootstrap injection
// below carries the host/port the engine actually
// needs; the header is purely observability.
tracing::warn!(
decode_url = %url,
error = %e,
"decode worker URL rejected by header parser; sending request without decode hint",
);
}
}
}
let headers = request_headers;
// Per-worker `active_requests` guard. The `ActiveLoadGuard` below
// sits beside this one: both track in-flight load, but the
// ActiveLoadGuard entry is per-request (with timeout-based janitor)
// while the worker-scoped counter is what the cache-aware policy
// reads. Both must drop at the same time — when the response stream
// ends, the client disconnects, or the handler returns an error. In
// PD mode the pair moves into the spawned prefill task so prefill
// load is tracked for the full duration of the KV transfer; in plain
// mode the pair stays in this handler. Decode-load contribution is
// 0 here: the active-load registry's decode axis is reserved for a
// future decode-side scheduler — current decode selection is
// host-affinity only.
let guard = worker.load_guard();
let prefill_load = estimate_prefill_tokens(&body);
let active_guard =
ctx.active_load
.register(worker.id.clone(), worker.url.clone(), prefill_load, 0);
// Snapshot the stale-request cancel token BEFORE moving the guard
// into the spawned prefill task / streaming pump / response future.
// The token is cheap to clone (it's an `Arc<...>` internally) and
// the chat handler races the client-facing fetch against
// `token.cancelled()` to surface a 504 `stale_request_expired` if
// the janitor expires the request mid-flight.
let stale_token = active_guard.cancel_token().clone();
// Snapshot the labels we need for metrics BEFORE moving the worker
// / model_str values into the per-branch fetch futures.
let metrics_worker_url = worker.url.clone();
let metrics_mode = match worker.mode() {
WorkerMode::Prefill => WorkerModeLabel::Prefill,
WorkerMode::Decode => WorkerModeLabel::Decode,
WorkerMode::Plain => WorkerModeLabel::Plain,
};
let metrics_model = model_str.clone();
let result = if let Some(decode_worker) = decode_peer {
// PD-disagg dispatch (Pattern B — spawn prefill, await decode).
//
// SGLang's HTTP-mode disagg-prefill requires three flat
// top-level fields on the request body: `bootstrap_host`,
// `bootstrap_port` (the prefill worker's bootstrap-server
// address) and `bootstrap_room` (a per-request 63-bit u64 ID
// used by both sides to pair up the KV transfer). We inject
// these here and fan the same modified body to both the
// prefill and decode workers concurrently.
//
// **Why spawn-and-forget for prefill instead of
// `tokio::join!`?** All three peer SGLang-HTTP-PD routers
// (Dynamo / llm-d / aibrix) converged on this shape: the
// prefill request must outlive the client connection because
// tying prefill to the client future opens a cancel-race
// window where the engine's NIXL RPC teardown can leak KV
// block refs (NVBugs 5969206 in Dynamo). The detached task
// also keeps the LoadGuard + ActiveLoadGuard alive for the full
// prefill duration — KV transfer can run for tens of seconds
// even when the client gave up.
//
// No watchdog for fail-fast on prefill 5xx: llm-d / aibrix both
// ship without one. On prefill failure the client experiences
// the SGLang decode-side bootstrap_room timeout (~3060 s by
// default) instead of an immediate 502. A follow-up can wire a
// `tokio::sync::watch` channel if telemetry shows it matters.
//
// **Scope of the "detached" guarantee.** The spawn protects
// against client disconnect — the handler future being dropped
// does NOT cancel the prefill HTTP request. It does NOT protect
// against router shutdown: when `AppContext` tears down, the
// tokio runtime cancels all unfinished tasks including this
// one. A future follow-up could thread a `TaskTracker` /
// `JoinSet` through `AppContext` for graceful shutdown drain;
// the current implementation ships without one (matching SMG's
// shutdown behaviour).
let bootstrap_room = generate_room_id();
let injected_body = inject_bootstrap_fields(
&body,
worker.bootstrap_host(),
worker.bootstrap_port(),
bootstrap_room,
)?;
let prefill_url = worker.url.clone();
let prefill_breaker = Arc::clone(&worker.breaker);
let prefill_headers = headers.clone();
let prefill_body = injected_body.clone();
let prefill_proxy = Arc::clone(&ctx.proxy);
let prefill_holds: (LoadGuard, _) = (guard, active_guard);
tokio::spawn(async move {
// The tuple binding extends both guards' lifetime to the
// end of this async block, which lasts until the prefill
// HTTP request returns (success / error / engine-side
// bootstrap_room timeout). The result is logged and
// swallowed — no channel back to the client. See the big
// comment above for the rationale.
let _hold = prefill_holds;
match prefill_proxy
.forward_json_to(
&prefill_url,
&prefill_breaker,
"/v1/chat/completions",
&prefill_headers,
prefill_body,
)
.await
{
Ok(_) => tracing::debug!(
prefill_url = %prefill_url,
bootstrap_room,
"prefill side completed",
),
Err(e) => tracing::warn!(
prefill_url = %prefill_url,
bootstrap_room,
error = %e,
"prefill request failed; decode will time out on bootstrap_room",
),
}
});
// Synchronously await the decode worker. Its response is what
// the client sees. The decode side gets its own LoadGuard so
// per-worker `active_requests` reflects decode-pool load for
// cache-aware-zmq decisions on the decode side.
let decode_guard = decode_worker.load_guard();
if streaming {
let stream_guards: Box<dyn Send + 'static> = Box::new(decode_guard);
let fetch = ctx.proxy.forward_streaming_to(
&decode_worker.url,
&decode_worker.breaker,
"/v1/chat/completions",
&headers,
injected_body,
Some(stream_guards),
);
tokio::select! {
biased;
r = fetch => r,
_ = stale_token.cancelled() => Err(ApiError::StaleRequestExpired { model: model_str }),
}
} else {
let _decode_hold = decode_guard;
let fetch = ctx.proxy.forward_json_to(
&decode_worker.url,
&decode_worker.breaker,
"/v1/chat/completions",
&headers,
injected_body,
);
tokio::select! {
biased;
r = fetch => r,
_ = stale_token.cancelled() => Err(ApiError::StaleRequestExpired { model: model_str }),
}
}
} else if streaming {
// Plain mode, streaming. Both guards ride the SSE pump until
// the body completes — see the matching comment in the
// non-streaming arm.
let stream_guards: Box<dyn Send + 'static> = Box::new((guard, active_guard));
let fetch = ctx.proxy.forward_streaming_to(
&worker.url,
&worker.breaker,
"/v1/chat/completions",
&headers,
body,
Some(stream_guards),
);
// Bias `fetch` over the cancellation branch: a successful
// response that completes in the same poll as the token firing
// MUST win (returning 504 for a request that already has
// headers is a correctness regression). The cancellation
// branch only matters when fetch is still pending — at that
// point biasing the order is a wash.
tokio::select! {
biased;
r = fetch => r,
_ = stale_token.cancelled() => Err(ApiError::StaleRequestExpired { model: model_str }),
}
} else {
// Plain mode, non-streaming. The handler awaits the full
// buffered response, so both guards live correctly in this
// scope. The tuple binding exists only to extend the guards'
// lifetime to the end of the function — the `forward_json_to`
// future does not need them (it does not return until the
// body is buffered).
let _holds: (LoadGuard, _) = (guard, active_guard);
let fetch = ctx.proxy.forward_json_to(
&worker.url,
&worker.breaker,
"/v1/chat/completions",
&headers,
body,
);
// Same `biased` order as the streaming arm.
tokio::select! {
biased;
r = fetch => r,
_ = stale_token.cancelled() => Err(ApiError::StaleRequestExpired { model: model_str }),
}
};
// Record the dispatch outcome AFTER we know whether the upstream
// accepted the request. A 504 from the stale-request branch counts as
// `cancelled` — semantically distinct from upstream errors that bubble
// through as `error`. The metric is per-worker so convergence tests
// can scrape `/metrics` and assert that ≥N requests landed on a
// single prefill worker.
let outcome = match &result {
Ok(_) => RequestOutcome::Success,
Err(ApiError::StaleRequestExpired { .. }) => {
// The janitor fired the stale-cancel and we observed it
// user-side; record both the per-request `cancelled` outcome
// AND the global `expired` count. The two views are useful for
// different alerts: per-worker request_total{cancelled} flags a
// worker that's hanging, while stale_requests_total{expired}
// tracks the global health of the janitor.
ctx.metrics
.record_stale_request(StaleRequestOutcome::Expired);
RequestOutcome::Cancelled
}
Err(_) => RequestOutcome::Error,
};
ctx.metrics
.record_request(&metrics_worker_url, &metrics_model, metrics_mode, outcome);
// Mirror the upstream `x-sgl-decode-url` hint onto the response so
// external tests / sidecars can observe PD decode affinity without
// sniffing the proxy hop. The request-side header was set above for
// the prefill worker; copying it here makes the affinity observable
// end-to-end. Plain-mode requests skip this (no decode peer was
// resolved). A malformed URL was already rejected at the
// request-side parse — we only reach this branch when the URL was
// header-valid, so the second parse is safe.
match (result, decode_hint_url) {
(Ok(mut response), Some(url)) => {
match HeaderValue::from_str(&url) {
Ok(v) => {
response.headers_mut().insert(X_SGL_DECODE_URL, v);
}
Err(e) => {
// Already-validated upstream; defensive log only.
tracing::warn!(
decode_url = %url,
error = %e,
"decode worker URL rejected by header parser on response; omitting response-side hint",
);
}
}
Ok(response)
}
(other, _) => other,
}
}
/// Estimate prefill-token count from the raw request body for use as
/// the active-load `prefill_load` counter. Returns 1 at minimum so
/// a registered request always shows up as "load > 0" — under-counting
/// to zero would hide the request from the cache-aware policy's
/// load-imbalance fast-path.
///
/// This is a coarse approximation: we count the body length in bytes
/// and divide by [`CHARS_PER_TOKEN_ESTIMATE`]. A future improvement is
/// to thread the tokenizer's actual token count through (the
/// cache-aware-zmq policy already tokenizes the prompt for tree
/// matching — that count could be reused here).
fn estimate_prefill_tokens(body: &Bytes) -> usize {
(body.len() / CHARS_PER_TOKEN_ESTIMATE).max(1)
}
/// Mint a fresh `bootstrap_room` for a PD-disagg request.
///
/// SGLang's disagg-prefill stores the room as a signed `i64` internally
/// (see `python/sglang/srt/disaggregation/utils.py` — `bootstrap_room`
/// metadata buffer is allocated as `torch.int64`). Generating in
/// `[0, i64::MAX]` keeps the value safely positive when reinterpreted
/// signed. Mirrors SMG's `pd_types::generate_room_id`, Dynamo's
/// `rand::random_range(0..=i64::MAX.cast_unsigned())`, and SGLang's
/// own Python-side `random.randint(0, 2**63 - 1)`.
fn generate_room_id() -> u64 {
rand::random::<u64>() & (i64::MAX as u64)
}
/// Inject the three flat top-level fields SGLang's HTTP disagg-prefill
/// validator requires:
///
/// * `bootstrap_host` — the prefill worker's hostname; decode connects
/// to this address for the KV transfer.
/// * `bootstrap_port` — the prefill worker's bootstrap server port
/// (may be `null` if the worker is misconfigured; the engine will
/// reject the request with a clear error).
/// * `bootstrap_room` — a 63-bit random `u64` identifying this request
/// on both prefill and decode sides.
///
/// The body must already be a JSON object (the chat handler's
/// `parse_probe` guarantees this); we re-parse into a `Map` here to
/// mutate top-level keys without walking nested values into a full
/// `serde_json::Value`. A malformed body is mapped to
/// `ApiError::BadRequest` — the parse_probe layer should already have
/// caught this, but defending against TOCTOU keeps the error path
/// honest.
fn inject_bootstrap_fields(
body: &Bytes,
bootstrap_host: &str,
bootstrap_port: Option<u16>,
bootstrap_room: u64,
) -> Result<Bytes, ApiError> {
let mut obj: serde_json::Map<String, serde_json::Value> = serde_json::from_slice(body)
.map_err(|e| {
tracing::debug!(error = %e, "re-parse for bootstrap injection failed");
ApiError::BadRequest("invalid request: body must be a JSON object".to_string())
})?;
obj.insert(
"bootstrap_host".to_string(),
serde_json::Value::String(bootstrap_host.to_string()),
);
obj.insert(
"bootstrap_port".to_string(),
match bootstrap_port {
Some(p) => serde_json::Value::Number(p.into()),
None => serde_json::Value::Null,
},
);
obj.insert(
"bootstrap_room".to_string(),
serde_json::Value::Number(bootstrap_room.into()),
);
let bytes = serde_json::to_vec(&obj).map_err(|e| {
ApiError::Internal(anyhow::Error::new(e).context("re-serialize bootstrap-injected body"))
})?;
Ok(Bytes::from(bytes))
}
fn parse_probe(body: &Bytes) -> Result<RequestProbe, ApiError> {
// We deliberately do NOT echo the serde error into the client-visible
// message — that risks leaking field-level detail and is also of little
// help to a real client (which already has its own JSON validator).
// Server-side, the full error is logged with `tracing::debug!` for
// operator triage.
//
// Two-step deserialize:
// 1. `Map<String, IgnoredAny>` *anchors* the shape to a JSON object.
// This rejects `null` / `[]` / `"hi"` (all valid JSON but not
// request shape) without walking the full value into a
// `serde_json::Value` per field.
// 2. `RequestProbe` (struct of `Option<bool>` + `Option<String>`)
// lifts out only the fields we care about — `stream` and `model`.
// Other fields are ignored; the worker is authoritative for the
// rest of the schema.
let _: HashMap<String, IgnoredAny> = serde_json::from_slice(body).map_err(|e| {
tracing::debug!(error = %e, "chat-completions body rejected as non-object JSON");
ApiError::BadRequest("invalid request: body must be a JSON object".to_string())
})?;
let probe: RequestProbe = serde_json::from_slice(body).map_err(|e| {
tracing::debug!(error = %e, "chat-completions request-probe deserialize failed");
ApiError::BadRequest("invalid request: body must be a JSON object".to_string())
})?;
Ok(probe)
}
#[cfg(test)]
mod tests {
use super::*;
/// `generate_room_id` MUST return values in `[0, i64::MAX]`. The
/// SGLang prefill stores `bootstrap_room` as `torch.int64`; a u64
/// with the top bit set would wrap negative on the engine side.
/// Sample many times to defend against future refactors of the
/// mask (e.g. someone "simplifying" to plain `rand::random::<u64>()`).
#[test]
fn generate_room_id_stays_in_63_bit_range() {
for _ in 0..10_000 {
let r = generate_room_id();
assert!(
r <= i64::MAX as u64,
"generate_room_id() returned {r} > i64::MAX; would wrap negative as torch.int64",
);
}
}
/// When the prefill worker has no `bootstrap_port` configured
/// (a misconfiguration the engine will reject loudly), the
/// injected field MUST be JSON `null` — not omitted, not 0.
/// SGLang's validator distinguishes "missing field" from
/// "null field" in some code paths.
#[test]
fn inject_bootstrap_fields_emits_null_for_missing_port() {
let body = Bytes::from_static(br#"{"model":"x","messages":[]}"#);
let injected = inject_bootstrap_fields(&body, "host", None, 42).unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&injected).unwrap();
assert_eq!(parsed.get("bootstrap_port"), Some(&serde_json::Value::Null));
assert_eq!(
parsed.get("bootstrap_host"),
Some(&serde_json::Value::String("host".into()))
);
assert_eq!(
parsed.get("bootstrap_room"),
Some(&serde_json::Value::Number(42.into()))
);
}
#[test]
fn parse_probe_reads_stream_bool_from_object() {
let b = Bytes::from_static(br#"{"stream": true, "model": "tiny"}"#);
assert_eq!(parse_probe(&b).unwrap().stream, Some(true));
let b = Bytes::from_static(br#"{"stream": false, "model": "tiny"}"#);
assert_eq!(parse_probe(&b).unwrap().stream, Some(false));
}
#[test]
fn parse_probe_defaults_when_stream_absent() {
// Existing happy-path contract: well-formed object missing `stream`
// must default to None (caller picks false). The minimal `RequestProbe`
// (Option<bool> + #[serde(default)]) must NOT break this.
let b = Bytes::from_static(br#"{"model": "tiny", "messages": []}"#);
let p = parse_probe(&b).unwrap();
assert_eq!(p.stream, None);
assert_eq!(p.model.as_deref(), Some("tiny"));
}
#[test]
fn parse_probe_rejects_non_object_shapes() {
// Pin the contract: degenerate JSON (valid JSON but wrong shape)
// must be rejected, not silently forwarded with `stream=false`.
for bad in [&b"null"[..], &b"[]"[..], &b"\"hi\""[..], &b"42"[..]] {
let b = Bytes::copy_from_slice(bad);
let err = parse_probe(&b).unwrap_err();
match err {
ApiError::BadRequest(_) => {}
other => panic!("expected BadRequest for {bad:?}, got {other:?}"),
}
}
}
#[test]
fn parse_probe_rejects_malformed_json() {
let b = Bytes::from_static(b"{not json}");
let err = parse_probe(&b).unwrap_err();
assert!(matches!(err, ApiError::BadRequest(_)));
}
#[test]
fn parse_probe_handles_nested_messages_with_stream_true() {
// Well-formed object with nested arrays/objects (real chat-completions
// payloads carry `messages: [{role, content: [{type, text}]}]`). The
// two-step deserialize must not balk on this — only the top-level
// object shape and the `stream`/`model` fields matter.
let b = Bytes::from_static(
br#"{
"model": "x",
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
"stream": true
}"#,
);
assert_eq!(parse_probe(&b).unwrap().stream, Some(true));
}
#[test]
fn parse_probe_handles_nested_messages_with_stream_false() {
let b = Bytes::from_static(
br#"{
"model": "x",
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
"stream": false
}"#,
);
assert_eq!(parse_probe(&b).unwrap().stream, Some(false));
}
#[test]
fn parse_probe_handles_duplicate_stream_keys() {
// RFC 8259 says "names within an object SHOULD be unique" but a
// parser MAY accept duplicates. Step 1 (HashMap) silently
// last-wins, but step 2 deserializes into the typed `RequestProbe`
// struct, and `serde_json`'s `#[derive(Deserialize)]` REJECTS
// duplicate fields with a `duplicate field` error.
//
// We map that to `BadRequest` (same path as other malformed input).
// Pinning "reject" rather than "last-wins" is intentional —
// ambiguous bodies should fail loudly at the edge, not silently
// route based on which copy serde happened to see last.
let b = Bytes::from_static(br#"{"stream": true, "stream": false}"#);
let err = parse_probe(&b).unwrap_err();
match err {
ApiError::BadRequest(_) => {}
other => panic!("expected BadRequest on duplicate `stream` key, got {other:?}"),
}
}
#[test]
fn parse_probe_bad_request_message_does_not_leak_serde_detail() {
// Info-leak guard: the client-visible message must be a fixed
// string, not the serde error (which can contain line/column
// detail or hint at field shape).
let b = Bytes::from_static(br#"{"stream": "not-a-bool"}"#);
let err = parse_probe(&b).unwrap_err();
match err {
ApiError::BadRequest(msg) => assert_eq!(
msg, "invalid request: body must be a JSON object",
"client-visible message must be fixed; got: {msg}"
),
other => panic!("expected BadRequest, got {other:?}"),
}
}
}
@@ -0,0 +1,126 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use crate::server::app_context::AppContext;
use axum::extract::State;
use axum::http::StatusCode;
use std::sync::Arc;
/// Always returns 200 — liveness probe.
pub async fn healthz() -> StatusCode {
StatusCode::OK
}
/// Readiness probe — 200 only when the pod can actually serve traffic.
///
/// Requires BOTH:
/// 1. `AppContext::mark_ready()` was called by main (process bootstrap
/// finished — config loaded, tokenizers built, server bound), AND
/// 2. At least one worker is registered. Without this second check,
/// `/readyz` flips green before the first `DiscoveryEvent::Added`
/// has been processed — the Service starts sending traffic to a
/// pod whose registry is empty, and every request returns 503
/// `no_healthy_workers`.
pub async fn readyz(State(ctx): State<Arc<AppContext>>) -> StatusCode {
if ctx.is_ready() && !ctx.registry.is_empty() {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
#[tokio::test]
async fn healthz_always_200() {
let app = crate::server::app::build_router(test_ctx(false, false));
let res = app
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn readyz_503_when_not_ready() {
let app = crate::server::app::build_router(test_ctx(false, true));
let res = app
.oneshot(
Request::builder()
.uri("/readyz")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn readyz_503_when_ready_but_registry_empty() {
// Regression: `/readyz` previously returned 200 the moment
// `mark_ready()` was called, even with an empty worker
// registry. The Service would route traffic to a pod that
// could only return 503 no_healthy_workers.
let app = crate::server::app::build_router(test_ctx(true, false));
let res = app
.oneshot(
Request::builder()
.uri("/readyz")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::SERVICE_UNAVAILABLE,
"ready=true + empty registry must still be 503"
);
}
#[tokio::test]
async fn readyz_200_when_ready_and_worker_registered() {
let app = crate::server::app::build_router(test_ctx(true, true));
let res = app
.oneshot(
Request::builder()
.uri("/readyz")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
fn test_ctx(ready: bool, with_worker: bool) -> Arc<AppContext> {
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
let ctx = AppContext::stub();
if ready {
ctx.mark_ready();
}
if with_worker {
ctx.registry
.add(WorkerSpec {
id: WorkerId("test-w".into()),
url: "http://test:30000".into(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("test".into())],
bootstrap_port: None,
})
.expect("test worker accepted");
}
Arc::new(ctx)
}
}
@@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! `/metrics` endpoint — Prometheus 0.0.4 exposition.
//!
//! Returns the live snapshot of [`crate::server::metrics::MetricsRegistry`].
//! Plain-text body; charset is utf-8. We deliberately don't gate this on
//! readiness — scrapers should be able to read the metrics surface even
//! while the router is warming up so the "router started but no workers
//! discovered" failure mode is observable.
use crate::server::app_context::AppContext;
use axum::extract::State;
use axum::http::header::CONTENT_TYPE;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use std::sync::Arc;
/// Content-Type per Prometheus exposition format spec.
const PROMETHEUS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8";
pub async fn metrics(State(ctx): State<Arc<AppContext>>) -> impl IntoResponse {
let body = ctx.metrics.render();
(
StatusCode::OK,
[(CONTENT_TYPE, PROMETHEUS_CONTENT_TYPE)],
body,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::metrics::{RequestOutcome, WorkerModeLabel};
use axum::body::Body;
use axum::http::Request;
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn metrics_endpoint_returns_prometheus_text() {
let ctx = Arc::new(AppContext::stub());
let app = crate::server::app::build_router(ctx.clone());
let res = app
.oneshot(
Request::builder()
.uri("/metrics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let content_type = res
.headers()
.get(CONTENT_TYPE)
.expect("content-type header")
.to_str()
.unwrap()
.to_owned();
assert!(
content_type.starts_with("text/plain"),
"expected text/plain, got {content_type}",
);
let body = res.into_body().collect().await.unwrap().to_bytes();
let body = std::str::from_utf8(&body).unwrap();
// Every metric family should at least carry its HELP/TYPE lines.
assert!(body.contains("# TYPE sgl_router_requests_total counter"));
assert!(body.contains("# TYPE sgl_router_overlap_blocks histogram"));
assert!(body.contains("# TYPE sgl_router_active_load gauge"));
}
#[tokio::test]
async fn metrics_endpoint_reflects_recorded_counters() {
let ctx = Arc::new(AppContext::stub());
ctx.metrics.record_request(
"http://w-test:30000",
"tiny",
WorkerModeLabel::Prefill,
RequestOutcome::Success,
);
let app = crate::server::app::build_router(ctx.clone());
let res = app
.oneshot(
Request::builder()
.uri("/metrics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = res.into_body().collect().await.unwrap().to_bytes();
let body = std::str::from_utf8(&body).unwrap();
assert!(
body.contains(r#"worker_url="http://w-test:30000""#),
"metrics did not include the recorded worker_url; got:\n{body}",
);
}
}
@@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
pub mod chat;
pub mod health;
pub mod metrics;
pub mod models;
pub mod tokenize;
@@ -0,0 +1,97 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use crate::server::app_context::AppContext;
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use std::sync::Arc;
#[derive(Serialize)]
pub struct ModelsList {
pub object: &'static str,
pub data: Vec<ModelEntry>,
}
#[derive(Serialize)]
pub struct ModelEntry {
pub id: String,
pub object: &'static str,
pub owned_by: &'static str,
}
pub async fn list_models(State(ctx): State<Arc<AppContext>>) -> Json<ModelsList> {
let data = ctx
.config
.models
.iter()
.map(|m| ModelEntry {
id: m.id.clone(),
object: "model",
owned_by: "sglang",
})
.collect();
Json(ModelsList {
object: "list",
data,
})
}
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use crate::config::PolicyKind;
#[tokio::test]
async fn lists_configured_models() {
let mut ctx = crate::server::app_context::AppContext::stub();
ctx.config.models = vec![
crate::config::ModelConfig {
id: "qwen3".into(),
tokenizer_path: "x".into(),
policy: PolicyKind::RoundRobin,
circuit_breaker: None,
cache_aware: None,
},
crate::config::ModelConfig {
id: "deepseek".into(),
tokenizer_path: "y".into(),
policy: PolicyKind::RoundRobin,
circuit_breaker: None,
cache_aware: None,
},
];
let app = crate::server::app::build_router(std::sync::Arc::new(ctx));
let res = app
.oneshot(
Request::builder()
.uri("/v1/models")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let bytes = res.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["object"], "list");
let ids: Vec<&str> = v["data"]
.as_array()
.unwrap()
.iter()
.map(|m| m["id"].as_str().unwrap())
.collect();
assert_eq!(ids, vec!["qwen3", "deepseek"]);
assert_eq!(v["data"][0]["object"], "model");
// Pin `owned_by` so a refactor that flips the hardcoded value to
// "openai" / "" / a typo would fail loudly here. OpenAI clients
// expect this field and some (e.g. langchain-openai) treat
// `owned_by != "system"` as a meaningful signal.
assert_eq!(v["data"][0]["owned_by"], "sglang");
assert_eq!(v["data"][1]["owned_by"], "sglang");
}
}
@@ -0,0 +1,362 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use crate::server::app_context::AppContext;
use crate::server::error::ApiError;
use crate::tokenizer::adapter;
use axum::extract::State;
use axum::Json;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TokenizeRequest {
pub model: String,
pub prompt: String,
}
#[derive(Serialize)]
#[cfg_attr(test, derive(Deserialize))]
pub struct TokenizeResponse {
pub model: String,
pub tokens: Vec<u32>,
pub count: usize,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DetokenizeRequest {
pub model: String,
pub tokens: Vec<u32>,
#[serde(default)]
pub skip_special_tokens: bool,
}
#[derive(Serialize)]
#[cfg_attr(test, derive(Deserialize))]
pub struct DetokenizeResponse {
pub model: String,
pub text: String,
}
pub async fn tokenize(
State(ctx): State<Arc<AppContext>>,
Json(req): Json<TokenizeRequest>,
) -> Result<Json<TokenizeResponse>, ApiError> {
let tok = ctx
.tokenizers
.get(&req.model)
.ok_or_else(|| ApiError::ModelNotFound(req.model.clone()))?;
// Structured log on failure so an operator can correlate
// "every encode for model X errors" against the route, model id, and
// prompt size. The generic anyhow-chain log in ApiError::Internal still
// fires from IntoResponse — duplication is intentional: the route-level
// line carries `model` / `prompt_len`, the IntoResponse line carries
// the full anyhow chain.
let ids = adapter::encode(&tok, &req.prompt).map_err(|e| {
tracing::error!(
route = "/v1/tokenize",
model = %req.model,
prompt_len = req.prompt.len(),
error = ?e,
"tokenize.encode failed",
);
ApiError::Internal(e)
})?;
Ok(Json(TokenizeResponse {
model: req.model,
count: ids.len(),
tokens: ids,
}))
}
pub async fn detokenize(
State(ctx): State<Arc<AppContext>>,
Json(req): Json<DetokenizeRequest>,
) -> Result<Json<DetokenizeResponse>, ApiError> {
let tok = ctx
.tokenizers
.get(&req.model)
.ok_or_else(|| ApiError::ModelNotFound(req.model.clone()))?;
let text =
adapter::decode_complete(&tok, &req.tokens, req.skip_special_tokens).map_err(|e| {
tracing::error!(
route = "/v1/detokenize",
model = %req.model,
n_tokens = req.tokens.len(),
skip_special = req.skip_special_tokens,
error = ?e,
"detokenize.decode_complete failed",
);
ApiError::Internal(e)
})?;
Ok(Json(DetokenizeResponse {
model: req.model,
text,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use crate::config::PolicyKind;
fn ctx_with_tiny() -> Arc<AppContext> {
let cfg = crate::config::Config {
server: crate::config::ServerConfig {
host: "x".into(),
port: 0,
},
observability: Default::default(),
models: vec![crate::config::ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
circuit_breaker: None,
cache_aware: None,
}],
discovery: crate::config::DiscoveryConfig {
backend: crate::config::DiscoveryBackend::StaticUrls(
crate::config::StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
},
),
},
proxy: crate::config::ProxyConfig::default(),
active_load: crate::config::ActiveLoadConfig::default(),
};
let registry = crate::tokenizer::TokenizerRegistry::load_from_config(&cfg).unwrap();
let proxy = Arc::new(
crate::proxy::Proxy::new(std::time::Duration::from_secs(60)).expect("stub proxy"),
);
let worker_registry = Arc::new(crate::workers::WorkerRegistry::default());
let policies = Arc::new(crate::policies::PolicyRegistry::default());
Arc::new(AppContext::new(
cfg,
Arc::new(registry),
proxy,
worker_registry,
policies,
))
}
#[tokio::test]
async fn tokenize_round_trip() {
let app = crate::server::app::build_router(ctx_with_tiny());
let body = serde_json::to_vec(&serde_json::json!({
"model": "tiny", "prompt": "hello world"
}))
.unwrap();
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/tokenize")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let bytes = res.into_body().collect().await.unwrap().to_bytes();
let r: TokenizeResponse = serde_json::from_slice(&bytes).unwrap();
assert!(r.count > 0);
let body2 = serde_json::to_vec(&serde_json::json!({
"model": "tiny", "tokens": r.tokens, "skip_special_tokens": true
}))
.unwrap();
let res2 = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/detokenize")
.header("content-type", "application/json")
.body(Body::from(body2))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res2.status(), StatusCode::OK);
let bytes2 = res2.into_body().collect().await.unwrap().to_bytes();
let r2: DetokenizeResponse = serde_json::from_slice(&bytes2).unwrap();
assert_eq!(r2.text, "hello world");
}
#[tokio::test]
async fn tokenize_request_does_not_advertise_add_special_tokens() {
// Regression: TokenizeRequest must not have an `add_special_tokens` field.
// Background: dynamo-tokenizers cannot honor it. Silently ignoring it
// would be a footgun for clients that set it.
let req: TokenizeRequest =
serde_json::from_str(r#"{"model": "tiny", "prompt": "hi"}"#).unwrap();
let _ = req; // compiles → schema is correct minus that field
// If someone sets it anyway, serde should reject with deny_unknown_fields.
let parsed: Result<TokenizeRequest, _> = serde_json::from_str(
r#"{"model": "tiny", "prompt": "hi", "add_special_tokens": true}"#,
);
assert!(
parsed.is_err(),
"add_special_tokens should be rejected as unknown field"
);
}
/// Ported from SMG tests/api/parser_endpoints_test.rs (parse_function_call_missing_fields):
/// DetokenizeRequest has `deny_unknown_fields`; an extra field must yield 422
/// Unprocessable Entity from axum's JSON extractor, not 200 with the field silently ignored.
/// Gap: the existing `tokenize_request_does_not_advertise_add_special_tokens` test only
/// exercises serde deserialization directly; this test exercises the HTTP layer.
#[tokio::test]
async fn detokenize_rejects_unknown_field() {
let app = crate::server::app::build_router(ctx_with_tiny());
let body = serde_json::to_vec(&serde_json::json!({
"model": "tiny",
"tokens": [15496, 995],
"skip_special_tokens": false,
"add_special_tokens": true // unknown field — must be rejected
}))
.unwrap();
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/detokenize")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::UNPROCESSABLE_ENTITY,
"DetokenizeRequest must reject unknown fields via deny_unknown_fields"
);
}
/// Gap: `tokenize_round_trip` tests only `skip_special_tokens: true`.
/// When omitted, `#[serde(default)]` gives `false` — a different decode code-path.
/// This covers the default (omitted) and explicit-false routes end-to-end via HTTP.
#[tokio::test]
async fn detokenize_skip_special_tokens_false_default() {
let app = crate::server::app::build_router(ctx_with_tiny());
// First tokenize to get IDs.
let tok_body = serde_json::to_vec(&serde_json::json!({
"model": "tiny", "prompt": "hello world"
}))
.unwrap();
let tok_res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/tokenize")
.header("content-type", "application/json")
.body(Body::from(tok_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(tok_res.status(), StatusCode::OK);
let tok_bytes = tok_res.into_body().collect().await.unwrap().to_bytes();
let r: TokenizeResponse = serde_json::from_slice(&tok_bytes).unwrap();
// Detokenize with skip_special_tokens omitted (defaults to false).
let det_body_omitted = serde_json::to_vec(&serde_json::json!({
"model": "tiny",
"tokens": r.tokens
// skip_special_tokens intentionally absent — must default to false
}))
.unwrap();
let det_res_omitted = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/detokenize")
.header("content-type", "application/json")
.body(Body::from(det_body_omitted))
.unwrap(),
)
.await
.unwrap();
assert_eq!(det_res_omitted.status(), StatusCode::OK);
let det_bytes_omitted = det_res_omitted
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
let d_omitted: DetokenizeResponse = serde_json::from_slice(&det_bytes_omitted).unwrap();
assert_eq!(
d_omitted.text, "hello world",
"detokenize with skip_special_tokens omitted (default false) must round-trip"
);
// Also test explicit false — must be identical to omitted.
let det_body_explicit = serde_json::to_vec(&serde_json::json!({
"model": "tiny",
"tokens": r.tokens,
"skip_special_tokens": false
}))
.unwrap();
let det_res_explicit = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/detokenize")
.header("content-type", "application/json")
.body(Body::from(det_body_explicit))
.unwrap(),
)
.await
.unwrap();
assert_eq!(det_res_explicit.status(), StatusCode::OK);
let det_bytes_explicit = det_res_explicit
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
let d_explicit: DetokenizeResponse = serde_json::from_slice(&det_bytes_explicit).unwrap();
assert_eq!(
d_explicit.text, d_omitted.text,
"explicit skip_special_tokens=false must produce same result as omitted"
);
}
#[tokio::test]
async fn unknown_model_404() {
let app = crate::server::app::build_router(ctx_with_tiny());
let body = serde_json::to_vec(&serde_json::json!({
"model": "nope", "prompt": "x"
}))
.unwrap();
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/tokenize")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
assert_eq!(
res.headers().get("x-router-error-code").unwrap(),
"model_not_found"
);
}
}
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use anyhow::{Context, Result};
use dynamo_tokenizers::{traits::DecodeResult, Tokenizer};
use std::sync::Arc;
pub fn load(path: &str) -> Result<Arc<Tokenizer>> {
Tokenizer::from_file(path)
.map(Arc::new)
.with_context(|| format!("load tokenizer from {path}"))
}
pub fn encode(t: &Tokenizer, text: &str) -> Result<Vec<u32>> {
let enc = t.encode(text).context("encode")?;
Ok(enc.token_ids().to_vec())
}
/// Decode token ids to a complete UTF-8 string.
///
/// Non-streaming callers (e.g. `/v1/detokenize`) get the full result either way:
/// - `DecodeResult::Complete(s)` — the token sequence ends on a codepoint boundary.
/// - `DecodeResult::Partial(s)` — the token sequence ends mid-codepoint; `s` ends
/// in U+FFFD. We return `s` as-is so the client sees the closest-possible string.
///
/// Streaming callers should NOT use this; they should consume `DecodeResult`
/// directly and withhold the trailing U+FFFD until the next decode produces a
/// `Complete` result.
pub fn decode_complete(t: &Tokenizer, ids: &[u32], skip_special: bool) -> Result<String> {
let res = t.decode(ids, skip_special).context("decode")?;
Ok(match res {
DecodeResult::Complete(s) => s,
DecodeResult::Partial(s) => {
tracing::debug!(
n_tokens = ids.len(),
trailing_bytes = s.len(),
"decode_complete: tokenizer returned Partial for non-streaming call"
);
s
}
})
}
@@ -0,0 +1,199 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
pub mod adapter;
use anyhow::Result;
use dashmap::DashMap;
use dynamo_tokenizers::Tokenizer;
use std::sync::Arc;
#[derive(Default)]
pub struct TokenizerRegistry {
inner: DashMap<String, Arc<Tokenizer>>,
}
impl std::fmt::Debug for TokenizerRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenizerRegistry")
.field("models", &self.ids())
.finish()
}
}
impl TokenizerRegistry {
pub fn load_from_config(cfg: &crate::config::Config) -> Result<Self> {
let me = TokenizerRegistry::default();
for m in &cfg.models {
let t = adapter::load(&m.tokenizer_path)?;
me.inner.insert(m.id.clone(), t);
}
Ok(me)
}
pub fn get(&self, model_id: &str) -> Option<Arc<Tokenizer>> {
self.inner.get(model_id).map(|r| Arc::clone(&*r))
}
pub fn ids(&self) -> Vec<String> {
self.inner.iter().map(|kv| kv.key().clone()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use crate::config::PolicyKind;
fn cfg() -> crate::config::Config {
crate::config::Config {
server: crate::config::ServerConfig {
host: "0".into(),
port: 0,
},
observability: Default::default(),
models: vec![crate::config::ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
circuit_breaker: None,
cache_aware: None,
}],
discovery: crate::config::DiscoveryConfig {
backend: crate::config::DiscoveryBackend::StaticUrls(
crate::config::StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
},
),
},
proxy: crate::config::ProxyConfig::default(),
active_load: crate::config::ActiveLoadConfig::default(),
}
}
#[test]
fn loads_from_config() {
let r = TokenizerRegistry::load_from_config(&cfg()).unwrap();
assert!(r.get("tiny").is_some());
assert!(r.get("missing").is_none());
}
#[test]
fn shared_arc_per_model() {
let r = TokenizerRegistry::load_from_config(&cfg()).unwrap();
let a = r.get("tiny").unwrap();
let b = r.get("tiny").unwrap();
assert!(
Arc::ptr_eq(&a, &b),
"registry should return shared Arc, not clones"
);
}
#[test]
fn decode_complete_preserves_round_trip() {
let r = TokenizerRegistry::load_from_config(&cfg()).unwrap();
let t = r.get("tiny").unwrap();
let ids = adapter::encode(&t, "hello world").unwrap();
assert!(!ids.is_empty());
let text = adapter::decode_complete(&t, &ids, true).unwrap();
// tiny BPE fixture is byte-level and lossless for ASCII.
assert_eq!(text, "hello world");
}
/// Forces `decode_complete` through its `DecodeResult::Partial` branch.
///
/// Strategy A: the fixture is a GPT-2 byte-level BPE. The 4-byte UTF-8
/// emoji `😀` (`\xF0\x9F\x98\x80`) encodes into 2 byte-level BPE tokens
/// with this fixture: `[47249, 222]`. Decoding just the first token
/// yields a leading-bytes-only prefix that the HF adapter passes through
/// `String::from_utf8_lossy`, producing a trailing U+FFFD. dynamo's
/// `DecodeResult::from_decoded` then classifies that as `Partial`.
/// Pinning the literal token id keeps the test deterministic — if the
/// fixture or upstream BPE merges ever shift, this fails loudly rather
/// than silently dropping back into `Complete` and losing coverage.
#[test]
fn decode_complete_returns_string_on_partial_utf8() {
let r = TokenizerRegistry::load_from_config(&cfg()).unwrap();
let t = r.get("tiny").unwrap();
// Sanity-check that the fixture still tokenises `😀` the way we
// expect; if upstream changes this we want a loud failure here.
let full = adapter::encode(&t, "😀").unwrap();
assert_eq!(
full,
vec![47249, 222],
"fixture tokenisation drift: '😀' no longer encodes to [47249, 222]"
);
// Feed only the first token — its bytes are the leading 3 of a
// 4-byte UTF-8 codepoint, which is incomplete.
let s = adapter::decode_complete(&t, &full[..1], false).unwrap();
// We pin the exact output: the lossy decoder folds the 3 leading
// bytes into a single U+FFFD. Anything else (empty string, Err, or
// the original bytes) would be a regression.
assert_eq!(s, "\u{FFFD}");
}
/// Concurrent encode against one shared `Arc<Tokenizer>`. Pins that the
/// registry's `Arc<Tokenizer>` is `Send + Sync` and that
/// `dynamo_tokenizers::Tokenizer::encode` can be called concurrently
/// without interior mutability hazards. A regression that wraps
/// `Tokenizer` in `RefCell` / `!Sync` data would fail to compile;
/// a regression that introduces non-thread-safe internal caches
/// would surface as one of the tasks returning wrong ids (caught by
/// the per-task assertion against the sequentially-computed
/// reference).
///
/// Uses a multi-thread runtime + `JoinSet` so the 10 tasks really do
/// run in parallel on distinct worker threads — a single-thread
/// runtime wouldn't exercise the `Sync` contract.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn tokenizer_supports_concurrent_encode() {
use tokio::task::JoinSet;
let r = TokenizerRegistry::load_from_config(&cfg()).unwrap();
let t = r.get("tiny").unwrap();
// Build the reference sequentially — what each task should return.
let inputs: Vec<String> = (0..10).map(|i| format!("hello {i}")).collect();
let expected: Vec<Vec<u32>> = inputs
.iter()
.map(|s| adapter::encode(&t, s).unwrap())
.collect();
let mut set = JoinSet::new();
for (i, text) in inputs.into_iter().enumerate() {
let shared = Arc::clone(&t);
set.spawn(async move {
let ids = adapter::encode(&shared, &text).expect("concurrent encode must not fail");
(i, ids)
});
}
let mut got: Vec<Option<Vec<u32>>> = vec![None; expected.len()];
while let Some(joined) = set.join_next().await {
let (i, ids) = joined.expect("task panicked");
got[i] = Some(ids);
}
for (i, ids) in got.into_iter().enumerate() {
let ids = ids.unwrap_or_else(|| panic!("task {i} did not record a result"));
assert_eq!(
ids, expected[i],
"concurrent encode produced wrong tokens for task {i}; \
sign of a non-thread-safe internal cache regression"
);
}
}
#[test]
fn missing_file_errors() {
let mut c = cfg();
c.models[0].tokenizer_path = "/nonexistent.json".into();
let err = TokenizerRegistry::load_from_config(&c).unwrap_err();
assert!(err.to_string().to_lowercase().contains("tokenizer"));
}
}
@@ -0,0 +1,556 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Single-shot `/server_info` introspection for newly-discovered workers.
//!
//! Combines what used to be two separate round-trips (the worker
//! manager's `served_model_name` fetch and `KvEventIndex::add_worker`'s
//! `fetch_event_config`) into one HTTP request. The result is dispatched
//! by the manager: registry consumes `served_model_name`, the optional
//! `KvEventIndex` consumes the resolved `EventConfig`.
//!
//! # Failure semantics
//!
//! `fetch` is **infallible** — any error (network, non-2xx, JSON parse,
//! invalid worker URL) is logged at `warn!` and returns an empty
//! `ServerInfo` so the caller can register the worker with empty
//! `model_ids` and no kv-events attachment. Workers that need accuracy
//! around publisher availability use `kv_events::discovery::fetch_event_config`
//! directly (it returns `Result<Option<EventConfig>>`); the manager
//! intentionally doesn't.
use std::time::Duration;
use serde::Deserialize;
use tracing::warn;
use url::Url;
use crate::policies::kv_events::EventConfig;
/// Default timeout for `/server_info`. Conservative for a small JSON
/// payload served by SGLang's HTTP server.
const SERVER_INFO_TIMEOUT: Duration = Duration::from_secs(2);
/// Retry budget for transient `/server_info` failures (connect/timeout/5xx).
/// 4xx + JSON-parse errors short-circuit — they're authoritative.
/// EndpointSlice can flip ready=true before the worker's HTTP server is
/// actually serving; without retry, that race lands a worker in the
/// registry with empty model_ids and chat dispatch fails with 502.
const FETCH_MAX_ATTEMPTS: u32 = 3;
const FETCH_BACKOFF_BASE: Duration = Duration::from_millis(100);
/// Resolved per-worker bootstrap state.
///
/// `served_model_name` populates the registry; `event_config` is handed
/// to `KvEventIndex::add_worker` (skipping its own fetch);
/// `disaggregation_role` lets the worker manager override the discovery
/// backend's PD classification (and fill in `WorkerSpec.bootstrap_port`
/// for prefill workers) — see `manager::register_one`.
#[derive(Debug, Clone, Default)]
pub struct ServerInfo {
pub served_model_name: Option<String>,
pub event_config: Option<EventConfig>,
pub disaggregation_role: Option<DisaggregationRole>,
}
/// PD classification derived from a worker's `/server_info` response.
///
/// `Some(_)` means the worker self-disclosed its role and we should trust
/// it over the discovery backend's classification. `None` (the
/// `ServerInfo::disaggregation_role` value, not a variant here) means the
/// worker didn't tell us — older SGLang, missing field, or a partial
/// response — and the backend's classification wins. See the resolution
/// table in `resolve_disaggregation_role`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisaggregationRole {
Plain,
Prefill { bootstrap_port: u16 },
Decode,
}
/// Performs the single `/server_info` round-trip and projects the
/// response into both halves of `ServerInfo`. Cheap to clone — wraps a
/// `reqwest::Client` (which is internally `Arc`-backed).
#[derive(Clone)]
pub struct WorkerIntrospector {
client: reqwest::Client,
}
impl WorkerIntrospector {
/// Build with a private `reqwest::Client` carrying the supplied
/// request timeout. Production callers pass `SERVER_INFO_TIMEOUT`
/// via `default()`; tests may pass shorter timeouts.
pub fn new(timeout: Duration) -> Self {
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.expect("introspector http client builds");
Self { client }
}
/// Reuse a caller-owned `reqwest::Client`. Useful in tests that want
/// to assert request shape via a fake HTTP transport, or to share a
/// connection pool across components.
pub fn with_client(client: reqwest::Client) -> Self {
Self { client }
}
/// Fetch `/server_info` for the worker. Never returns an error:
/// any failure is logged at `warn!` and yields a default
/// `ServerInfo` with both halves `None`. Callers register the
/// worker with empty model IDs and no event subscription on the
/// failure path; future re-discovery will retry.
///
/// Transient failures (network errors, 5xx) are retried up to
/// `FETCH_MAX_ATTEMPTS` times with exponential backoff. 4xx
/// responses and JSON-parse errors short-circuit immediately —
/// the worker answered authoritatively, retrying won't help.
pub async fn fetch(&self, worker_url: &str) -> ServerInfo {
let server_info_url = format!("{}/server_info", worker_url.trim_end_matches('/'));
let parsed = match Self::fetch_with_retry(&self.client, &server_info_url, worker_url).await
{
Some(p) => p,
None => return ServerInfo::default(),
};
let served_model_name = match parsed.served_model_name {
Some(name) if !name.is_empty() => Some(name),
Some(_) => {
warn!(
worker_url = %worker_url,
"introspect: /server_info has empty `served_model_name`; registering worker with empty model_ids"
);
None
}
None => None,
};
let event_config = parsed
.kv_events
.map(|block| resolve_event_config(block, worker_url));
let disaggregation_role = resolve_disaggregation_role(
parsed.disaggregation_mode.as_deref(),
parsed.disaggregation_bootstrap_port,
worker_url,
);
ServerInfo {
served_model_name,
event_config,
disaggregation_role,
}
}
/// Issue the `/server_info` GET with bounded retry on transient
/// errors. Returns `Some(body)` on success, `None` after exhausting
/// retries (the caller falls back to default `ServerInfo`).
async fn fetch_with_retry(
client: &reqwest::Client,
server_info_url: &str,
worker_url: &str,
) -> Option<ServerInfoBody> {
let mut delay = FETCH_BACKOFF_BASE;
for attempt in 1..=FETCH_MAX_ATTEMPTS {
match client.get(server_info_url).send().await {
Err(e) => {
warn!(
worker_url = %worker_url,
attempt,
error = %e,
"introspect: /server_info request failed; will retry"
);
}
Ok(resp) if resp.status().is_server_error() => {
warn!(
worker_url = %worker_url,
attempt,
status = %resp.status(),
"introspect: /server_info returned 5xx; will retry"
);
}
Ok(resp) if !resp.status().is_success() => {
warn!(
worker_url = %worker_url,
status = %resp.status(),
"introspect: /server_info returned non-2xx; registering worker with empty model_ids"
);
return None;
}
Ok(resp) => match resp.json::<ServerInfoBody>().await {
Ok(body) => return Some(body),
Err(e) => {
warn!(
worker_url = %worker_url,
error = %e,
"introspect: /server_info JSON parse failed; registering worker with empty model_ids"
);
return None;
}
},
}
if attempt < FETCH_MAX_ATTEMPTS {
tokio::time::sleep(delay).await;
delay *= 2;
}
}
warn!(
worker_url = %worker_url,
attempts = FETCH_MAX_ATTEMPTS,
"introspect: /server_info failed after retries; registering worker with empty model_ids"
);
None
}
}
/// Map the two `disaggregation_*` fields from `/server_info` into a
/// `DisaggregationRole`. Returns `None` when the worker hasn't told us
/// enough to be useful — the caller treats that as "defer to the
/// discovery backend's classification" instead of forcing Plain, which
/// preserves backwards compatibility with SGLang versions that predate
/// the field.
///
/// Resolution table:
///
/// | `disaggregation_mode` | `disaggregation_bootstrap_port` | Result |
/// |------------------------------|----------------------------------|-------------------------------------|
/// | `None` (older SGLang) | _any_ | `None` — defer to backend |
/// | `Some("null")` | _any_ | `Some(Plain)` |
/// | `Some("prefill")` | `Some(p)` | `Some(Prefill { bootstrap_port: p })` |
/// | `Some("prefill")` | `None` | warn + `None` — defer to backend |
/// | `Some("decode")` | _any_ | `Some(Decode)` |
/// | `Some(other)` | _any_ | warn + `None` |
fn resolve_disaggregation_role(
mode: Option<&str>,
bootstrap_port: Option<u16>,
worker_url: &str,
) -> Option<DisaggregationRole> {
match mode {
None => None,
Some("null") => Some(DisaggregationRole::Plain),
Some("prefill") => match bootstrap_port {
Some(p) => Some(DisaggregationRole::Prefill { bootstrap_port: p }),
None => {
warn!(
worker_url = %worker_url,
"introspect: /server_info reports disaggregation_mode=\"prefill\" but \
disaggregation_bootstrap_port is missing; deferring to the discovery \
backend's classification"
);
None
}
},
Some("decode") => Some(DisaggregationRole::Decode),
Some(other) => {
warn!(
worker_url = %worker_url,
disaggregation_mode = %other,
"introspect: /server_info has unknown disaggregation_mode value; \
deferring to the discovery backend's classification"
);
None
}
}
}
impl Default for WorkerIntrospector {
fn default() -> Self {
Self::new(SERVER_INFO_TIMEOUT)
}
}
/// Substitute a wildcard bind host (`*`, `0.0.0.0`, `::`, `[::]`) with
/// the host parsed from the worker URL — the gateway has to connect to
/// a routable address. An unparsable worker URL leaves the host
/// unchanged: the subsequent ZMQ connect will fail visibly with the
/// wildcard literal, which is the same observable failure mode that
/// would occur today if the bind/connect were skipped.
pub(crate) fn resolve_event_config(block: KvEventsBlock, worker_url: &str) -> EventConfig {
let host = if matches!(
block.endpoint_host.as_str(),
"*" | "0.0.0.0" | "::" | "[::]"
) {
match Url::parse(worker_url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_owned()))
{
Some(h) => h,
None => {
warn!(
worker_url = %worker_url,
"introspect: cannot parse worker_url for wildcard substitution; keeping advertised host"
);
block.endpoint_host
}
}
} else {
block.endpoint_host
};
EventConfig {
host,
port_base: block.endpoint_port_base,
topic: block.topic,
block_size: block.block_size,
dp_size: block.dp_size,
}
}
/// Projection of `/server_info` used by the introspector. Every field is
/// `#[serde(default)]` so a worker that exposes only some of them still
/// deserialises; downstream callers handle `None` as "absent".
#[derive(Debug, Default, Deserialize)]
struct ServerInfoBody {
#[serde(default)]
served_model_name: Option<String>,
#[serde(default)]
kv_events: Option<KvEventsBlock>,
/// Carries the value of `ServerArgs.disaggregation_mode`
/// (`"null"` | `"prefill"` | `"decode"`). Absent on older SGLang
/// versions that predate the field.
#[serde(default)]
disaggregation_mode: Option<String>,
/// `ServerArgs.disaggregation_bootstrap_port`. Meaningful only when
/// `disaggregation_mode == "prefill"`; the prefill server's
/// bootstrap server binds to exactly this port (no internal offset).
#[serde(default)]
disaggregation_bootstrap_port: Option<u16>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct KvEventsBlock {
// Forward-compatibility: the only publisher implementation
// supported on the gateway side is ZMQ. Keeping the field optional
// means a future SGLang that adds a non-ZMQ publisher string won't
// fail deserialize; the resulting subscriber will still try to open
// a ZMQ connection and fail visibly.
#[allow(dead_code)]
#[serde(default)]
publisher: Option<String>,
pub endpoint_host: String,
pub endpoint_port_base: u16,
#[serde(default)]
pub topic: String,
pub block_size: u32,
pub dp_size: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{routing::get, Json, Router};
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
async fn spawn_fake_worker(body: Value) -> (String, oneshot::Sender<()>) {
let body = Arc::new(body);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new().route(
"/server_info",
get(move || {
let body = body.clone();
async move { Json((*body).clone()) }
}),
);
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
(format!("http://127.0.0.1:{port}"), tx)
}
fn fast_introspector() -> WorkerIntrospector {
WorkerIntrospector::new(Duration::from_millis(500))
}
#[tokio::test]
async fn fetch_returns_both_served_model_name_and_event_config() {
let (url, _shutdown) = spawn_fake_worker(json!({
"served_model_name": "Qwen3-0.6B",
"kv_events": {
"publisher": "zmq",
"endpoint_host": "10.1.2.3",
"endpoint_port_base": 6000,
"topic": "kv",
"block_size": 64,
"dp_size": 2,
}
}))
.await;
let got = fast_introspector().fetch(&url).await;
assert_eq!(got.served_model_name.as_deref(), Some("Qwen3-0.6B"));
let cfg = got.event_config.expect("kv_events present");
assert_eq!(cfg.host, "10.1.2.3");
assert_eq!(cfg.port_base, 6000);
assert_eq!(cfg.topic, "kv");
assert_eq!(cfg.block_size, 64);
assert_eq!(cfg.dp_size, 2);
}
#[tokio::test]
async fn fetch_substitutes_wildcard_host() {
let (url, _shutdown) = spawn_fake_worker(json!({
"served_model_name": "m",
"kv_events": {
"publisher": "zmq",
"endpoint_host": "*",
"endpoint_port_base": 5557,
"topic": "kv",
"block_size": 64,
"dp_size": 1,
}
}))
.await;
let got = fast_introspector().fetch(&url).await;
let cfg = got.event_config.expect("kv_events present");
assert_eq!(cfg.host, "127.0.0.1");
}
#[tokio::test]
async fn fetch_returns_empty_on_connection_refused() {
// Port 1 is reserved; bind a temp listener to reserve a free
// port then drop it so the connect fails fast.
let temp = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = temp.local_addr().unwrap().port();
drop(temp);
let url = format!("http://127.0.0.1:{port}");
let got = fast_introspector().fetch(&url).await;
assert!(
got.served_model_name.is_none(),
"served_model_name must be None on connection refused"
);
assert!(
got.event_config.is_none(),
"event_config must be None on connection refused"
);
}
#[tokio::test]
async fn fetch_only_served_model_name_when_kv_events_absent() {
let (url, _shutdown) = spawn_fake_worker(json!({"served_model_name": "m"})).await;
let got = fast_introspector().fetch(&url).await;
assert_eq!(got.served_model_name.as_deref(), Some("m"));
assert!(got.event_config.is_none());
}
#[tokio::test]
async fn fetch_only_event_config_when_served_model_name_absent() {
let (url, _shutdown) = spawn_fake_worker(json!({
"kv_events": {
"publisher": "zmq",
"endpoint_host": "127.0.0.1",
"endpoint_port_base": 5557,
"topic": "",
"block_size": 64,
"dp_size": 1,
}
}))
.await;
let got = fast_introspector().fetch(&url).await;
assert!(got.served_model_name.is_none());
let cfg = got.event_config.expect("kv_events present");
assert_eq!(cfg.port_base, 5557);
}
/// `disaggregation_mode=prefill` + a bootstrap port → manager should
/// see the worker as a prefill peer with the supplied port. This is
/// the happy path that lets PD-on-K8s skip pod annotations entirely.
#[tokio::test]
async fn fetch_resolves_prefill_role_with_bootstrap_port() {
let (url, _shutdown) = spawn_fake_worker(json!({
"served_model_name": "m",
"disaggregation_mode": "prefill",
"disaggregation_bootstrap_port": 8998,
}))
.await;
let got = fast_introspector().fetch(&url).await;
assert_eq!(
got.disaggregation_role,
Some(DisaggregationRole::Prefill {
bootstrap_port: 8998
}),
);
}
/// `disaggregation_mode=decode` → role is Decode regardless of any
/// bootstrap-port field value (decode workers don't bind one).
#[tokio::test]
async fn fetch_resolves_decode_role() {
let (url, _shutdown) = spawn_fake_worker(json!({
"served_model_name": "m",
"disaggregation_mode": "decode",
}))
.await;
let got = fast_introspector().fetch(&url).await;
assert_eq!(got.disaggregation_role, Some(DisaggregationRole::Decode));
}
/// `disaggregation_mode="null"` is SGLang's explicit "not
/// disaggregated" value — we trust it and force the worker to Plain
/// even if the discovery backend mistakenly classified it as
/// prefill/decode.
#[tokio::test]
async fn fetch_resolves_plain_role_when_mode_is_null() {
let (url, _shutdown) = spawn_fake_worker(json!({
"served_model_name": "m",
"disaggregation_mode": "null",
}))
.await;
let got = fast_introspector().fetch(&url).await;
assert_eq!(got.disaggregation_role, Some(DisaggregationRole::Plain));
}
/// Partial data (`prefill` mode with no bootstrap port) returns
/// `None` so the manager keeps the discovery backend's
/// classification. The alternative — forcing Plain — would silently
/// demote a misconfigured prefill worker to plain dispatch.
#[tokio::test]
async fn fetch_defers_to_backend_when_prefill_mode_lacks_bootstrap_port() {
let (url, _shutdown) = spawn_fake_worker(json!({
"served_model_name": "m",
"disaggregation_mode": "prefill",
}))
.await;
let got = fast_introspector().fetch(&url).await;
assert!(
got.disaggregation_role.is_none(),
"prefill with no bootstrap port must defer to backend, got {:?}",
got.disaggregation_role,
);
}
/// Older SGLang doesn't expose `disaggregation_mode`. The
/// introspector must not invent a classification — the discovery
/// backend's seed (K8s labels, static-urls Plain default) still
/// drives mode for these workers.
#[tokio::test]
async fn fetch_defers_to_backend_when_mode_field_is_absent() {
let (url, _shutdown) = spawn_fake_worker(json!({
"served_model_name": "m",
}))
.await;
let got = fast_introspector().fetch(&url).await;
assert!(got.disaggregation_role.is_none());
}
/// Unknown `disaggregation_mode` value (future SGLang adds a new
/// disaggregation flavor, network garbled the field, etc.) → defer
/// to backend rather than guessing.
#[tokio::test]
async fn fetch_defers_to_backend_when_mode_is_unrecognized() {
let (url, _shutdown) = spawn_fake_worker(json!({
"served_model_name": "m",
"disaggregation_mode": "encode_only",
"disaggregation_bootstrap_port": 8998,
}))
.await;
let got = fast_introspector().fetch(&url).await;
assert!(got.disaggregation_role.is_none());
}
}
@@ -0,0 +1,765 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use crate::config::Config;
use crate::discovery::{DiscoveryEvent, ModelId, WorkerId, WorkerMode, WorkerSpec};
use crate::health::circuit_breaker::CircuitBreakerConfig;
use crate::policies::active_load::ActiveLoadRegistry;
use crate::policies::kv_events::KvEventIndex;
use crate::workers::introspect::{DisaggregationRole, WorkerIntrospector};
use crate::workers::WorkerRegistry;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
/// Resolve the circuit-breaker config for all model IDs carried by a spec.
///
/// Workers may serve multiple models; we use the config of the **first** model
/// that has an explicit CB config, falling back to `None` (default config).
fn cb_config_for_spec(spec: &WorkerSpec, cfg: &Config) -> Option<CircuitBreakerConfig> {
for model_id in &spec.model_ids {
if let Some(mc) = cfg.models.iter().find(|m| m.id == model_id.0) {
if let Some(cbc) = &mc.circuit_breaker {
return Some(CircuitBreakerConfig {
threshold: cbc.threshold,
cool_down: Duration::from_secs(cbc.cool_down_secs),
});
}
}
}
None
}
pub async fn run(rx: mpsc::Receiver<DiscoveryEvent>, registry: Arc<WorkerRegistry>) {
run_with_config(rx, registry, None, None, None).await;
}
/// Run the worker manager, optionally honoring per-model circuit-breaker
/// configuration from `cfg`, an optional KV-event index that is notified
/// on every worker add / remove, and an optional active-load registry
/// that is asked to forget per-worker counters on `Removed`.
///
/// When `kv_index` is `None` the cache-aware-zmq path is disabled
/// (selection falls through to the non-cache-aware policies); when
/// `active_load` is `None` the active-load bookkeeping is not pruned
/// on worker removal (leaks one `WorkerCounters` slot per departed
/// worker — fine for tests, but production passes `Some(...)`); when
/// `cfg` is `None` the default CB config is used for every worker
/// (threshold = 3).
///
/// Uses the default HTTP client (2-second timeout) for `/server_info`
/// introspection. Tests that want a tighter timeout call
/// [`run_with_introspector`] directly.
pub async fn run_with_config(
rx: mpsc::Receiver<DiscoveryEvent>,
registry: Arc<WorkerRegistry>,
cfg: Option<Arc<Config>>,
kv_index: Option<Arc<KvEventIndex>>,
active_load: Option<Arc<ActiveLoadRegistry>>,
) {
run_with_introspector(
rx,
registry,
cfg,
kv_index,
active_load,
Arc::new(WorkerIntrospector::default()),
)
.await
}
/// Internal entry point used by tests so they can supply a custom
/// [`WorkerIntrospector`] (e.g. shorter timeout, fake transport).
/// Production callers use [`run_with_config`].
///
/// # Concurrency model
///
/// - **Added(spec):** spawned onto a `tokio::task` so multiple workers
/// can fetch `/server_info` and register concurrently. Without this,
/// a burst of N workers would serialize N × `SERVER_INFO_TIMEOUT`
/// worth of registration latency on the event loop.
/// - **Removed / ModeChanged:** processed sequentially on the event
/// loop, but first **await** any in-flight `Added` task for the same
/// id so the mutation observes the post-Added registry state.
/// Without this await, a `Removed` queued while `Added` is still
/// fetching would no-op (registry empty), then the deferred Added
/// write would leak the worker indefinitely.
pub async fn run_with_introspector(
mut rx: mpsc::Receiver<DiscoveryEvent>,
registry: Arc<WorkerRegistry>,
cfg: Option<Arc<Config>>,
kv_index: Option<Arc<KvEventIndex>>,
active_load: Option<Arc<ActiveLoadRegistry>>,
introspector: Arc<WorkerIntrospector>,
) {
// In-flight `Added` registrations, keyed by worker id. Subsequent
// `Removed` / `ModeChanged` events for the same id `await` the
// handle so they observe the registry write the spawned task is
// about to perform. Entries are removed on completion (Added's
// own task drops the slot before returning).
let mut pending: HashMap<WorkerId, JoinHandle<()>> = HashMap::new();
while let Some(event) = rx.recv().await {
// Opportunistically reap handles whose tasks have already
// completed so the map doesn't grow without bound under steady-
// state churn. This is O(map.len()) per event but the map only
// holds in-flight Added events (typically << total workers).
pending.retain(|_, h| !h.is_finished());
match event {
DiscoveryEvent::Added(spec) => {
tracing::info!("discovery: +worker {} ({:?})", spec.id, spec.mode);
let id = spec.id.clone();
// If a previous Added for the same id is still in-flight,
// drain it first so the upsert observes a consistent
// pre-state (and so the new spawn doesn't race with the
// old).
if let Some(prev) = pending.remove(&id) {
let _ = prev.await;
}
let registry_t = registry.clone();
let cfg_t = cfg.clone();
let kv_index_t = kv_index.clone();
let introspector_t = introspector.clone();
let handle = tokio::spawn(async move {
register_one(spec, registry_t, cfg_t, kv_index_t, introspector_t).await;
});
pending.insert(id, handle);
}
DiscoveryEvent::Removed { id } => {
tracing::info!("discovery: -worker {id}");
if let Some(prev) = pending.remove(&id) {
// Wait for the matching Added to finish its registry
// write so the Removed observes (and clears) it.
let _ = prev.await;
}
// Look up the URL before dropping the entry so the
// KV-event index can clear its per-(url, dp_rank) state.
let worker_url = registry.get(&id).map(|w| w.url.clone());
registry.remove(&id);
match (&kv_index, worker_url) {
(Some(idx), Some(url)) => {
idx.remove_worker(&url).await;
}
(Some(_), None) => {
// Registry didn't know this worker but kv-events
// is enabled — duplicate Removed or out-of-order
// event. KvEventIndex state for this id (if any)
// leaks until process shutdown; log so it's
// detectable.
tracing::warn!(
id = %id,
"discovery: Removed without a known URL; kv-events state (if any) not cleared",
);
}
(None, _) => {}
}
// Drop the active-load per-worker counters slot.
// Idempotent on the registry side, so we call it
// unconditionally — a Removed for an unknown worker
// (duplicate event) is a no-op. In-flight guards
// pointing at this id are NOT invalidated; their drop
// still removes the per-request entry cleanly, but the
// per-worker counters slot will not be re-created
// (selectors no longer see the worker, so no new
// requests can register against it).
if let Some(al) = &active_load {
al.forget_worker(&id);
}
}
DiscoveryEvent::ModeChanged { id, mode } => {
if let Some(prev) = pending.remove(&id) {
// Same rationale as Removed: wait for the registry
// write so the mode flip lands on the new entry.
let _ = prev.await;
}
// Mutate mode in place — preserves active_requests counter
// (in-flight LoadGuards stay valid) and CircuitBreaker state
// (open/half-open survives PD role flips).
//
// workers_for_mode filters at query time via w.mode(), so no
// secondary index needs updating.
match registry.get(&id) {
Some(w) => {
tracing::info!("discovery: ~worker {id} mode→{mode:?}");
w.set_mode(mode);
}
None => {
tracing::warn!(
id = %id,
mode = ?mode,
"discovery: ModeChanged for unknown worker — out-of-order event from backend",
);
}
}
}
}
}
// Drain any still-running registration tasks so callers `await`ing
// the manager handle (tests, shutdown paths) see all registry
// mutations land before the future resolves.
for (_, h) in pending.drain() {
let _ = h.await;
}
}
/// Onboard a single worker: introspect once, then dispatch the result
/// to the registry and (if enabled) the KV-event index. Failure of any
/// step is logged inside the call chain; we still register the worker
/// with empty `model_ids` so the rest of the proxy plane treats it as
/// reachable.
async fn register_one(
mut spec: WorkerSpec,
registry: Arc<WorkerRegistry>,
cfg: Option<Arc<Config>>,
kv_index: Option<Arc<KvEventIndex>>,
introspector: Arc<WorkerIntrospector>,
) {
let worker_url = spec.url.clone();
let info = introspector.fetch(&worker_url).await;
if let Some(name) = info.served_model_name {
spec.model_ids = vec![ModelId(name)];
}
// Trust `/server_info` over the discovery backend when the worker
// self-disclosed its PD role: the server's own ServerArgs is the
// authoritative source for `disaggregation_mode` and
// `disaggregation_bootstrap_port`. The backend's mode (from K8s
// labels, static-urls seed, etc.) was a best-guess seed; if the
// server says it's actually a prefill peer on port 8998, that wins.
// `None` here means the worker didn't tell us — keep the backend's
// classification (older SGLang without the field, partial response,
// unknown mode value, etc.).
if let Some(role) = info.disaggregation_role {
let (new_mode, new_port) = match role {
DisaggregationRole::Plain => (WorkerMode::Plain, None),
DisaggregationRole::Prefill { bootstrap_port } => {
(WorkerMode::Prefill, Some(bootstrap_port))
}
DisaggregationRole::Decode => (WorkerMode::Decode, None),
};
if (new_mode, new_port) != (spec.mode, spec.bootstrap_port) {
tracing::info!(
worker_url = %worker_url,
backend_mode = ?spec.mode,
resolved_mode = ?new_mode,
backend_bootstrap_port = ?spec.bootstrap_port,
resolved_bootstrap_port = ?new_port,
"/server_info overrode discovery-backend classification",
);
spec.mode = new_mode;
spec.bootstrap_port = new_port;
}
}
let cb = cfg.as_ref().and_then(|c| cb_config_for_spec(&spec, c));
if let Err(e) = registry.add_with_cb(spec, cb) {
// Mixed PD + plain on the same model is rejected at registration
// time. Log loudly so the operator notices the conflicting
// worker — the alternative (silently dropping into either pool)
// makes the resolver surface the wrong 5xx code under partial
// outages. Skip the kv_index hook too: a worker we didn't add
// shouldn't drive cache-aware tree state.
tracing::error!(
worker_url = %worker_url,
error = %e,
"worker manager: refused to register worker due to mixed PD/plain configuration",
);
return;
}
if let Some(idx) = kv_index {
// Pass the pre-resolved EventConfig so the KvEventIndex does
// not issue a second `/server_info` round-trip.
idx.add_worker(&worker_url, info.event_config).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
ActiveLoadConfig, CircuitBreakerConfig as RawCbConfig, DiscoveryBackend, DiscoveryConfig,
ModelConfig, PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
};
use crate::discovery::{WorkerId, WorkerMode};
use axum::{routing::get, Json, Router};
use serde_json::{json, Value};
use std::num::NonZeroU32;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
fn cfg_with_model_cb(id: &str, threshold: u32, cool_down_secs: u64) -> Config {
Config {
server: ServerConfig {
host: "0".into(),
port: 0,
},
observability: Default::default(),
models: vec![ModelConfig {
id: id.into(),
tokenizer_path: "/tmp/x".into(),
policy: PolicyKind::RoundRobin,
circuit_breaker: Some(RawCbConfig {
threshold: NonZeroU32::new(threshold).unwrap(),
cool_down_secs,
}),
cache_aware: None,
}],
discovery: DiscoveryConfig {
backend: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
urls: vec!["http://test:30000".into()],
}),
},
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
}
}
#[test]
fn cb_config_for_spec_carries_threshold_and_cool_down() {
let cfg = cfg_with_model_cb("m", 5, 60);
let spec = WorkerSpec {
id: WorkerId("w".into()),
url: "http://x".into(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("m".into())],
bootstrap_port: None,
};
let cb = cb_config_for_spec(&spec, &cfg).expect("model has cb config");
assert_eq!(cb.threshold.get(), 5);
assert_eq!(cb.cool_down, Duration::from_secs(60));
}
/// Helper: spawn a tiny fake worker that returns the supplied JSON body
/// on `GET /server_info`. Returns the worker URL + a shutdown channel.
async fn spawn_fake_server_info_worker(body: Value) -> (String, oneshot::Sender<()>) {
let body = Arc::new(body);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new().route(
"/server_info",
get(move || {
let body = body.clone();
async move { Json((*body).clone()) }
}),
);
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
(format!("http://127.0.0.1:{port}"), tx)
}
/// Reserve a TCP port and immediately drop the listener so subsequent
/// connection attempts during the test fail fast with
/// ConnectionRefused.
fn unused_port() -> u16 {
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
}
fn fast_introspector() -> Arc<WorkerIntrospector> {
Arc::new(WorkerIntrospector::new(Duration::from_millis(500)))
}
/// `/server_info` returns `served_model_name` => the registry entry
/// carries that as a single `ModelId`.
#[tokio::test]
async fn manager_resolves_model_id_from_server_info() {
let (worker_url, _shutdown) =
spawn_fake_server_info_worker(json!({"served_model_name": "Qwen3-0.6B"})).await;
let registry = Arc::new(WorkerRegistry::default());
let (tx, rx) = mpsc::channel::<DiscoveryEvent>(8);
let manager_handle = tokio::spawn(run_with_introspector(
rx,
registry.clone(),
None,
None,
None,
fast_introspector(),
));
let spec = WorkerSpec {
id: WorkerId("w-1".into()),
url: worker_url,
mode: WorkerMode::Plain,
model_ids: Vec::new(),
bootstrap_port: None,
};
tx.send(DiscoveryEvent::Added(spec.clone())).await.unwrap();
let registered = tokio::time::timeout(Duration::from_secs(2), async {
loop {
if let Some(w) = registry.get(&spec.id) {
if w.model_ids.iter().any(|m| m.0 == "Qwen3-0.6B") {
return true;
}
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(registered.is_ok(), "manager did not resolve model id");
drop(tx);
let _ = manager_handle.await;
}
/// Worker unreachable (connection refused) => registry still has the
/// worker, with `model_ids` empty. No panic; manager continues running.
#[tokio::test]
async fn manager_registers_with_empty_model_ids_when_server_info_unreachable() {
let port = unused_port();
let worker_url = format!("http://127.0.0.1:{port}");
let registry = Arc::new(WorkerRegistry::default());
let (tx, rx) = mpsc::channel::<DiscoveryEvent>(8);
let manager_handle = tokio::spawn(run_with_introspector(
rx,
registry.clone(),
None,
None,
None,
fast_introspector(),
));
let spec = WorkerSpec {
id: WorkerId("w-2".into()),
url: worker_url,
mode: WorkerMode::Plain,
model_ids: Vec::new(),
bootstrap_port: None,
};
tx.send(DiscoveryEvent::Added(spec.clone())).await.unwrap();
let registered = tokio::time::timeout(Duration::from_secs(2), async {
loop {
if let Some(w) = registry.get(&spec.id) {
return w.model_ids.is_empty();
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(
matches!(registered, Ok(true)),
"manager must register worker with empty model_ids when /server_info fails: {registered:?}"
);
drop(tx);
let _ = manager_handle.await;
}
/// `/server_info` returns a JSON object without `served_model_name`
/// (or with the empty string): manager logs a warn and registers the
/// worker with empty `model_ids`.
#[tokio::test]
async fn manager_registers_with_empty_model_ids_when_served_model_name_missing() {
let (no_field_url, _no_field_shutdown) =
spawn_fake_server_info_worker(json!({"other_field": "value"})).await;
let (empty_url, _empty_shutdown) =
spawn_fake_server_info_worker(json!({"served_model_name": ""})).await;
let registry = Arc::new(WorkerRegistry::default());
let (tx, rx) = mpsc::channel::<DiscoveryEvent>(8);
let manager_handle = tokio::spawn(run_with_introspector(
rx,
registry.clone(),
None,
None,
None,
fast_introspector(),
));
for (id, url) in [("w-no-field", no_field_url), ("w-empty", empty_url)] {
let spec = WorkerSpec {
id: WorkerId(id.into()),
url,
mode: WorkerMode::Plain,
model_ids: Vec::new(),
bootstrap_port: None,
};
tx.send(DiscoveryEvent::Added(spec.clone())).await.unwrap();
let registered = tokio::time::timeout(Duration::from_secs(2), async {
loop {
if let Some(w) = registry.get(&spec.id) {
return w.model_ids.is_empty();
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(
matches!(registered, Ok(true)),
"manager must register worker {id} with empty model_ids when served_model_name is missing/empty: {registered:?}"
);
}
drop(tx);
let _ = manager_handle.await;
}
/// End-to-end wiring smoke test: spin up a fake worker, run the
/// manager with a real `KvEventIndex` against that worker URL, and
/// verify both `Added` and `Removed` propagate through to the
/// index's internal worker map.
///
/// The fake worker advertises a `kv_events` block in `/server_info`,
/// so the manager → KvEventIndex → discovery → registry path is
/// exercised end-to-end. The ZMQ connect itself targets an unused
/// port and fails (port is closed), but the *index-level* state still
/// records the worker — which is exactly the invariant under test:
/// `add_worker` registers the worker URL in `KvEventIndex.workers`
/// even when the per-rank SUB connect fails.
#[tokio::test]
async fn manager_drives_kv_index_lifecycle() {
use tokio::time::timeout;
// The fake worker advertises both `kv_events` for KvEventIndex AND
// `served_model_name` so the worker-manager HTTP introspection
// also resolves a model id.
let body = json!({
"served_model_name": "m",
"kv_events": {
"publisher": "zmq",
"endpoint_host": "127.0.0.1",
"endpoint_port_base": 60000,
"topic": "",
"block_size": 64,
"dp_size": 1,
}
});
let (worker_url, _shutdown) = spawn_fake_server_info_worker(body).await;
let registry = Arc::new(WorkerRegistry::default());
let kv_index = KvEventIndex::new();
let (tx, rx) = mpsc::channel::<DiscoveryEvent>(8);
let manager_handle = tokio::spawn(run_with_config(
rx,
registry.clone(),
None,
Some(kv_index.clone()),
None,
));
let spec = WorkerSpec {
id: WorkerId("w-1".into()),
url: worker_url.clone(),
mode: WorkerMode::Plain,
model_ids: Vec::new(),
bootstrap_port: None,
};
tx.send(DiscoveryEvent::Added(spec.clone())).await.unwrap();
// Wait until the manager has both registered the worker AND
// resolved /server_info — bound the wait so a hang surfaces.
let added = timeout(Duration::from_secs(2), async {
loop {
if registry.get(&spec.id).is_some() && kv_index.known_worker_count() == 1 {
return true;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(added.is_ok(), "manager failed to propagate Added");
tx.send(DiscoveryEvent::Removed {
id: spec.id.clone(),
})
.await
.unwrap();
let removed = timeout(Duration::from_secs(2), async {
loop {
if registry.get(&spec.id).is_none() && kv_index.known_worker_count() == 0 {
return true;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(removed.is_ok(), "manager failed to propagate Removed");
drop(tx);
let _ = manager_handle.await;
kv_index.shutdown().await;
}
/// `Removed` for an unknown id with kv-events enabled must not panic.
/// The kv_index has no entry for that id either, so it must remain
/// empty after the no-op.
#[tokio::test]
async fn manager_removed_unknown_id_is_noop() {
use tokio::time::sleep;
let registry = Arc::new(WorkerRegistry::default());
let kv_index = KvEventIndex::new();
let (tx, rx) = mpsc::channel::<DiscoveryEvent>(8);
let manager_handle = tokio::spawn(run_with_config(
rx,
registry.clone(),
None,
Some(kv_index.clone()),
None,
));
tx.send(DiscoveryEvent::Removed {
id: WorkerId("never-added".into()),
})
.await
.unwrap();
// Let the manager process the event.
sleep(Duration::from_millis(50)).await;
assert_eq!(kv_index.known_worker_count(), 0);
drop(tx);
let _ = manager_handle.await;
kv_index.shutdown().await;
}
/// Task B: `DiscoveryEvent::Removed` calls
/// `ActiveLoadRegistry::forget_worker` so the per-worker counters
/// slot is reaped. Without this, a long-lived cluster with worker
/// churn would leak one `WorkerCounters` entry per departed worker.
#[tokio::test]
async fn manager_calls_active_load_forget_on_removed() {
use tokio::time::timeout;
// Fake worker is needed so the introspection step succeeds and
// the Removed path observes a known URL — same shape as the
// existing `manager_drives_kv_index_lifecycle` test.
let (worker_url, _shutdown) =
spawn_fake_server_info_worker(json!({"served_model_name": "m"})).await;
let registry = Arc::new(WorkerRegistry::default());
let active_load = ActiveLoadRegistry::with_defaults();
let (tx, rx) = mpsc::channel::<DiscoveryEvent>(8);
let manager_handle = tokio::spawn(run_with_introspector(
rx,
registry.clone(),
None,
None,
Some(Arc::clone(&active_load)),
fast_introspector(),
));
let id = WorkerId("w-1".into());
let spec = WorkerSpec {
id: id.clone(),
url: worker_url,
mode: WorkerMode::Plain,
model_ids: Vec::new(),
bootstrap_port: None,
};
tx.send(DiscoveryEvent::Added(spec.clone())).await.unwrap();
// Wait for the manager to land the registry write so the
// subsequent register/forget round trip exercises a live slot.
let added = timeout(Duration::from_secs(2), async {
loop {
if registry.get(&id).is_some() {
return true;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(added.is_ok(), "manager failed to register worker");
// Mint a guard to force the active-load registry to create a
// per-worker counters slot for this id.
let _g = active_load.register(id.clone(), "test://", 10, 1);
assert!(active_load.is_known(&id));
// Now drive the Removed event and assert the counters slot is
// gone. We tear down the guard last so the request entry is
// exercised on the post-forget path.
tx.send(DiscoveryEvent::Removed { id: id.clone() })
.await
.unwrap();
let removed = timeout(Duration::from_secs(2), async {
loop {
if !active_load.is_known(&id) && registry.get(&id).is_none() {
return true;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(
removed.is_ok(),
"manager must call active_load.forget_worker on Removed",
);
drop(tx);
let _ = manager_handle.await;
}
/// Discovery backend emits a `Plain` worker with no bootstrap port,
/// but `/server_info` says `disaggregation_mode="prefill"` with
/// `disaggregation_bootstrap_port=8998`. The manager must trust
/// `/server_info` and register the worker as Prefill with the
/// disclosed port — this is the load-bearing assertion for
/// PD-on-K8s, where the K8s backend always emits Plain + None for
/// `bootstrap_port` and the manager has to recover the role from
/// the worker's self-disclosure.
#[tokio::test]
async fn manager_overrides_backend_classification_from_server_info() {
let (worker_url, _shutdown) = spawn_fake_server_info_worker(json!({
"served_model_name": "m",
"disaggregation_mode": "prefill",
"disaggregation_bootstrap_port": 8998,
}))
.await;
let registry = Arc::new(WorkerRegistry::default());
let (tx, rx) = mpsc::channel::<DiscoveryEvent>(8);
let manager_handle = tokio::spawn(run_with_introspector(
rx,
registry.clone(),
None,
None,
None,
fast_introspector(),
));
// Backend says Plain + None — the shape the K8s backend always
// emits today.
let spec = WorkerSpec {
id: WorkerId("w-prefill".into()),
url: worker_url,
mode: WorkerMode::Plain,
model_ids: Vec::new(),
bootstrap_port: None,
};
tx.send(DiscoveryEvent::Added(spec.clone())).await.unwrap();
let resolved = tokio::time::timeout(Duration::from_secs(2), async {
loop {
if let Some(w) = registry.get(&spec.id) {
if w.mode() == WorkerMode::Prefill && w.bootstrap_port() == Some(8998) {
return true;
}
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(
resolved.is_ok(),
"manager must apply /server_info disaggregation_role override; \
expected mode=Prefill bootstrap_port=Some(8998), got {:?}",
registry
.get(&spec.id)
.map(|w| (w.mode(), w.bootstrap_port())),
);
drop(tx);
let _ = manager_handle.await;
}
}
@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
pub mod introspect;
pub mod manager;
pub mod registry;
pub mod worker;
pub use introspect::{ServerInfo, WorkerIntrospector};
pub use registry::WorkerRegistry;
pub use worker::LoadGuard;
pub use worker::Worker;
@@ -0,0 +1,550 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use crate::health::circuit_breaker::CircuitBreakerConfig;
use crate::workers::worker::Worker;
use dashmap::DashMap;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
/// Reason a [`WorkerRegistry::add`] call refused the spec.
#[derive(Debug, Clone, thiserror::Error)]
pub enum AddWorkerError {
/// The spec's mode (plain vs prefill/decode) conflicts with workers
/// already registered for one of its `model_ids`. The router does
/// not support mixed PD + plain pools on a single model: the
/// resolver derives the PD-vs-plain shape from the registered
/// workers, and a mixed pool would silently degrade to whichever
/// shape happens to be healthy when the other is breaker-open,
/// surfacing the wrong error code to clients.
#[error(
"worker {worker:?} for model {model:?} would mix PD ({pd_mode}) with plain workers on \
the same model — sgl-router does not support mixed pools. Use one of: only Plain \
workers, or only Prefill+Decode workers."
)]
MixedPdAndPlain {
worker: WorkerId,
model: ModelId,
/// The role of the *incoming* worker that triggered the conflict
/// (the *existing* worker has the opposite role).
pd_mode: &'static str,
},
}
#[derive(Debug, Default)]
pub struct WorkerRegistry {
by_id: DashMap<WorkerId, Arc<Worker>>,
by_model: DashMap<ModelId, HashSet<WorkerId>>,
/// Serializes the validate→insert section of `add_with_cb` so the
/// `MixedPdAndPlain` check is atomic with the subsequent write. Two
/// concurrent registrations from `manager::register_one` for the
/// same model with conflicting modes could otherwise both observe
/// an empty pool and both insert, leaving the registry in a mixed
/// state — the exact corruption the check is meant to prevent.
/// Reads (`workers_for`, `get`, `len`, …) stay lock-free against
/// the underlying DashMaps; only writes through `add_with_cb` /
/// `remove` take this lock so contention is bounded by registry
/// mutation rate (worker-discovery events), not request rate.
write: Mutex<()>,
}
impl WorkerRegistry {
pub fn add(&self, spec: WorkerSpec) -> Result<(), AddWorkerError> {
self.add_with_cb(spec, None)
}
/// Add a worker, optionally supplying a circuit-breaker config.
/// Pass `None` to use the circuit-breaker default (threshold = 3).
///
/// Re-adding an existing `WorkerId` is an upsert: the prior entry's
/// `by_model` memberships are cleared first so a model that the new
/// spec no longer serves stops resolving to this worker. Without the
/// pre-removal step a worker whose model set shrank would still appear
/// in `workers_for(<dropped model>)` because `by_id.get(...)` would
/// return the new worker via the stale model→id index.
///
/// Returns [`AddWorkerError::MixedPdAndPlain`] when adding the spec
/// would mix PD (prefill/decode) workers with plain workers on the
/// same model. The conflict is detected against the *existing*
/// registry state — re-adding the same worker id is fine (the prior
/// entry is removed first), and adding a worker whose own
/// `model_ids` are all unmixed is fine even if other models in the
/// process have a mix of modes.
///
/// On rejection the registry is **not** mutated. If the rejected
/// spec carries an id that already has an entry, the prior entry
/// stays put — it's the caller's responsibility to decide whether
/// to evict it (and, importantly, to also clean up sidecar state
/// in `KvEventIndex` / `ActiveLoadRegistry` if so). Doing that
/// cleanup here would leak orphan state into those sidecars when
/// a caller actually wanted to keep the prior entry.
pub fn add_with_cb(
&self,
spec: WorkerSpec,
cb: Option<CircuitBreakerConfig>,
) -> Result<(), AddWorkerError> {
let incoming_mode = spec.mode;
// Hold the write lock for the entire validate→insert sequence.
// Without it, two concurrent callers for conflicting modes on
// the same model can both see an empty pool and both proceed
// to insert, producing the mixed PD+plain state the check
// exists to prevent.
//
// Mutex poisoning here means a previous writer panicked while
// holding the lock — and since the critical section spans
// `remove_locked` + several `by_model` updates + the final
// `by_id.insert`, a panic mid-section can leave the registry
// with a partial entry across the two DashMaps. Recovering via
// `PoisonError::into_inner` would silently continue against
// that half-written state; propagating the panic instead
// surfaces the corruption to `manager::register_one`'s task
// and ultimately trips `supervise_critical_tasks → mark_unready`
// so the pod stops taking traffic. That's the right outcome.
let _guard = self.write.lock().unwrap();
// Validate against existing workers BEFORE we mutate. Re-adding
// the same id is an upsert; pretend the prior entry is gone for
// the purposes of the check (otherwise an upsert of an unmixed
// worker would self-conflict if its current entry already
// serves the model).
for model in &spec.model_ids {
for existing in self.workers_for(model) {
if existing.id == spec.id {
continue;
}
if modes_are_mixed(incoming_mode, existing.mode()) {
return Err(AddWorkerError::MixedPdAndPlain {
worker: spec.id,
model: model.clone(),
pd_mode: mode_name(incoming_mode),
});
}
}
}
let w = Arc::new(Worker::with_cb_config(spec, cb));
let id = w.id.clone();
self.remove_locked(&id);
for m in &w.model_ids {
self.by_model
.entry(m.clone())
.or_default()
.insert(id.clone());
}
self.by_id.insert(id, w);
Ok(())
}
pub fn remove(&self, id: &WorkerId) {
// Mirror `add_with_cb`'s write-lock acquisition so removals
// don't race with concurrent adds (a stale `workers_for` snapshot
// could otherwise let an add succeed against a peer that's
// about to be removed, or vice versa).
let _guard = self.write.lock().unwrap();
self.remove_locked(id);
}
/// Internal removal that assumes the write lock is already held.
/// Use this from any path that has acquired `self.write`.
fn remove_locked(&self, id: &WorkerId) {
if let Some((_, w)) = self.by_id.remove(id) {
for m in &w.model_ids {
if let Some(mut set) = self.by_model.get_mut(m) {
set.remove(id);
}
}
}
}
pub fn workers_for(&self, model: &ModelId) -> Vec<Arc<Worker>> {
self.by_model
.get(model)
.map(|ids| {
ids.iter()
.filter_map(|i| self.by_id.get(i).map(|w| Arc::clone(&w)))
.collect()
})
.unwrap_or_default()
}
pub fn healthy_workers_for(&self, model: &ModelId) -> Vec<Arc<Worker>> {
// Use `would_allow` (non-mutating) for filtering — `allow()` would
// claim a half-open probe slot for every enumerated candidate,
// starving the worker that the policy actually picks. The probe
// is claimed at dispatch time by `forward_*_to` in
// [`crate::proxy`].
self.workers_for(model)
.into_iter()
.filter(|w| w.breaker.would_allow())
.collect()
}
pub fn workers_for_mode(&self, model: &ModelId, mode: WorkerMode) -> Vec<Arc<Worker>> {
self.workers_for(model)
.into_iter()
.filter(|w| w.mode() == mode)
.collect()
}
pub fn len(&self) -> usize {
self.by_id.len()
}
pub fn is_empty(&self) -> bool {
self.by_id.is_empty()
}
pub fn get(&self, id: &WorkerId) -> Option<Arc<Worker>> {
self.by_id.get(id).map(|w| Arc::clone(&w))
}
}
/// `true` when the two modes can't coexist for the same model — i.e.
/// one is `Plain` and the other is `Prefill` or `Decode`.
fn modes_are_mixed(a: WorkerMode, b: WorkerMode) -> bool {
matches!(
(a, b),
(WorkerMode::Plain, WorkerMode::Prefill | WorkerMode::Decode)
| (WorkerMode::Prefill | WorkerMode::Decode, WorkerMode::Plain)
)
}
fn mode_name(m: WorkerMode) -> &'static str {
match m {
WorkerMode::Plain => "plain",
WorkerMode::Prefill => "prefill",
WorkerMode::Decode => "decode",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
fn spec(id: &str, mode: WorkerMode, models: &[&str]) -> WorkerSpec {
WorkerSpec {
id: WorkerId(id.into()),
url: format!("http://{id}:30000"),
mode,
model_ids: models.iter().map(|m| ModelId((*m).into())).collect(),
bootstrap_port: None,
}
}
#[test]
fn add_then_query_by_model() {
let r = WorkerRegistry::default();
let _ = r.add(spec("w1", WorkerMode::Plain, &["m1", "m2"]));
let _ = r.add(spec("w2", WorkerMode::Plain, &["m1"]));
let m1 = r.workers_for(&ModelId("m1".into()));
let m2 = r.workers_for(&ModelId("m2".into()));
let m_missing = r.workers_for(&ModelId("missing".into()));
assert_eq!(m1.len(), 2);
assert_eq!(m2.len(), 1);
assert!(m_missing.is_empty());
}
#[test]
fn remove_drops_from_all_models() {
let r = WorkerRegistry::default();
let _ = r.add(spec("w1", WorkerMode::Plain, &["m1", "m2"]));
r.remove(&WorkerId("w1".into()));
assert!(r.workers_for(&ModelId("m1".into())).is_empty());
assert!(r.workers_for(&ModelId("m2".into())).is_empty());
}
/// `healthy_workers_for` must drop workers whose breaker is Open.
/// An earlier version of this test asserted `healthy.len() == 2`
/// against two workers with untouched breakers — i.e., it pinned
/// only the no-op case (both Closed) and would have passed even if
/// `healthy_workers_for` ignored the breaker entirely and was a
/// thin alias for `workers_for`. Tripping one breaker and asserting
/// the surviving set excludes it is the actual contract.
#[test]
fn healthy_subset_filters_via_breaker() {
use crate::health::circuit_breaker::CircuitBreakerConfig;
use std::num::NonZeroU32;
use std::time::Duration;
let r = WorkerRegistry::default();
let _ = r.add_with_cb(spec("ok", WorkerMode::Plain, &["m"]), None);
// Give "bad" a threshold=1 breaker so a single record_failure
// flips it to Open.
let _ = r.add_with_cb(
spec("bad", WorkerMode::Plain, &["m"]),
Some(CircuitBreakerConfig {
threshold: NonZeroU32::new(1).unwrap(),
cool_down: Duration::from_secs(30),
}),
);
let bad = r.get(&WorkerId("bad".into())).expect("bad worker present");
bad.breaker.record_failure();
assert!(
!bad.breaker.would_allow(),
"sanity: threshold=1 + one failure must Open the breaker",
);
let healthy = r.healthy_workers_for(&ModelId("m".into()));
assert_eq!(
healthy.len(),
1,
"only the worker with a non-Open breaker should survive",
);
assert_eq!(healthy[0].id, WorkerId("ok".into()));
}
/// PD prefill/decode workers and plain workers cannot coexist on the
/// same model. The resolver bases its PD-vs-plain shape on registered
/// workers; mixing the two forces a fallback to whichever bucket
/// happens to be healthy when the other is breaker-open, surfacing
/// the wrong 5xx code (`no_healthy_workers` instead of
/// `no_prefill_workers_available`). Reject the conflicting add up
/// front so the operator sees the misconfiguration immediately.
#[test]
fn plain_then_pd_for_same_model_is_rejected() {
let r = WorkerRegistry::default();
assert!(r.add(spec("plain", WorkerMode::Plain, &["m"])).is_ok());
let err = r
.add(spec("p", WorkerMode::Prefill, &["m"]))
.expect_err("PD worker must be rejected when model already has Plain workers");
let msg = err.to_string();
assert!(
msg.contains("PD") && msg.contains("plain"),
"error must name both modes; got: {msg}"
);
// Existing plain worker survives the rejection.
assert_eq!(
r.workers_for_mode(&ModelId("m".into()), WorkerMode::Plain)
.len(),
1,
);
assert!(r
.workers_for_mode(&ModelId("m".into()), WorkerMode::Prefill)
.is_empty());
}
#[test]
fn pd_then_plain_for_same_model_is_rejected() {
let r = WorkerRegistry::default();
assert!(r.add(spec("p", WorkerMode::Prefill, &["m"])).is_ok());
assert!(r.add(spec("d", WorkerMode::Decode, &["m"])).is_ok());
let err = r
.add(spec("plain", WorkerMode::Plain, &["m"]))
.expect_err("plain worker must be rejected when model already has PD workers");
let msg = err.to_string();
assert!(
msg.contains("PD") && msg.contains("plain"),
"error must name both modes; got: {msg}"
);
}
#[test]
fn plain_only_pool_admits_more_plain_workers() {
let r = WorkerRegistry::default();
assert!(r.add(spec("a", WorkerMode::Plain, &["m"])).is_ok());
assert!(r.add(spec("b", WorkerMode::Plain, &["m"])).is_ok());
assert_eq!(
r.workers_for_mode(&ModelId("m".into()), WorkerMode::Plain)
.len(),
2,
);
}
#[test]
fn pd_pool_admits_more_pd_workers_in_both_roles() {
let r = WorkerRegistry::default();
assert!(r.add(spec("p1", WorkerMode::Prefill, &["m"])).is_ok());
assert!(r.add(spec("p2", WorkerMode::Prefill, &["m"])).is_ok());
assert!(r.add(spec("d1", WorkerMode::Decode, &["m"])).is_ok());
assert_eq!(
r.workers_for_mode(&ModelId("m".into()), WorkerMode::Prefill)
.len(),
2,
);
assert_eq!(
r.workers_for_mode(&ModelId("m".into()), WorkerMode::Decode)
.len(),
1,
);
}
/// Re-adding a worker with a shrunken `model_ids` must drop the worker
/// from the models it no longer serves. The earlier implementation
/// only updated `by_id`, leaving the stale `by_model` entries pointing
/// at the new worker.
#[test]
fn re_add_with_shrunken_model_set_drops_stale_indexes() {
let r = WorkerRegistry::default();
let _ = r.add(spec("w1", WorkerMode::Plain, &["m1", "m2"]));
assert_eq!(r.workers_for(&ModelId("m2".into())).len(), 1);
let _ = r.add(spec("w1", WorkerMode::Plain, &["m1"]));
assert_eq!(
r.workers_for(&ModelId("m2".into())).len(),
0,
"w1 no longer serves m2 after re-add"
);
assert_eq!(
r.workers_for(&ModelId("m1".into())).len(),
1,
"w1 still serves m1"
);
}
/// Re-adding the same id with a different mode reflects in
/// `workers_for_mode`.
#[test]
fn re_add_with_different_mode_updates_mode_filter() {
let r = WorkerRegistry::default();
let _ = r.add(spec("w1", WorkerMode::Prefill, &["m"]));
let _ = r.add(spec("w1", WorkerMode::Decode, &["m"]));
assert_eq!(
r.workers_for_mode(&ModelId("m".into()), WorkerMode::Prefill)
.len(),
0,
);
assert_eq!(
r.workers_for_mode(&ModelId("m".into()), WorkerMode::Decode)
.len(),
1,
);
}
/// On a rejected upsert with `MixedPdAndPlain`, the registry is
/// **not** mutated — the prior entry for the rejected id stays
/// put. Eviction (with the matching `KvEventIndex` /
/// `ActiveLoadRegistry` cleanup) is the manager's responsibility;
/// doing it here would leak orphan state in those sidecars.
#[test]
fn upsert_rejected_with_mixed_modes_leaves_registry_unchanged() {
let r = WorkerRegistry::default();
// Healthy PD pool on model m.
let _ = r.add(spec("p", WorkerMode::Prefill, &["m"]));
let _ = r.add(spec("d", WorkerMode::Decode, &["m"]));
// Re-add "p" with Plain mode — discovery has reported a role flip.
// The decode worker "d" is still on m, so validation rejects.
let err = r
.add(spec("p", WorkerMode::Plain, &["m"]))
.expect_err("plain upsert must be rejected when peer decode worker remains");
assert!(err.to_string().contains("plain"), "got: {err}");
// Prior "p" entry survives (still Prefill). The registry
// deliberately does NOT auto-evict on rejection — eviction
// (and the matching sidecar cleanup) is the caller's call.
let p = r
.get(&WorkerId("p".into()))
.expect("prior entry must remain — caller owns the cleanup");
assert_eq!(p.mode(), WorkerMode::Prefill);
assert_eq!(
r.workers_for_mode(&ModelId("m".into()), WorkerMode::Prefill)
.len(),
1,
);
assert_eq!(
r.workers_for_mode(&ModelId("m".into()), WorkerMode::Decode)
.len(),
1,
);
}
/// A *new* (not-yet-registered) worker rejected with `MixedPdAndPlain`
/// must not affect the pool. Combined with the upsert test above,
/// this pins that rejection never mutates registry state on its own.
#[test]
fn rejected_new_add_leaves_pool_untouched() {
let r = WorkerRegistry::default();
let _ = r.add(spec("plain", WorkerMode::Plain, &["m"]));
let err = r
.add(spec("p", WorkerMode::Prefill, &["m"]))
.expect_err("PD worker must be rejected against existing plain pool");
assert!(err.to_string().contains("plain"), "got: {err}");
assert_eq!(
r.workers_for_mode(&ModelId("m".into()), WorkerMode::Plain)
.len(),
1,
);
assert!(r.get(&WorkerId("p".into())).is_none());
}
/// Concurrent registrations from `manager::register_one` race against
/// each other: each spawned task calls `add_with_cb` in parallel, and
/// the validate-then-insert sequence inside that method is **not**
/// atomic. Two threads adding workers of conflicting modes for the
/// same model can both pass the existing-workers check (each sees an
/// empty pool) and both proceed to insert, leaving the registry in a
/// mixed PD+plain state — exactly the corruption the
/// `MixedPdAndPlain` check is supposed to prevent.
///
/// Invariant we pin: for every model, the resulting pool must be
/// EITHER all-Plain OR all-PD, never a mix. We don't care which
/// "winner" mode is selected — the racing manager already serialises
/// per-WorkerId so it's the cross-id case that needs atomicity here.
#[test]
fn concurrent_conflicting_modes_never_produce_mixed_pool() {
use std::sync::Arc;
use std::sync::Barrier;
use std::thread;
// All threads target one shared model so every `add_with_cb`
// racer contends on the same `workers_for("m")` slot — that's
// what makes the read-validate-write window of one thread
// overlap with another's mutate. An earlier variant spread the
// load across 4 models and did not reliably reproduce the bug
// (per-slot contention was diluted to ~N/4 threads). 200
// iterations × 16 threads triggers the race within the first
// few iterations on the author's machine; post-fix the
// invariant must hold across every iteration.
const N_THREADS: usize = 16;
const ITER: usize = 200;
for iter in 0..ITER {
let r = Arc::new(WorkerRegistry::default());
let barrier = Arc::new(Barrier::new(N_THREADS));
let mut handles = Vec::with_capacity(N_THREADS);
for t in 0..N_THREADS {
let r = Arc::clone(&r);
let barrier = Arc::clone(&barrier);
// Half the threads register Plain workers, half register
// Prefill, all on the same model. With a non-atomic
// validate→write inside `add_with_cb`, a Plain and a
// Prefill thread both see an empty pool and both
// succeed.
let mode = if t % 2 == 0 {
WorkerMode::Plain
} else {
WorkerMode::Prefill
};
let id = format!("iter{iter}-t{t}");
handles.push(thread::spawn(move || {
barrier.wait();
let _ = r.add(spec(&id, mode, &["m"]));
}));
}
for h in handles {
h.join().unwrap();
}
// Invariant check: model is single-mode.
let model = ModelId("m".into());
let plain = r.workers_for_mode(&model, WorkerMode::Plain).len();
let prefill = r.workers_for_mode(&model, WorkerMode::Prefill).len();
let decode = r.workers_for_mode(&model, WorkerMode::Decode).len();
let pd = prefill + decode;
assert!(
plain == 0 || pd == 0,
"iter {iter}: registry holds a mixed pool — \
plain={plain}, prefill={prefill}, decode={decode}. \
The MixedPdAndPlain check in `add_with_cb` is not atomic \
across concurrent callers.",
);
// Sanity: the first thread to take the lock must succeed
// (no peer exists yet). Defends against a degenerate "fix"
// that satisfies the single-mode invariant by silently
// rejecting every add.
assert!(
plain + pd >= 1,
"iter {iter}: no workers were registered — \
the lock or mixed-mode check is starving every caller.",
);
}
}
}
@@ -0,0 +1,298 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use crate::discovery::{ModelId, WorkerId, WorkerMode};
use crate::health::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
use std::sync::Arc;
/// Parse a host from a worker URL. Matches SMG's `worker_builder.rs`
/// fallback chain: parse as-is, retry with `http://` prefix if missing,
/// fall back to `"localhost"` if both fail. The fallback is defensive —
/// discovery code should never emit an unparsable URL — but a panic
/// here would crash the whole router on a single bad config entry.
fn parse_bootstrap_host(url: &str) -> String {
if let Ok(parsed) = url::Url::parse(url) {
if let Some(h) = parsed.host_str() {
return h.to_string();
}
}
if !url.contains("://") {
if let Ok(parsed) = url::Url::parse(&format!("http://{url}")) {
if let Some(h) = parsed.host_str() {
return h.to_string();
}
}
}
tracing::warn!(
worker_url = %url,
"Failed to parse worker URL for bootstrap_host; defaulting to 'localhost'"
);
"localhost".to_string()
}
/// RAII guard that increments `active_requests` on construction and
/// decrements on drop. Obtain via [`Worker::load_guard`].
///
/// `#[must_use]`: a statement-form call like `worker.load_guard();` would
/// drop the guard on the same line, so the counter would never see the
/// in-flight request. The compile-time warning catches that misuse.
#[must_use = "LoadGuard must be held for the request's lifetime; dropping it immediately decrements active_requests"]
pub struct LoadGuard {
counter: Arc<AtomicUsize>,
}
impl LoadGuard {
pub(crate) fn new(counter: Arc<AtomicUsize>) -> Self {
counter.fetch_add(1, Ordering::Relaxed);
Self { counter }
}
}
impl Drop for LoadGuard {
fn drop(&mut self) {
self.counter.fetch_sub(1, Ordering::Relaxed);
}
}
impl WorkerMode {
fn as_u8(self) -> u8 {
match self {
WorkerMode::Plain => 0,
WorkerMode::Prefill => 1,
WorkerMode::Decode => 2,
}
}
/// Inverse of [`Self::as_u8`]. The only writers of the underlying
/// `AtomicU8` are `as_u8`-derived values, so any out-of-range byte
/// indicates memory corruption or a stale store from an
/// incompatible build — fail loudly rather than silently mislabel
/// the worker as `Decode`.
fn from_u8(v: u8) -> Self {
match v {
0 => WorkerMode::Plain,
1 => WorkerMode::Prefill,
2 => WorkerMode::Decode,
other => unreachable!("invalid WorkerMode discriminant {other}"),
}
}
}
pub struct Worker {
pub id: WorkerId,
pub url: String,
/// Interior-mutable mode so `ModeChanged` can update in place without
/// dropping the Worker (which would reset `active_requests` + breaker).
mode: AtomicU8,
pub model_ids: Vec<ModelId>,
pub breaker: Arc<CircuitBreaker>,
pub active_requests: Arc<AtomicUsize>,
/// Hostname parsed from `url` at construction time and cached.
/// Used as the `bootstrap_host` field on PD-disagg requests so the
/// prefill engine can match incoming KV-transfer requests from
/// decode peers. Falls back to `"localhost"` if the URL fails to
/// parse — a misconfigured worker will fail the prefill request
/// downstream rather than panic here.
bootstrap_host: String,
/// SGLang bootstrap server port for prefill workers (`None` for
/// decode and plain). Set via `--disaggregation-bootstrap-port` at
/// worker startup; carried from `WorkerSpec`.
bootstrap_port: Option<u16>,
}
impl Worker {
pub fn new(spec: crate::discovery::WorkerSpec) -> Self {
Self::with_cb_config(spec, None)
}
/// Construct a worker with an explicit circuit-breaker configuration.
/// Pass `None` to use the default config (threshold = 3, cool_down = 30 s).
pub fn with_cb_config(
spec: crate::discovery::WorkerSpec,
cb: Option<CircuitBreakerConfig>,
) -> Self {
let breaker = match cb {
Some(cfg) => Arc::new(CircuitBreaker::with_config(cfg)),
None => Arc::new(CircuitBreaker::new()),
};
let bootstrap_host = parse_bootstrap_host(&spec.url);
Self {
id: spec.id,
url: spec.url,
mode: AtomicU8::new(spec.mode.as_u8()),
model_ids: spec.model_ids,
breaker,
active_requests: Arc::new(AtomicUsize::new(0)),
bootstrap_host,
bootstrap_port: spec.bootstrap_port,
}
}
/// Hostname carried on PD-disagg request bodies as `bootstrap_host`.
pub fn bootstrap_host(&self) -> &str {
&self.bootstrap_host
}
/// SGLang bootstrap server port. `None` for decode / plain workers.
pub fn bootstrap_port(&self) -> Option<u16> {
self.bootstrap_port
}
/// Returns the current [`WorkerMode`] of this worker.
///
/// Uses `Relaxed` ordering: mode changes are rare discovery events and do
/// not need to synchronise with any other memory access.
pub fn mode(&self) -> WorkerMode {
WorkerMode::from_u8(self.mode.load(Ordering::Relaxed))
}
/// Update the worker's mode in place.
///
/// Preserves `active_requests` and `breaker` state — the same `Arc<Worker>`
/// identity survives the mode transition.
pub fn set_mode(&self, m: WorkerMode) {
self.mode.store(m.as_u8(), Ordering::Relaxed);
}
pub fn active_load(&self) -> usize {
self.active_requests.load(Ordering::Relaxed)
}
/// Returns a RAII guard that increments `active_requests` now and
/// decrements when the guard is dropped.
pub fn load_guard(&self) -> LoadGuard {
LoadGuard::new(self.active_requests.clone())
}
}
impl std::fmt::Debug for Worker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Worker")
.field("id", &self.id)
.field("url", &self.url)
.field("mode", &self.mode())
.field("active_load", &self.active_load())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
#[test]
fn load_guard_increments_and_decrements() {
let w = Worker::new(WorkerSpec {
id: WorkerId("w".into()),
url: "http://x".into(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("m".into())],
bootstrap_port: None,
});
assert_eq!(w.active_load(), 0);
let g = w.load_guard();
assert_eq!(w.active_load(), 1);
let g2 = w.load_guard();
assert_eq!(w.active_load(), 2);
drop(g);
assert_eq!(w.active_load(), 1);
drop(g2);
assert_eq!(w.active_load(), 0);
}
#[test]
fn mode_accessor_round_trips_all_variants() {
for m in [WorkerMode::Plain, WorkerMode::Prefill, WorkerMode::Decode] {
let w = Worker::new(WorkerSpec {
id: WorkerId("w".into()),
url: "http://x".into(),
mode: m,
model_ids: vec![],
bootstrap_port: None,
});
assert_eq!(w.mode(), m);
}
}
#[test]
fn set_mode_updates_in_place() {
let w = Worker::new(WorkerSpec {
id: WorkerId("w".into()),
url: "http://x".into(),
mode: WorkerMode::Prefill,
model_ids: vec![],
bootstrap_port: None,
});
assert_eq!(w.mode(), WorkerMode::Prefill);
w.set_mode(WorkerMode::Decode);
assert_eq!(w.mode(), WorkerMode::Decode);
w.set_mode(WorkerMode::Plain);
assert_eq!(w.mode(), WorkerMode::Plain);
}
#[test]
fn bootstrap_port_returns_spec_value_for_prefill() {
let w = Worker::new(WorkerSpec {
id: WorkerId("p1".into()),
url: "http://10.0.0.1:30000".into(),
mode: WorkerMode::Prefill,
model_ids: vec![ModelId("m".into())],
bootstrap_port: Some(8997),
});
assert_eq!(w.bootstrap_port(), Some(8997));
}
#[test]
fn bootstrap_port_defaults_to_none() {
let w = Worker::new(WorkerSpec {
id: WorkerId("w".into()),
url: "http://10.0.0.1:30000".into(),
mode: WorkerMode::Plain,
model_ids: vec![],
bootstrap_port: None,
});
assert_eq!(w.bootstrap_port(), None);
}
#[test]
fn bootstrap_host_parses_ipv4_from_url() {
let w = Worker::new(WorkerSpec {
id: WorkerId("p1".into()),
url: "http://10.0.0.1:30000".into(),
mode: WorkerMode::Prefill,
model_ids: vec![],
bootstrap_port: Some(8997),
});
assert_eq!(w.bootstrap_host(), "10.0.0.1");
}
#[test]
fn bootstrap_host_parses_dns_name_from_url() {
let w = Worker::new(WorkerSpec {
id: WorkerId("p1".into()),
url: "http://prefill-0.svc.cluster.local:30000".into(),
mode: WorkerMode::Prefill,
model_ids: vec![],
bootstrap_port: Some(8997),
});
assert_eq!(w.bootstrap_host(), "prefill-0.svc.cluster.local");
}
#[test]
fn bootstrap_host_falls_back_to_localhost_for_unparsable_url() {
// An empty / invalid URL is not expected from discovery, but the
// accessor must return a usable string rather than panic — the
// prefill worker will reject the request body-side if the host
// really is unreachable.
let w = Worker::new(WorkerSpec {
id: WorkerId("p1".into()),
url: "not a url".into(),
mode: WorkerMode::Prefill,
model_ids: vec![],
bootstrap_port: Some(8997),
});
assert_eq!(w.bootstrap_host(), "localhost");
}
}
@@ -0,0 +1,4 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
mod static_urls;
@@ -0,0 +1,169 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use sgl_router::config::StaticUrlsDiscoveryConfig;
use sgl_router::discovery::{DiscoveryEvent, WorkerMode};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
#[tokio::test]
async fn emits_one_added_per_url_with_plain_seed() {
let cfg = StaticUrlsDiscoveryConfig {
urls: vec!["http://x:30000".into(), "http://y:30000".into()],
};
let (tx, mut rx) = mpsc::channel(16);
let _h = sgl_router::discovery::static_urls::spawn(cfg, tx)
.await
.unwrap();
let mut seen = std::collections::HashSet::new();
for _ in 0..2 {
let event = tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.unwrap()
.unwrap();
match event {
DiscoveryEvent::Added(spec) => {
// mode / model_ids / bootstrap_port are seeded as Plain/empty/None;
// the worker manager fills them from /server_info post-discovery.
assert_eq!(spec.mode, WorkerMode::Plain);
assert!(spec.model_ids.is_empty());
assert_eq!(spec.bootstrap_port, None);
// The URL doubles as the worker id — strings already have to
// be unique (rejected at config-load otherwise).
assert_eq!(spec.id.0, spec.url);
seen.insert(spec.url);
}
other => panic!("unexpected event: {other:?}"),
}
}
assert_eq!(
seen,
["http://x:30000".to_string(), "http://y:30000".to_string()].into(),
);
}
/// Single-URL list — the common dev deployment shape. The producer
/// emits exactly one event and then parks until the receiver is
/// dropped. Earlier versions exited as soon as fan-out completed,
/// which tripped `server::supervisor::supervise_critical_tasks` →
/// `mark_unready` → `/readyz` 503; the lib-side
/// `stays_alive_after_fanout_until_receiver_dropped` pins that
/// invariant in isolation, while this test pins the same contract
/// through the public `spawn` entry point used by the binary.
#[tokio::test]
async fn emits_one_event_and_parks_until_receiver_dropped() {
let cfg = StaticUrlsDiscoveryConfig {
urls: vec!["http://x:30000".into()],
};
let (tx, mut rx) = mpsc::channel(16);
let h = sgl_router::discovery::static_urls::spawn(cfg, tx)
.await
.unwrap();
let event = rx.recv().await.unwrap();
assert!(matches!(event, DiscoveryEvent::Added(_)));
assert!(rx.try_recv().is_err(), "exactly one event expected");
// Drop the receiver → producer's `tx.closed()` resolves → task
// exits cleanly.
drop(rx);
tokio::time::timeout(Duration::from_secs(2), h)
.await
.expect("static_urls task should exit after receiver is dropped")
.expect("join handle should not panic");
}
/// Spin up a fake worker that advertises
/// `disaggregation_mode = "prefill"` + `disaggregation_bootstrap_port`,
/// pipe it through `spawn_discovery` (StaticUrls backend) into
/// `manager::run_with_config`, and assert the worker lands in the
/// registry with `WorkerMode::Prefill` + the disclosed port.
///
/// This is the load-bearing end-to-end assertion for the refactor's
/// central claim — "prefill, decode, and plain workers can all appear
/// in the same `urls` list and end up classified correctly" — exercised
/// against the full discovery → introspect → registry pipeline rather
/// than just the in-isolation `register_one` unit test.
#[tokio::test]
async fn static_urls_pd_role_resolved_end_to_end() {
use axum::{routing::get, Json, Router};
use serde_json::json;
use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, DiscoveryConfig, ObservabilityConfig,
ProxyConfig, ServerConfig,
};
use sgl_router::discovery::{spawn_discovery, WorkerId};
use sgl_router::workers::{manager, WorkerRegistry};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
// Fake worker advertising a prefill role + bootstrap port.
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let url = format!("http://127.0.0.1:{port}");
let app = Router::new().route(
"/server_info",
get(|| async {
Json(json!({
"served_model_name": "tiny",
"disaggregation_mode": "prefill",
"disaggregation_bootstrap_port": 8998,
}))
}),
);
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
let cfg = Config {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
},
observability: ObservabilityConfig::default(),
models: vec![],
discovery: DiscoveryConfig {
backend: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
urls: vec![url.clone()],
}),
},
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
};
let registry = Arc::new(WorkerRegistry::default());
let (event_rx, _disc) = spawn_discovery(&cfg).await.unwrap();
let _mgr = tokio::spawn(manager::run_with_config(
event_rx,
registry.clone(),
Some(Arc::new(cfg)),
None,
None,
));
let id = WorkerId(url);
let resolved = tokio::time::timeout(Duration::from_secs(2), async {
loop {
if let Some(w) = registry.get(&id) {
if w.mode() == WorkerMode::Prefill && w.bootstrap_port() == Some(8998) {
return true;
}
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(
resolved.is_ok(),
"expected mode=Prefill bootstrap_port=Some(8998); got {:?}",
registry.get(&id).map(|w| (w.mode(), w.bootstrap_port()))
);
let _ = shutdown_tx.send(());
}
@@ -0,0 +1,163 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use sgl_router::health::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
use std::time::Duration;
fn cb() -> CircuitBreaker {
CircuitBreaker::with_config(CircuitBreakerConfig {
threshold: std::num::NonZeroU32::new(3).unwrap(),
cool_down: Duration::from_millis(100),
})
}
#[test]
fn starts_closed_and_allows() {
let b = cb();
assert!(b.allow());
}
#[test]
fn three_failures_open_the_breaker() {
let b = cb();
b.record_failure();
b.record_failure();
assert!(b.allow(), "still closed before threshold");
b.record_failure();
assert!(!b.allow(), "open after threshold reached");
}
#[test]
fn intermittent_success_resets_failure_count() {
let b = cb();
b.record_failure();
b.record_failure();
b.record_success(); // resets
b.record_failure();
b.record_failure();
assert!(b.allow(), "should still be closed (2 failures since reset)");
}
#[tokio::test(start_paused = true)]
async fn open_breaker_recovers_via_half_open() {
let b = cb();
b.record_failure();
b.record_failure();
b.record_failure();
assert!(!b.allow());
// Wait past cool_down.
tokio::time::advance(Duration::from_millis(150)).await;
// Half-open: allow one probe.
assert!(b.allow(), "half-open allows the probe");
// While half-open, further allow() calls should reject (only one probe in flight).
assert!(!b.allow(), "half-open rejects second probe");
// Probe succeeded.
b.record_success();
assert!(b.allow(), "closed after successful probe");
assert!(b.allow(), "stays closed");
}
#[tokio::test(start_paused = true)]
async fn half_open_failure_reopens() {
let b = cb();
b.record_failure();
b.record_failure();
b.record_failure();
tokio::time::advance(Duration::from_millis(150)).await;
assert!(b.allow(), "half-open admit");
b.record_failure();
// Back to Open.
assert!(!b.allow(), "back to open");
}
#[tokio::test(start_paused = true)]
async fn would_allow_is_non_mutating_past_cool_down() {
// `would_allow()` answers "would `allow()` return true right now?" without
// claiming a probe slot. Enumeration / filtering paths (e.g.
// `WorkerRegistry::healthy_workers_for`) call it to inspect breakers
// without disturbing state.
let b = cb();
b.record_failure();
b.record_failure();
b.record_failure();
assert!(!b.allow(), "open after threshold");
tokio::time::advance(Duration::from_millis(150)).await;
// Repeated would_allow() returns true and leaves state untouched.
assert!(b.would_allow());
assert!(b.would_allow());
assert!(b.would_allow());
// The first allow() claims the half-open probe.
assert!(b.allow(), "allow() admits the probe");
// The probe is in flight — subsequent allow() (and would_allow()) reject.
assert!(!b.allow(), "only one probe in flight");
assert!(!b.would_allow(), "would_allow() agrees: no slot available");
}
#[tokio::test(start_paused = true)]
async fn enumeration_then_dispatch_preserves_probe() {
// Regression for the bug where `healthy_workers_for` filtered with
// mutating `allow()`. Once would_allow() is the filter, an enumeration
// pass over many workers must not steal the probe slot from the one
// worker that actually gets dispatched to.
let b = cb();
b.record_failure();
b.record_failure();
b.record_failure();
tokio::time::advance(Duration::from_millis(150)).await;
// Imagine 3 workers; enumeration filters each with would_allow().
for _ in 0..3 {
assert!(b.would_allow(), "filter sees the worker as available");
}
// Now the policy picks ONE worker and dispatch claims the probe.
assert!(b.allow(), "dispatch on the picked worker succeeds");
}
#[test]
fn would_allow_in_closed_state_is_true_and_non_mutating() {
let b = cb();
for _ in 0..5 {
assert!(b.would_allow());
}
// And allow() should still work afterwards.
assert!(b.allow());
}
#[tokio::test(start_paused = true)]
async fn open_breaker_recovery_is_not_delayed_by_continued_failures() {
// Regression: previously, record_failure on an already-Open breaker
// refreshed opened_at, so a failure storm pinned the breaker open
// forever. Now the cool_down is measured from first-open.
let b = CircuitBreaker::with_config(CircuitBreakerConfig {
threshold: std::num::NonZeroU32::new(3).unwrap(),
cool_down: Duration::from_millis(100),
});
// Open it.
b.record_failure();
b.record_failure();
b.record_failure();
assert!(!b.allow(), "breaker should be open");
// Advance halfway through cool_down, then record more failures.
tokio::time::advance(Duration::from_millis(50)).await;
b.record_failure();
b.record_failure();
b.record_failure();
// Advance just past the original cool_down.
tokio::time::advance(Duration::from_millis(60)).await;
// We're past the original cool_down → HalfOpen.
assert!(
b.allow(),
"breaker should be half-open after cool_down from first-open"
);
}
@@ -0,0 +1,4 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
mod circuit_breaker;
@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Component-scope integration tests.
//!
//! Each submodule exercises a single library component (policy, registry,
//! discovery, health, tokenizer) via the crate's public API. None of these
//! tests spin up the full HTTP router; for those see `tests/proxy/`.
mod discovery;
mod health;
mod policies;
mod tokenizer;
mod workers;
@@ -0,0 +1,182 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! E2E test for the cache-aware-zmq policy.
//!
//! Drives a real ZMQ PUB socket → `KvEventIndex` subscriber pipeline →
//! `HashTree` → `CacheAwareZmqPolicy::select`. Verifies that an event
//! published by one worker's PUB causes subsequent selection to route
//! to that worker (cache-aware affinity).
//!
//! API constraint: the subscriber registry builds endpoints as
//! `tcp://{host}:{port_base + dp_rank}` where `port_base` is in the
//! per-worker `EventConfig`. Both mock workers below share
//! `127.0.0.1` as host, so both subscribe to the same PUB socket and
//! both end up indexed in the tree. The tiebreak (lowest active_load)
//! picks the worker we want; same shape as the SMG version of this
//! test.
use std::sync::Arc;
use std::time::Duration;
use zeromq::SocketSend;
use sgl_router::config::CacheAwareConfig;
use sgl_router::config::{ActiveLoadConfig, ProxyConfig};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::cache_aware_zmq::CacheAwareZmqPolicy;
use sgl_router::policies::kv_events::{compute_block_hashes, discovery::EventConfig, KvEventIndex};
use sgl_router::policies::{Policy, SelectionContext};
use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::Worker;
use super::zmq_helpers::{
build_multipart, encode_block_stored_event, encode_event_batch, make_pub_bound,
};
fn build_worker(url: &str, model: &str) -> Arc<Worker> {
Arc::new(Worker::new(WorkerSpec {
id: WorkerId(url.into()),
url: url.into(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId(model.into())],
bootstrap_port: None,
}))
}
/// E2E: real PUB socket publishes a `BlockStored` for worker A's
/// hash chain. The `CacheAwareZmqPolicy`'s shared `KvEventIndex`
/// receives it, applies it to the tree, and the next `select` call
/// picks worker A.
///
/// Both workers share `127.0.0.1` as host so both subscribers connect
/// to the same PUB and both get indexed under their KvWorkerIds — the
/// same shape as the SMG e2e test. We tie-break on min-load: worker B
/// is bumped above worker A so the matched-worker pick prefers A.
#[tokio::test]
async fn zmq_indexer_routes_to_publishing_worker_e2e() {
let model_id = ModelId("tiny".into());
// 1. Tokenizer registry — use the in-tree tiny fixture.
let cfg = sgl_router::config::Config {
server: sgl_router::config::ServerConfig {
host: "0".into(),
port: 0,
},
observability: Default::default(),
models: vec![sgl_router::config::ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: sgl_router::config::PolicyKind::CacheAwareZmq,
circuit_breaker: None,
cache_aware: None,
}],
discovery: sgl_router::config::DiscoveryConfig {
backend: sgl_router::config::DiscoveryBackend::StaticUrls(
sgl_router::config::StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
},
),
},
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
};
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
// 2. Bind a real PUB socket on an OS-assigned port.
let (mut pub_a, port) = make_pub_bound().await;
// 3. Compute the hash chain for the routing prompt.
let text = "hello world hello world hello world";
let tok = tokenizers.get("tiny").unwrap();
let token_ids = sgl_router::tokenizer::adapter::encode(&tok, text).unwrap();
let block_size = 4u32;
let hashes = compute_block_hashes(&token_ids, block_size as usize);
assert!(!hashes.is_empty(), "tiny tokenizer must yield ≥1 block");
// 4. Build the KvEventIndex + policy. The policy holds an
// Arc<HashTree> that the index also owns; events the index
// receives mutate the same tree the policy reads.
let kv_index = KvEventIndex::new();
// Mirror what `KvEventIndex::add_worker` would do in production: seed
// the oracle with the worker-reported page_size before any cache
// lookup happens. The integration path calls `add_worker` further
// down, but here we want the policy to know `block_size` immediately.
let block_size_oracle = kv_index.block_size_oracle();
block_size_oracle.try_set(block_size).unwrap();
let policy = CacheAwareZmqPolicy::new(
CacheAwareConfig {
cache_threshold: 0.0,
balance_abs_threshold: 32,
balance_rel_threshold: 1.1,
},
kv_index.tree(),
Arc::clone(&tokenizers),
block_size_oracle,
);
// 5. Register two workers. They share `127.0.0.1` so both
// subscribers connect to the same PUB; preresolved EventConfig
// points at the bound port.
let url_a = "http://127.0.0.1:30000";
let url_b = "http://127.0.0.1:30001";
let preresolved = EventConfig {
host: "127.0.0.1".to_string(),
port_base: port,
topic: String::new(),
block_size,
dp_size: 1,
};
kv_index.add_worker(url_a, Some(preresolved.clone())).await;
kv_index.add_worker(url_b, Some(preresolved)).await;
// SUB sockets take a moment to handshake. The polling loop below
// soaks up any extra latency; this is just a publish-before-SUB
// guard.
tokio::time::sleep(Duration::from_millis(150)).await;
// 6. Publish a BlockStored event for the routing prompt's chain.
let event_bytes = encode_block_stored_event(&hashes, None, &token_ids, block_size);
let payload = encode_event_batch(0.0, vec![event_bytes], Some(0));
pub_a
.send(build_multipart(1, payload))
.await
.expect("send block-stored event");
// 7. Bump worker B's load so the tie-break picks A among matched
// workers. The bump stays below balance_abs_threshold so the
// imbalance fast-path does not skip cache-aware selection.
// Bind the guards to a Vec held for the rest of the test scope
// so the counter stays > 0 through the polling loop.
let w_a = build_worker(url_a, "tiny");
let w_b = build_worker(url_b, "tiny");
let _b_load: Vec<_> = (0..3).map(|_| w_b.load_guard()).collect();
let workers = vec![Arc::clone(&w_a), Arc::clone(&w_b)];
// 8. Drive select until the event has been applied. The pipeline is
// asynchronous (publish → SUB recv → mpsc → pump → tree); a
// polling loop is less flaky than a fixed sleep.
let body = serde_json::to_vec(&serde_json::json!({"prompt": text})).unwrap();
let ctx = SelectionContext::new(&model_id, Some(&body));
let start = std::time::Instant::now();
let mut chose_a = false;
while start.elapsed() < Duration::from_secs(3) {
if let Some(w) = policy.select(&workers, &ctx) {
if w.url == url_a {
chose_a = true;
break;
}
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(
chose_a,
"policy did not route to publishing worker A within timeout",
);
// 9. Shutdown cleanly.
let r = tokio::time::timeout(Duration::from_secs(2), kv_index.shutdown()).await;
assert!(r.is_ok(), "kv_index shutdown should not hang");
}
@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Cross-implementation parity test for the KV-event block-hash algorithm.
//!
//! The Rust implementation at `src/policies/kv_events/hash.rs` must produce
//! the same i64 block hashes as SGLang's `radix_cache::RadixKey.hash_page`
//! followed by `hash_str_to_int64`. Hard-coded `cross_language_golden_*`
//! values inside `hash.rs` are correct but brittle: if either side's
//! algorithm changes, the comments don't get regenerated and the tests
//! pass with stale expectations.
//!
//! This test consumes a fixture produced by
//! `tests/scripts/generate_kv_events_hash_parity.py`, which replicates the
//! SGLang algorithm verbatim (see the script's docstring for authority
//! pointers). CI regenerates the fixture (see
//! `.github/workflows/pr-test-sgl-router.yml`) and diffs against the
//! committed file; this test asserts the Rust implementation matches
//! whatever fixture is checked in.
use serde::Deserialize;
use sgl_router::policies::kv_events::compute_block_hashes;
use std::path::PathBuf;
#[derive(Debug, Deserialize)]
struct ParityCase {
name: String,
tokens: Vec<u32>,
block_size: usize,
expected_i64_hashes: Vec<i64>,
}
fn fixture_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("kv_events_hash_parity.json")
}
fn load_cases() -> Vec<ParityCase> {
let path = fixture_path();
let bytes = std::fs::read(&path)
.unwrap_or_else(|e| panic!("read parity fixture {}: {e}", path.display()));
serde_json::from_slice(&bytes)
.unwrap_or_else(|e| panic!("decode parity fixture {}: {e}", path.display()))
}
#[test]
fn fixture_is_non_empty() {
let cases = load_cases();
assert!(
!cases.is_empty(),
"kv_events_hash_parity.json is empty — run \
tests/scripts/generate_kv_events_hash_parity.py",
);
}
/// Drives every case in the fixture through `compute_block_hashes` and
/// asserts equality with the Python-derived expectation.
#[test]
fn rust_block_hashes_match_python_radix_cache() {
for case in load_cases() {
// block_size of 0 is rejected by `compute_block_hashes` with a
// panic; the Python generator also rejects it. The fixture
// doesn't include a 0 case, so unwrap is safe.
let block_size = std::num::NonZeroUsize::new(case.block_size)
.unwrap_or_else(|| panic!("case {} has block_size=0 which is invalid", case.name));
let got = compute_block_hashes(&case.tokens, block_size.get());
assert_eq!(
got, case.expected_i64_hashes,
"case {}: tokens={:?} block_size={} — Rust produced {:?}, fixture says {:?}",
case.name, case.tokens, case.block_size, got, case.expected_i64_hashes,
);
}
}
@@ -0,0 +1,167 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Concurrent-mutation stress test for `HashTree`.
//!
//! The 19 inline tests in `policies::kv_events::tree` are all
//! single-threaded. Under production load, multiple worker subscribers
//! drive `insert` / `remove` / `clear_worker` against the same tree from
//! tokio worker threads while the chat handler simultaneously calls
//! `match_prefix` from many concurrent requests.
//!
//! The tree is documented as taking a write-lock for mutations and a
//! read-lock for `match_prefix`; this test exercises that contract under
//! heavy contention to catch:
//!
//! * Deadlocks between the reverse index and the arena's RwLock.
//! * Logical races where a removed worker still appears in the reverse
//! index (or vice versa).
//! * Panics from a node arena being mutated mid-read.
//!
//! After the storm settles, the tree must be self-consistent: every
//! worker that was fully cleared must be absent from every node's worker
//! set, and `node_count()` must converge to zero.
use std::sync::Arc;
use std::thread;
use sgl_router::policies::kv_events::{HashTree, KvWorkerId};
fn worker(i: usize) -> KvWorkerId {
KvWorkerId {
url: format!("http://w{i}:30000"),
dp_rank: 0,
}
}
/// 8 mutator threads × 200 ops + 4 reader threads × 500 match queries.
/// Each mutator inserts a chain, queries it, then clears the worker; the
/// invariant is that after every thread joins, the tree is empty (every
/// worker was cleared) and no thread panicked.
#[test]
fn tree_survives_concurrent_inserts_removes_and_matches() {
let tree = Arc::new(HashTree::new());
let mut handles = Vec::new();
for tid in 0..8 {
let tree = tree.clone();
handles.push(thread::spawn(move || {
let w = worker(tid);
for round in 0..200_u64 {
// Each round uses a fresh chain so different mutators
// don't trample each other's nodes — we want contention
// on the lock, not contention on the keys (those are
// covered by the single-threaded reinsert/remove tests).
let chain: Vec<i64> = (0..4)
.map(|i| ((tid as i64) << 32) | ((round as i64) << 8) | i as i64)
.collect();
tree.insert(&w, None, &chain);
let m = tree.match_prefix(None, &chain);
assert!(
m.matched_blocks <= chain.len(),
"match must never exceed query length",
);
// Half the rounds use remove(&chain); the rest use
// clear_worker — both must leave a consistent tree.
if round % 2 == 0 {
tree.remove(&w, &chain);
} else {
tree.clear_worker(&w);
}
}
// Final blanket clear in case the last iteration used `remove`
// on only part of the chain.
tree.clear_worker(&w);
}));
}
for tid in 0..4 {
let tree = tree.clone();
handles.push(thread::spawn(move || {
for round in 0..500_u64 {
let probe: Vec<i64> = (0..3)
.map(|i| ((tid as i64) << 40) | ((round as i64) << 8) | i as i64)
.collect();
// Readers must never block-walk and must never panic.
let _ = tree.match_prefix(None, &probe);
}
}));
}
for h in handles {
h.join()
.expect("worker thread panicked under concurrent load");
}
assert_eq!(
tree.node_count(),
0,
"tree must be empty after every worker was cleared; \
residual nodes indicate a missed clear_worker path",
);
// The arena and the reverse index must agree: zero non-root nodes
// means zero `by_hash` entries. A bug that prunes the arena but not
// the reverse index would leak memory and corrupt future inserts;
// this assertion turns that into an immediate test failure.
assert_eq!(
tree.reverse_index_size(),
0,
"by_hash reverse index must be empty when no non-root nodes remain",
);
}
/// A mutator races `clear_worker` against a reader that is mid-`match_prefix`
/// on a deep chain. The reader must never see a partially-mutated tree
/// (no panic, no double-counted workers in the result set).
#[test]
fn match_prefix_is_consistent_with_concurrent_clear() {
let tree = Arc::new(HashTree::new());
let w = worker(0);
let chain: Vec<i64> = (0..32).map(|i| 1_000 + i).collect();
// Pre-populate so the reader has something to walk.
tree.insert(&w, None, &chain);
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let mutator = {
let tree = tree.clone();
let stop = stop.clone();
let w = w.clone();
let chain = chain.clone();
thread::spawn(move || {
let mut round = 0u64;
while !stop.load(std::sync::atomic::Ordering::Relaxed) {
if round.is_multiple_of(2) {
tree.clear_worker(&w);
} else {
tree.insert(&w, None, &chain);
}
round += 1;
}
})
};
for _ in 0..2_000 {
let m = tree.match_prefix(None, &chain);
// Either the worker was present (matched_blocks == chain.len(),
// workers set contains w) or it was cleared mid-walk (matched_blocks
// == 0 OR matched_blocks > 0 with empty workers if the chain is
// partially present). Whichever — the result must be internally
// consistent.
if m.matched_blocks == chain.len() {
assert!(
m.workers.contains(&w),
"full match must include worker; got {:?}",
m.workers,
);
}
}
stop.store(true, std::sync::atomic::Ordering::Relaxed);
mutator.join().unwrap();
}
@@ -0,0 +1,305 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Two independent `KvEventIndex` instances subscribed to the same PUB
//! socket — the in-process surrogate for "two router replicas watching
//! the same SGLang worker's KV publisher."
//!
//! Why this matters: sgl-router v1 explicitly omits multi-replica state
//! sync (deferred to v2 in the slim-design spec). Independent ZMQ
//! subscription is the **only** mechanism by which two routers arrive at
//! a consistent cache-aware view today. If a future change accidentally
//! degraded that property — e.g. a worker that only allows one subscriber,
//! a switch from PUB/SUB to PUSH/PULL, or a teardown bug that drops
//! events to one of N subscribers — this test fails loudly.
//!
//! Property pinned: after publishing N `BlockStored` events, both trees
//! report the same `match_prefix(matched_blocks, workers)` for the
//! published key, and an unpublished key remains absent from both.
use std::sync::Arc;
use std::time::Duration;
use zeromq::SocketSend;
use sgl_router::policies::kv_events::discovery::EventConfig;
use sgl_router::policies::kv_events::{compute_block_hashes, KvEventIndex, KvWorkerId};
use super::zmq_helpers::{
build_multipart, encode_block_stored_event, encode_event_batch, make_pub_bound,
};
#[tokio::test]
async fn two_independent_subscribers_converge_to_same_tree_state() {
// 1. One PUB socket — the worker. Both router surrogates connect to it.
let (mut publisher, port) = make_pub_bound().await;
let worker_url = "http://127.0.0.1:30000";
let block_size = 4u32;
let cfg = EventConfig {
host: "127.0.0.1".into(),
port_base: port,
topic: String::new(),
block_size,
dp_size: 1,
};
// 2. Two independent router-process surrogates, each with its own
// `KvEventIndex` (own tree, own subscriber, own pump task). Both
// call `add_worker` with the same preresolved `EventConfig` — the
// same shape production wires through `WorkerManager`.
let router_a = KvEventIndex::new();
let router_b = KvEventIndex::new();
router_a.add_worker(worker_url, Some(cfg.clone())).await;
router_b.add_worker(worker_url, Some(cfg.clone())).await;
// SUB-side handshake settle. Publishing before the subscribers
// finish their initial connect loses messages in PUB/SUB semantics;
// the polling loop below would then never converge.
tokio::time::sleep(Duration::from_millis(200)).await;
// 3. Publish a deterministic, multi-block event chain.
let tokens: Vec<u32> = (0..16).collect();
let hashes = compute_block_hashes(&tokens, block_size as usize);
assert!(
hashes.len() >= 3,
"test needs ≥3 blocks; got {}",
hashes.len()
);
let event_bytes = encode_block_stored_event(&hashes, None, &tokens, block_size);
let payload = encode_event_batch(0.0, vec![event_bytes], Some(0));
publisher
.send(build_multipart(1, payload))
.await
.expect("publish BlockStored");
// 4. Poll both trees until both report the FULL chain matched. The
// SUB→mpsc→pump→tree pipeline is async; loopback delivery is
// reliable but not instantaneous.
let target = hashes.len();
let key = KvWorkerId {
url: worker_url.into(),
dp_rank: 0,
};
let start = std::time::Instant::now();
loop {
let ma = router_a.tree().match_prefix(None, &hashes);
let mb = router_b.tree().match_prefix(None, &hashes);
let converged = ma.matched_blocks == target
&& mb.matched_blocks == target
&& ma.workers.contains(&key)
&& mb.workers.contains(&key);
if converged {
// Both trees agree on count AND on the worker that holds the
// prefix. This is what the cache-aware-zmq policy reads to
// pick a worker; both routers picking the same key here
// means they would route the same prompt to the same worker.
assert_eq!(
ma.matched_blocks, mb.matched_blocks,
"subscribers disagreed on matched_blocks",
);
assert_eq!(
ma.workers, mb.workers,
"subscribers disagreed on worker set",
);
break;
}
if start.elapsed() > Duration::from_secs(3) {
panic!(
"subscribers did not converge within 3s: \
router_a={{matched={}, workers={:?}}}, \
router_b={{matched={}, workers={:?}}}, target={target}",
ma.matched_blocks, ma.workers, mb.matched_blocks, mb.workers,
);
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
// 5. Negative leg: a key that was never published must not appear in
// either tree. Guards against a future bug where one subscriber
// accidentally inherits another's state (shared static, etc.).
let unseen: Vec<i64> = vec![999_999_999_001, 999_999_999_002, 999_999_999_003];
let na = router_a.tree().match_prefix(None, &unseen);
let nb = router_b.tree().match_prefix(None, &unseen);
assert_eq!(na.matched_blocks, 0, "router_a leaked unpublished key");
assert_eq!(nb.matched_blocks, 0, "router_b leaked unpublished key");
// 6. Both shutdowns must complete cleanly — no hang from the second
// subscriber holding a reference to a shared resource. The first
// drains under a generous ceiling (worker thread joins, mpsc
// receiver drop); the second has nothing left to wait on and
// must complete promptly. A slow second shutdown indicates the
// two subscribers were sharing a resource that serialized them.
let r = tokio::time::timeout(Duration::from_secs(2), Arc::clone(&router_a).shutdown()).await;
assert!(r.is_ok(), "router_a shutdown hung");
let t = std::time::Instant::now();
let r = tokio::time::timeout(Duration::from_secs(2), Arc::clone(&router_b).shutdown()).await;
assert!(r.is_ok(), "router_b shutdown hung");
let elapsed = t.elapsed();
assert!(
elapsed < Duration::from_millis(100),
"router_b shutdown after router_a drained took {elapsed:?}; \
expected <100ms (no shared-resource contention)",
);
}
/// Two PUB sockets (two workers) + two `KvEventIndex` instances (two
/// routers), each subscribed to **both** publishers. This is the real
/// v1 HA shape: each router replica fans out subscriptions across the
/// worker pool and merges every publisher's `BlockStored` stream into
/// its own tree. The companion 1-PUB test above only verifies broadcast
/// fan-out; this test verifies the per-worker attribution stays correct
/// when events arrive from multiple sources concurrently.
///
/// Property pinned: after publishing prefix `X` on `pub_x` and prefix
/// `Y` on `pub_y`, both trees report
/// * `match_prefix(X) = {full, workers={worker_x}}`
/// * `match_prefix(Y) = {full, workers={worker_y}}`
/// with no cross-attribution (worker_x must NOT appear in match(Y)).
/// A regression that wires both subscribers to the same internal
/// channel — or that mis-keys events by their arrival socket rather
/// than their announced worker URL — would surface here as cross-
/// contamination of the worker sets.
#[tokio::test]
async fn two_subscribers_merge_events_from_two_publishers() {
let (mut pub_x, port_x) = make_pub_bound().await;
let (mut pub_y, port_y) = make_pub_bound().await;
let worker_x = "http://127.0.0.1:30001";
let worker_y = "http://127.0.0.1:30002";
let block_size = 4u32;
let cfg_x = EventConfig {
host: "127.0.0.1".into(),
port_base: port_x,
topic: String::new(),
block_size,
dp_size: 1,
};
let cfg_y = EventConfig {
host: "127.0.0.1".into(),
port_base: port_y,
topic: String::new(),
block_size,
dp_size: 1,
};
// Both routers subscribe to BOTH workers — the production fan-out.
let router_a = KvEventIndex::new();
let router_b = KvEventIndex::new();
router_a.add_worker(worker_x, Some(cfg_x.clone())).await;
router_a.add_worker(worker_y, Some(cfg_y.clone())).await;
router_b.add_worker(worker_x, Some(cfg_x.clone())).await;
router_b.add_worker(worker_y, Some(cfg_y.clone())).await;
// Four SUB→PUB handshakes need to settle before publishing; missed
// SUBSCRIBE frames lose messages forever in PUB/SUB semantics.
tokio::time::sleep(Duration::from_millis(200)).await;
// Two non-overlapping token streams → two distinct hash chains. The
// gap between them (0..16 vs 1000..1016) keeps `compute_block_hashes`
// outputs disjoint so a cross-attribution bug can't be masked by
// hash collision.
let tokens_x: Vec<u32> = (0..16).collect();
let tokens_y: Vec<u32> = (1000..1016).collect();
let hashes_x = compute_block_hashes(&tokens_x, block_size as usize);
let hashes_y = compute_block_hashes(&tokens_y, block_size as usize);
assert!(hashes_x.len() >= 3 && hashes_y.len() >= 3);
let payload_x = encode_event_batch(
0.0,
vec![encode_block_stored_event(
&hashes_x, None, &tokens_x, block_size,
)],
Some(0),
);
let payload_y = encode_event_batch(
0.0,
vec![encode_block_stored_event(
&hashes_y, None, &tokens_y, block_size,
)],
Some(0),
);
pub_x
.send(build_multipart(1, payload_x))
.await
.expect("publish on pub_x");
pub_y
.send(build_multipart(1, payload_y))
.await
.expect("publish on pub_y");
let key_x = KvWorkerId {
url: worker_x.into(),
dp_rank: 0,
};
let key_y = KvWorkerId {
url: worker_y.into(),
dp_rank: 0,
};
let target_x = hashes_x.len();
let target_y = hashes_y.len();
let start = std::time::Instant::now();
loop {
let ax = router_a.tree().match_prefix(None, &hashes_x);
let ay = router_a.tree().match_prefix(None, &hashes_y);
let bx = router_b.tree().match_prefix(None, &hashes_x);
let by = router_b.tree().match_prefix(None, &hashes_y);
let converged = ax.matched_blocks == target_x
&& ay.matched_blocks == target_y
&& bx.matched_blocks == target_x
&& by.matched_blocks == target_y
&& ax.workers.contains(&key_x)
&& ay.workers.contains(&key_y)
&& bx.workers.contains(&key_x)
&& by.workers.contains(&key_y);
if converged {
// Negative attribution: prefix X must not be attributed to
// worker_y in either tree, and vice versa. A regression that
// keyed events by arriving socket rather than announced
// worker URL would set BOTH worker keys on each prefix.
assert!(
!ax.workers.contains(&key_y),
"router_a cross-attributed worker_y to prefix X: {:?}",
ax.workers,
);
assert!(
!ay.workers.contains(&key_x),
"router_a cross-attributed worker_x to prefix Y: {:?}",
ay.workers,
);
assert!(
!bx.workers.contains(&key_y),
"router_b cross-attributed worker_y to prefix X: {:?}",
bx.workers,
);
assert!(
!by.workers.contains(&key_x),
"router_b cross-attributed worker_x to prefix Y: {:?}",
by.workers,
);
break;
}
if start.elapsed() > Duration::from_secs(3) {
panic!(
"trees did not converge within 3s:\n \
router_a: X={{matched={}, workers={:?}}}, Y={{matched={}, workers={:?}}}\n \
router_b: X={{matched={}, workers={:?}}}, Y={{matched={}, workers={:?}}}\n \
targets: X={target_x}, Y={target_y}",
ax.matched_blocks,
ax.workers,
ay.matched_blocks,
ay.workers,
bx.matched_blocks,
bx.workers,
by.matched_blocks,
by.workers,
);
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
let r = tokio::time::timeout(Duration::from_secs(2), Arc::clone(&router_a).shutdown()).await;
assert!(r.is_ok(), "router_a shutdown hung");
let r = tokio::time::timeout(Duration::from_secs(2), Arc::clone(&router_b).shutdown()).await;
assert!(r.is_ok(), "router_b shutdown hung");
}
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
mod zmq_helpers;
mod cache_aware_zmq;
mod kv_events_hash_parity;
mod kv_events_tree_concurrent;
mod kv_events_two_subscribers;
mod power_of_two;
mod round_robin;
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::power_of_two::PowerOfTwoChoicesPolicy;
use sgl_router::policies::{Policy, SelectionContext};
use sgl_router::workers::Worker;
use std::sync::atomic::Ordering;
use std::sync::Arc;
fn worker(id: &str) -> Arc<Worker> {
Arc::new(Worker::new(WorkerSpec {
id: WorkerId(id.into()),
url: format!("http://{id}"),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("m".into())],
bootstrap_port: None,
}))
}
#[test]
fn selects_lower_load() {
let a = worker("a");
let b = worker("b");
a.active_requests.store(10, Ordering::Relaxed);
b.active_requests.store(2, Ordering::Relaxed);
let p = PowerOfTwoChoicesPolicy::new();
let ws = vec![a.clone(), b.clone()];
let model_id = ModelId("m".into());
let ctx = SelectionContext::new(&model_id, None);
let chosen = p.select(&ws, &ctx).unwrap();
assert_eq!(chosen.id.0, "b");
}
#[test]
fn distribution_skews_to_lower_load() {
// With 3 workers and one heavily loaded, the loaded one should win
// significantly less than 1/3 of selections.
let workers = vec![worker("a"), worker("b"), worker("c")];
workers[2].active_requests.store(100, Ordering::Relaxed); // c is loaded
let p = PowerOfTwoChoicesPolicy::new();
let model_id = ModelId("m".into());
let ctx = SelectionContext::new(&model_id, None);
let mut counts = std::collections::HashMap::new();
for _ in 0..1000 {
let w = p.select(&workers, &ctx).unwrap();
*counts.entry(w.id.0.clone()).or_insert(0) += 1;
}
let c_picks = *counts.get("c").unwrap_or(&0);
assert!(
c_picks < 200,
"loaded worker should be picked < 20% of the time, got {c_picks}"
);
}
#[test]
fn empty_returns_none() {
let p = PowerOfTwoChoicesPolicy::new();
let ws: Vec<Arc<Worker>> = vec![];
let model_id = ModelId("m".into());
let ctx = SelectionContext::new(&model_id, None);
assert!(p.select(&ws, &ctx).is_none());
}
#[test]
fn single_worker_returns_it() {
let p = PowerOfTwoChoicesPolicy::new();
let ws = vec![worker("only")];
let model_id = ModelId("m".into());
let ctx = SelectionContext::new(&model_id, None);
assert_eq!(p.select(&ws, &ctx).unwrap().id.0, "only");
}
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::round_robin::RoundRobinPolicy;
use sgl_router::policies::{Policy, SelectionContext};
use sgl_router::workers::Worker;
use std::sync::Arc;
fn worker(id: &str) -> Arc<Worker> {
Arc::new(Worker::new(WorkerSpec {
id: WorkerId(id.into()),
url: format!("http://{id}"),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("m".into())],
bootstrap_port: None,
}))
}
#[test]
fn cycles_through_workers() {
let p = RoundRobinPolicy::new();
let ws = vec![worker("a"), worker("b"), worker("c")];
let model_id = ModelId("m".into());
let ctx = SelectionContext::new(&model_id, None);
let picks: Vec<_> = (0..6)
.filter_map(|_| p.select(&ws, &ctx))
.map(|w| w.id.0.clone())
.collect();
assert_eq!(picks, vec!["a", "b", "c", "a", "b", "c"]);
}
#[test]
fn empty_pool_returns_none() {
let p = RoundRobinPolicy::new();
let ws: Vec<Arc<Worker>> = vec![];
let model_id = ModelId("m".into());
let ctx = SelectionContext::new(&model_id, None);
assert!(p.select(&ws, &ctx).is_none());
}
#[test]
fn distribution_across_100_calls() {
let p = RoundRobinPolicy::new();
let ws = vec![worker("a"), worker("b"), worker("c")];
let model_id = ModelId("m".into());
let ctx = SelectionContext::new(&model_id, None);
let mut counts = std::collections::HashMap::new();
for _ in 0..99 {
let w = p.select(&ws, &ctx).unwrap();
*counts.entry(w.id.0.clone()).or_insert(0) += 1;
}
assert_eq!(counts["a"], 33);
assert_eq!(counts["b"], 33);
assert_eq!(counts["c"], 33);
}
@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Shared ZMQ wire-format helpers for the `policies::kv_events` component
//! tests. Encodes events in the same msgspec layout SGLang emits, builds
//! the two-frame `[seq, payload]` ZMQ message a real publisher sends, and
//! binds a loopback PUB socket on an OS-assigned port.
#![allow(dead_code)]
use bytes::Bytes;
use rmp::encode as mp;
use zeromq::{Endpoint, PubSocket, Socket, ZmqMessage};
/// Bind a PUB socket to an OS-assigned 127.0.0.1 port. Returns
/// `(socket, port)`.
pub async fn make_pub_bound() -> (PubSocket, u16) {
let mut sock = PubSocket::new();
let endpoint = sock
.bind("tcp://127.0.0.1:0")
.await
.expect("bind PUB socket");
let port = match endpoint {
Endpoint::Tcp(_, p) => p,
other => panic!("unexpected endpoint: {other:?}"),
};
(sock, port)
}
/// Encode a single `BlockStored` event in the wire format msgspec
/// emits. Layout: `["BlockStored", block_hashes, parent, token_ids,
/// block_size, lora_id, medium]`.
pub fn encode_block_stored_event(
block_hashes: &[i64],
parent: Option<i64>,
token_ids: &[u32],
block_size: u32,
) -> Vec<u8> {
let mut buf = Vec::new();
mp::write_array_len(&mut buf, 7).unwrap();
mp::write_str(&mut buf, "BlockStored").unwrap();
mp::write_array_len(&mut buf, block_hashes.len() as u32).unwrap();
for v in block_hashes {
mp::write_sint(&mut buf, *v).unwrap();
}
match parent {
Some(v) => {
mp::write_sint(&mut buf, v).unwrap();
}
None => mp::write_nil(&mut buf).unwrap(),
}
mp::write_array_len(&mut buf, token_ids.len() as u32).unwrap();
for v in token_ids {
mp::write_uint(&mut buf, *v as u64).unwrap();
}
mp::write_uint(&mut buf, block_size as u64).unwrap();
mp::write_nil(&mut buf).unwrap(); // lora_id
mp::write_str(&mut buf, "GPU").unwrap();
buf
}
/// Wrap one or more pre-encoded events into a KVEventBatch with
/// timestamp + optional dp-rank.
pub fn encode_event_batch(ts: f64, events: Vec<Vec<u8>>, attn_dp_rank: Option<u32>) -> Vec<u8> {
let mut buf = Vec::new();
mp::write_array_len(&mut buf, 3).unwrap();
mp::write_f64(&mut buf, ts).unwrap();
mp::write_array_len(&mut buf, events.len() as u32).unwrap();
for ev in events {
buf.extend_from_slice(&ev);
}
match attn_dp_rank {
Some(v) => {
mp::write_uint(&mut buf, v as u64).unwrap();
}
None => mp::write_nil(&mut buf).unwrap(),
}
buf
}
/// Build the two-frame ZMQ message a real KV publisher sends:
/// `[seq (big-endian i64), payload]`.
pub fn build_multipart(seq: i64, payload: Vec<u8>) -> ZmqMessage {
let mut msg = ZmqMessage::from(Bytes::new());
msg.push_back(Bytes::copy_from_slice(&seq.to_be_bytes()));
msg.push_back(Bytes::from(payload));
msg
}
@@ -0,0 +1,4 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
mod parity;
@@ -0,0 +1,150 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Bit-parity check: dynamo-tokenizers must produce the same token_ids as
//! SGLang's reference (transformers.AutoTokenizer) for every (model, shape)
//! fixture. Any drift is a regression.
//!
//! ## Running
//!
//! `cargo test --release --test component tokenizer::parity` runs the test.
//!
//! Each fixture cell needs the model's `tokenizer.json` on disk; the test
//! looks in the local HuggingFace cache (`HF_HOME` or `~/.cache/huggingface`).
//! Cells whose snapshot isn't cached are skipped (with a warning); cells
//! whose snapshot IS cached are asserted bit-identical.
//!
//! Locally, when no fixtures can be checked (fresh cache) the test emits a
//! warning and passes — useful for contributors without the model snapshots.
//! In CI (`SGLANG_IS_IN_CI=true`) the same condition is a hard failure: a
//! parity matrix that validates nothing is worse than no test at all, since
//! it gives a false sense of coverage. The e2e HTTP tokenize test remains
//! the authoritative live-model parity gate, but this matrix must actually
//! run against cached snapshots when present in CI.
//!
//! ## Regenerating fixtures
//!
//! Run `tests/scripts/generate_parity_fixtures.py` after changing a prompt
//! shape or adding a model, then commit the new JSON.
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Deserialize)]
struct Fixture {
model_id: String,
shape: String,
prompt_text: String,
expected_token_ids: Vec<u32>,
#[allow(dead_code)]
skip_special_tokens: bool,
}
fn fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tokenizer_parity")
}
/// Resolve a model's tokenizer.json file from the local HF cache.
///
/// Strategy:
/// 1. Check HF_HOME env var, or default to ~/.cache/huggingface
/// 2. Look for models--<safe-name>/snapshots/<hash>/tokenizer.json
/// 3. Return None if not found — the test cell is skipped.
fn resolve_tokenizer_path(model_id: &str) -> Option<PathBuf> {
let hf_home = std::env::var("HF_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".cache/huggingface")))?;
let safe = model_id.replace('/', "--");
let candidate = hf_home.join("hub").join(format!("models--{safe}"));
if !candidate.exists() {
return None;
}
let snapshots = candidate.join("snapshots");
let snap = std::fs::read_dir(&snapshots).ok()?.next()?.ok()?.path();
let tj = snap.join("tokenizer.json");
tj.exists().then_some(tj)
}
/// Parity matrix: dynamo-tokenizers vs. transformers.AutoTokenizer.
///
/// Skips cells whose tokenizer.json isn't in the local HF cache. See
/// module-level docs.
#[test]
fn parity_matrix() {
let mut checked = 0;
let mut skipped = vec![];
for model_dir in std::fs::read_dir(fixture_root()).unwrap() {
let model_dir = model_dir.unwrap().path();
if !model_dir.is_dir() {
continue;
}
for shape_file in std::fs::read_dir(&model_dir).unwrap() {
let p = shape_file.unwrap().path();
if p.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let raw = std::fs::read_to_string(&p).unwrap();
let f: Fixture =
serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {}: {e}", p.display()));
let Some(tp) = resolve_tokenizer_path(&f.model_id) else {
skipped.push((f.model_id.clone(), f.shape.clone()));
continue;
};
let tok = sgl_router::tokenizer::adapter::load(tp.to_str().unwrap()).unwrap();
let ids = sgl_router::tokenizer::adapter::encode(&tok, &f.prompt_text).unwrap();
assert_eq!(
ids, f.expected_token_ids,
"DRIFT on {}/{}",
f.model_id, f.shape
);
checked += 1;
}
}
let expected = std::fs::read_dir(fixture_root())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.map(|e| {
std::fs::read_dir(e.path())
.unwrap()
.filter_map(|f| f.ok())
.filter(|f| f.path().extension().and_then(|s| s.to_str()) == Some("json"))
.count()
})
.sum::<usize>();
assert_eq!(
checked + skipped.len(),
expected,
"expected {expected} fixtures, found {}",
checked + skipped.len()
);
if checked == 0 {
let families: Vec<String> = skipped
.iter()
.map(|(m, _)| m.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let msg = format!(
"parity_matrix: no fixtures could be checked — HF cache empty? skipped {} cells \
across model families: [{}]. The e2e HTTP tokenize test remains the \
authoritative live-model parity gate.",
skipped.len(),
families.join(", "),
);
if std::env::var("SGLANG_IS_IN_CI").as_deref() == Ok("true") {
panic!(
"{msg}\n\nThis is a hard failure in CI: a parity test that validates zero \
cells provides no coverage. Either pre-populate the HF cache for these \
model families on the runner, or remove the parity test."
);
}
eprintln!("{msg}");
} else {
eprintln!(
"parity: {checked} cells passed, {} skipped (no HF snapshot)",
skipped.len()
);
}
}
@@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Concurrent-state invariants for the worker/registry/breaker layer.
//!
//! These tests stress the lock-free / single-Mutex paths that production
//! traffic exercises in parallel: many requests calling `breaker.allow()`,
//! many discovery events racing with workers_for() reads, and LoadGuard
//! lifecycles under panics.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::health::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
use sgl_router::workers::{Worker, WorkerRegistry};
/// HalfOpen state must admit at most one probe at a time even under high
/// concurrency. N threads race `allow()` when the breaker is HalfOpen; the
/// invariant is that exactly one observes `true` (the probe holder); the
/// rest see `false` because `probe_in_flight` is already set.
#[tokio::test(start_paused = true)]
async fn breaker_half_open_admits_only_one_probe_concurrently() {
let cb = Arc::new(CircuitBreaker::with_config(CircuitBreakerConfig {
threshold: std::num::NonZeroU32::new(1).unwrap(),
cool_down: Duration::from_millis(50),
}));
// Trip into Open.
cb.record_failure();
assert!(!cb.allow(), "must be Open immediately after a failure");
// Advance the paused clock past cool_down so the next `allow()` will
// attempt the Open → HalfOpen transition.
tokio::time::advance(Duration::from_millis(60)).await;
let admitted = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..32 {
let cb = cb.clone();
let admitted = admitted.clone();
handles.push(tokio::spawn(async move {
if cb.allow() {
admitted.fetch_add(1, Ordering::Relaxed);
}
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(
admitted.load(Ordering::Relaxed),
1,
"exactly one probe must be admitted in HalfOpen",
);
}
/// Concurrent `add_with_cb` (upsert) and `remove` from many threads on the
/// same WorkerId must not panic, must not deadlock, and must leave a
/// consistent index — `workers_for(model)` may return 0 or 1 worker, but
/// must never resolve to a worker that has been removed.
#[test]
fn registry_concurrent_add_remove_keeps_indexes_consistent() {
let r = Arc::new(WorkerRegistry::default());
let model = ModelId("m".into());
let mut handles = Vec::new();
for i in 0..8 {
let r = r.clone();
let model = model.clone();
handles.push(std::thread::spawn(move || {
for _ in 0..200 {
let _ = r.add(WorkerSpec {
id: WorkerId(format!("w{i}")),
url: format!("http://w{i}:30000"),
mode: WorkerMode::Plain,
model_ids: vec![model.clone()],
bootstrap_port: None,
});
let snapshot = r.workers_for(&model);
for w in &snapshot {
// Cross-index invariant: an entry surfaced via
// `by_model[m]` must come from a Worker whose own
// `model_ids` includes `m`. An earlier version of
// this assertion checked `w.id.0.starts_with('w')`,
// which is a tautology — every id is `w0..w7` by
// construction — and a regression where `by_model`
// pointed at the wrong Worker (e.g., a stale entry
// left after an upsert that should have cleared its
// by_model membership for the dropped model) would
// pass silently. We can't `re-get by_id and ptr_eq`
// because a concurrent remove can drop the by_id
// entry between the two reads — `Arc` keeps the
// Worker alive on our side but the index map is
// gone. The model-membership claim, however, is a
// property of the Arc itself and stays stable.
assert!(
w.model_ids.contains(&model),
"cross-index drift: by_model[{model:?}] surfaced \
{:?} whose own model_ids = {:?}",
w.id,
w.model_ids,
);
}
r.remove(&WorkerId(format!("w{i}")));
}
}));
}
for h in handles {
h.join().unwrap();
}
// After every thread finishes, every removed worker must really be gone.
assert!(
r.workers_for(&model).is_empty(),
"registry must be empty after all threads finished their add/remove cycles",
);
}
/// `LoadGuard` must decrement the counter during a panic-unwind, not just
/// on a normal scope exit. Rust's RAII contract via `Drop` covers this,
/// but a future refactor (e.g. adding a manual decrement on a non-panic
/// path) could silently regress it. This test pins the invariant.
#[test]
fn load_guard_decrements_on_panic_unwind() {
let w = Arc::new(Worker::new(WorkerSpec {
id: WorkerId("w".into()),
url: "http://x:30000".into(),
mode: WorkerMode::Plain,
model_ids: vec![ModelId("m".into())],
bootstrap_port: None,
}));
assert_eq!(w.active_load(), 0);
let w_inner = w.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
let _g = w_inner.load_guard();
assert_eq!(w_inner.active_load(), 1);
panic!("synthetic panic to exercise Drop on unwind");
}));
assert!(result.is_err(), "the closure must have panicked");
assert_eq!(
w.active_load(),
0,
"LoadGuard's Drop must decrement even when the holder panics",
);
}
@@ -0,0 +1,511 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use axum::{routing::get, Json, Router};
use serde_json::{json, Value};
use sgl_router::discovery::{DiscoveryEvent, ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::workers::{manager, WorkerRegistry};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::net::TcpListener;
use tokio::sync::{mpsc, oneshot};
/// Spin up a tiny fake worker that returns `body` on `GET /server_info`.
/// Returns the worker base URL and a shutdown channel.
async fn spawn_fake_worker(body: Value) -> (String, oneshot::Sender<()>) {
let body = Arc::new(body);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new().route(
"/server_info",
get(move || {
let body = body.clone();
async move { Json((*body).clone()) }
}),
);
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
(format!("http://127.0.0.1:{port}"), tx)
}
fn spec_for(id: &str, url: &str, mode: WorkerMode) -> WorkerSpec {
// model_ids are intentionally empty: the manager resolves them via
// /server_info introspection. Pre-populating here would lie about
// what discovery backends actually emit.
WorkerSpec {
id: WorkerId(id.into()),
url: url.into(),
mode,
model_ids: Vec::new(),
bootstrap_port: None,
}
}
#[tokio::test]
async fn manager_processes_added_then_removed() {
let (url_a, _s_a) = spawn_fake_worker(json!({"served_model_name": "m"})).await;
let (url_b, _s_b) = spawn_fake_worker(json!({"served_model_name": "m"})).await;
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
tx.send(DiscoveryEvent::Added(spec_for(
"w1",
&url_a,
WorkerMode::Plain,
)))
.await
.unwrap();
tx.send(DiscoveryEvent::Added(spec_for(
"w2",
&url_b,
WorkerMode::Plain,
)))
.await
.unwrap();
// Give the manager time to drain.
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(registry.workers_for(&ModelId("m".into())).len(), 2);
tx.send(DiscoveryEvent::Removed {
id: WorkerId("w1".into()),
})
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(registry.workers_for(&ModelId("m".into())).len(), 1);
drop(tx);
h.await.unwrap();
}
#[tokio::test]
async fn manager_handles_mode_changed() {
let (url, _s) = spawn_fake_worker(json!({"served_model_name": "m"})).await;
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
tx.send(DiscoveryEvent::Added(spec_for(
"w1",
&url,
WorkerMode::Prefill,
)))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(
registry
.workers_for_mode(&ModelId("m".into()), WorkerMode::Prefill)
.len(),
1
);
tx.send(DiscoveryEvent::ModeChanged {
id: WorkerId("w1".into()),
mode: WorkerMode::Decode,
})
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
registry
.workers_for_mode(&ModelId("m".into()), WorkerMode::Prefill)
.len(),
0
);
assert_eq!(
registry
.workers_for_mode(&ModelId("m".into()), WorkerMode::Decode)
.len(),
1
);
drop(tx);
h.await.unwrap();
}
#[tokio::test]
async fn mode_changed_preserves_active_requests_and_breaker() {
let (url, _s) = spawn_fake_worker(json!({"served_model_name": "m"})).await;
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
tx.send(DiscoveryEvent::Added(spec_for(
"w1",
&url,
WorkerMode::Prefill,
)))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
// Grab a handle, bump active_requests, and open the breaker.
let w = registry.get(&WorkerId("w1".into())).unwrap();
w.active_requests.fetch_add(5, Ordering::Relaxed);
// Default threshold is 3 — record 10 failures to guarantee Open state.
for _ in 0..10 {
w.breaker.record_failure();
}
let breaker_open_before = !w.breaker.allow();
assert!(
breaker_open_before,
"breaker should be open after 10 failures"
);
// Flip mode via ModeChanged.
tx.send(DiscoveryEvent::ModeChanged {
id: WorkerId("w1".into()),
mode: WorkerMode::Decode,
})
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
// Re-fetch the Worker handle from the registry.
let w_after = registry.get(&WorkerId("w1".into())).unwrap();
assert_eq!(
w_after.mode(),
WorkerMode::Decode,
"mode should have flipped to Decode"
);
assert_eq!(
w_after.active_requests.load(Ordering::Relaxed),
5,
"active_requests should be preserved across mode change"
);
assert!(
!w_after.breaker.allow(),
"breaker open state should be preserved across mode change"
);
// Critical: the Arc identity must be the same — mutation in place.
assert!(
Arc::ptr_eq(&w, &w_after),
"Worker handle should be the SAME Arc, not a fresh replacement"
);
drop(tx);
h.await.unwrap();
}
/// An out-of-order `ModeChanged` for a worker the registry does not know
/// about (e.g. a buggy discovery backend reordered `Removed` and
/// `ModeChanged`) must not panic, must not silently log INFO claiming the
/// mode flip happened, and must leave the registry untouched.
#[tokio::test]
async fn manager_handles_orphan_mode_changed_without_panic() {
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
tx.send(DiscoveryEvent::ModeChanged {
id: WorkerId("ghost".into()),
mode: WorkerMode::Decode,
})
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
registry.get(&WorkerId("ghost".into())).is_none(),
"an orphan ModeChanged must not create a phantom worker",
);
assert_eq!(
registry.workers_for(&ModelId("m".into())).len(),
0,
"registry must be empty after an orphan event",
);
drop(tx);
h.await.unwrap();
}
/// A `Removed` for an unknown id is a no-op — registry stays empty, manager
/// keeps running.
#[tokio::test]
async fn manager_handles_orphan_removed_without_panic() {
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
tx.send(DiscoveryEvent::Removed {
id: WorkerId("ghost".into()),
})
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(registry.is_empty());
drop(tx);
h.await.unwrap();
}
/// Duplicate `Added` for the same id is an upsert — the registry ends up
/// with exactly one worker. The model resolved by /server_info wins on
/// re-add (a different worker may advertise a different served model).
#[tokio::test]
async fn manager_handles_duplicate_added_as_upsert() {
let (url_first, _s_first) = spawn_fake_worker(json!({"served_model_name": "m1"})).await;
let (url_second, _s_second) = spawn_fake_worker(json!({"served_model_name": "m1"})).await;
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
tx.send(DiscoveryEvent::Added(spec_for(
"w1",
&url_first,
WorkerMode::Plain,
)))
.await
.unwrap();
tx.send(DiscoveryEvent::Added(spec_for(
"w1",
&url_second,
WorkerMode::Plain,
)))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
registry.workers_for(&ModelId("m1".into())).len(),
1,
"w1 still serves m1 after the second Added",
);
drop(tx);
h.await.unwrap();
}
/// Spawn a fake worker whose `/server_info` returns `body` only after
/// sleeping for `delay`. Returns the worker URL and a shutdown channel.
async fn spawn_slow_worker(body: Value, delay: Duration) -> (String, oneshot::Sender<()>) {
let body = Arc::new(body);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new().route(
"/server_info",
get(move || {
let body = body.clone();
async move {
tokio::time::sleep(delay).await;
Json((*body).clone())
}
}),
);
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
(format!("http://127.0.0.1:{port}"), tx)
}
/// Spawn a fake worker that counts each `GET /server_info` hit in the
/// returned `AtomicUsize`. Used to assert the manager makes exactly
/// one round-trip per worker.
async fn spawn_counting_worker(body: Value) -> (String, Arc<AtomicUsize>, oneshot::Sender<()>) {
let body = Arc::new(body);
let counter = Arc::new(AtomicUsize::new(0));
let counter_clone = counter.clone();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new().route(
"/server_info",
get(move || {
let body = body.clone();
let counter = counter_clone.clone();
async move {
counter.fetch_add(1, Ordering::SeqCst);
Json((*body).clone())
}
}),
);
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
(format!("http://127.0.0.1:{port}"), counter, tx)
}
/// Registration must run in parallel across multiple `Added` events.
/// Each fake worker delays its `/server_info` by 200ms; with sequential
/// processing the manager would take ≥1000ms for 5 workers. We allow
/// up to 600ms (3x the per-fetch delay) as a generous bound that still
/// rejects the sequential implementation.
#[tokio::test]
async fn added_events_run_in_parallel() {
let delay = Duration::from_millis(200);
let n = 5;
let mut workers = Vec::new();
for _ in 0..n {
workers.push(spawn_slow_worker(json!({"served_model_name": "m"}), delay).await);
}
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
let start = Instant::now();
for (i, (url, _s)) in workers.iter().enumerate() {
tx.send(DiscoveryEvent::Added(spec_for(
&format!("w{i}"),
url,
WorkerMode::Plain,
)))
.await
.unwrap();
}
let registered = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if registry.workers_for(&ModelId("m".into())).len() == n {
return true;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await;
let elapsed = start.elapsed();
assert!(registered.is_ok(), "manager failed to register {n} workers");
assert!(
elapsed < Duration::from_millis(600),
"registration of {n} workers took {elapsed:?}; sequential per-worker /server_info \
fetches would take ≥1000ms — parallel spawn is required"
);
drop(tx);
h.await.unwrap();
}
/// A `Removed` issued while the matching `Added` is still mid-fetch
/// must await the in-flight registration handle before removing.
/// Without that ordering the removal runs first (registry has nothing
/// to remove), then the Added's deferred registry write leaks the
/// worker.
#[tokio::test]
async fn removed_awaits_pending_added() {
let (url, _s) = spawn_slow_worker(
json!({"served_model_name": "m"}),
Duration::from_millis(300),
)
.await;
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let h = tokio::spawn(manager::run(rx, registry.clone()));
tx.send(DiscoveryEvent::Added(spec_for(
"w-slow",
&url,
WorkerMode::Plain,
)))
.await
.unwrap();
tx.send(DiscoveryEvent::Removed {
id: WorkerId("w-slow".into()),
})
.await
.unwrap();
// Wait long enough for the Added's /server_info to complete (300ms),
// then assert the worker is gone. If Removed ran before Added's
// registry write, the post-fetch write would leak the entry.
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(
registry.get(&WorkerId("w-slow".into())).is_none(),
"Removed must await the in-flight Added; otherwise the deferred \
registry write leaks the worker"
);
drop(tx);
h.await.unwrap();
}
/// The manager must make exactly ONE `/server_info` request per worker.
/// Before this fix the worker manager fetched `served_model_name` and
/// `KvEventIndex::add_worker` fetched the `kv_events` block
/// independently — 2N round-trips for N workers.
#[tokio::test]
async fn manager_emits_single_server_info_fetch_per_worker() {
use sgl_router::policies::kv_events::KvEventIndex;
let body = json!({
"served_model_name": "m",
"kv_events": {
"publisher": "zmq",
"endpoint_host": "127.0.0.1",
"endpoint_port_base": 60100,
"topic": "",
"block_size": 64,
"dp_size": 1,
}
});
let (url, counter, _s) = spawn_counting_worker(body).await;
let (tx, rx) = mpsc::channel(16);
let registry = Arc::new(WorkerRegistry::default());
let kv_index = KvEventIndex::new();
let h = tokio::spawn(manager::run_with_config(
rx,
registry.clone(),
None,
Some(kv_index.clone()),
None,
));
tx.send(DiscoveryEvent::Added(spec_for(
"w1",
&url,
WorkerMode::Plain,
)))
.await
.unwrap();
// Wait for both the registry and kv-events index to reflect the worker.
let ready = tokio::time::timeout(Duration::from_secs(2), async {
loop {
if registry.get(&WorkerId("w1".into())).is_some() && kv_index.known_worker_count() == 1
{
return true;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(
ready.is_ok(),
"manager did not finish onboarding the worker"
);
let hits = counter.load(Ordering::SeqCst);
assert_eq!(
hits, 1,
"manager must fetch /server_info exactly once per worker (got {hits})"
);
drop(tx);
h.await.unwrap();
kv_index.shutdown().await;
}
@@ -0,0 +1,5 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
mod concurrent_state;
mod manager;
@@ -0,0 +1,266 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
# SPDX-License-Identifier: Apache-2.0
"""Content-based cross-router routing test for cache-aware-zmq.
Two routers + two SGLang workers + one shared model. Each router runs an
independent ``cache_aware_zmq`` policy whose ``KvEventIndex`` subscribes
to **both** workers' KV publishers.
The test warms each worker with a DIFFERENT prefix DIRECTLY (bypassing
both routers), then sends those prefixes through each router and
asserts that routing follows the prefix CONTENT: ``PREFIX_X`` lands on
the worker holding X, ``PREFIX_Y`` lands on the worker holding Y, on
both routers.
# Why content-based, not convergence
An earlier version of this test asserted that both routers converged on
the *same dominant worker* after a one-prefix warmup. That property
sounds like it pins the ZMQ-fan-out contract, but it doesn't: when the
KV-event path is broken (subscribers never opened, e.g. a worker's
``/server_info`` lacks the ``kv_events`` block), ``cache_aware_zmq``
silently degrades to **min-load** — which, with sequential requests
holding ``active_load`` at zero, picks the same worker deterministically
on every call within a router. Both routers' min-load picks happened to
agree often enough (about half the time, modulo HashSet seed) to make
the convergence assertion pass even when no event ever flowed.
Content-based routing is uniquely sensitive to the KV-event path. Two
disjoint prefixes warmed on two different workers can only be routed
correctly if the router knows *which worker holds which content* — the
only mechanism that supplies that information is the ``BlockStored``
event stream. Under min-load fallback, both prefixes route to the same
default worker on each router, so the ``PREFIX_Y → worker_y`` assertion
fails regardless of which worker min-load defaults to.
"""
from __future__ import annotations
import re
import time
import httpx
import pytest
from infra.gateway import Gateway
from infra.model_pool import PASSTHROUGH_CHAT_TEMPLATE_PATH, spawn_worker
from infra.model_specs import get_model_spec
# Disjoint prefixes — share no common opening text, so block 0 hashes
# differ from the first block onward and each worker's HashTree
# contribution is uniquely identifying.
#
# Length matters: each prefix must span ≥2 SGLang blocks at the default
# block_size of 64 tokens so the worker actually emits BlockStored
# events. Below that, the publisher stays quiet and we'd be testing
# min-load by accident — the exact failure mode this test exists to
# rule out.
_PREFIX_X_BODY = (
"Apricot bouquet cinnamon dewdrop elderflower fennel garlic "
"hibiscus indigo jasmine kumquat lavender mint nutmeg oregano "
"paprika quince rosemary saffron tarragon. "
)
PREFIX_X = (_PREFIX_X_BODY * 8).strip()
_PREFIX_Y_BODY = (
"Zephyr yellow xylophone wombat vortex umbrella thistle saffron "
"quartz peppermint orchid nightshade marigold lemongrass kale "
"juniper iris hyacinth gardenia foxglove. "
)
PREFIX_Y = (_PREFIX_Y_BODY * 8).strip()
_REQ_TOTAL_RE = re.compile(
r"^sgl_router_requests_total\{([^}]*)\}\s+(\d+(?:\.\d+)?)\s*$"
)
_LABEL_RE = re.compile(r'(\w+)="([^"]*)"')
def _success_counts_by_worker(router_url: str) -> dict[str, int]:
"""Scrape ``/metrics`` and return ``{worker_url: success_count}``."""
r = httpx.get(f"{router_url}/metrics", timeout=5.0)
r.raise_for_status()
counts: dict[str, int] = {}
for line in r.text.splitlines():
m = _REQ_TOTAL_RE.match(line)
if not m:
continue
labels = dict(_LABEL_RE.findall(m.group(1)))
if labels.get("outcome") != "success":
continue
worker = labels.get("worker_url")
if not worker:
continue
try:
counts[worker] = counts.get(worker, 0) + int(float(m.group(2)))
except ValueError:
continue
return counts
def _send_chat(url: str, model_id: str, prompt: str) -> int:
"""POST one chat completion; return the HTTP status."""
r = httpx.post(
f"{url}/v1/chat/completions",
json={
"model": model_id,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 4,
"stream": False,
},
timeout=60.0,
)
return r.status_code
def _direct_warm(worker_url: str, model_id: str, prefix: str) -> None:
"""Send one ``/v1/chat/completions`` request with ``prefix`` DIRECTLY to a worker.
The KV-event publisher emits ``BlockStored`` as the request's
prompt blocks commit to that worker's cache; routers subscribed to
the publisher receive the event and add ``(block_hash → worker)``
entries to their ``HashTree``. The test then exercises those
entries by routing through the router.
Direct-warming (rather than going through a router) is the load-
bearing detail: routing through a router would itself choose which
worker to populate, so the two workers' HashTree state would no
longer be uniquely identifying.
Token alignment with the router — ``cache_aware_zmq`` hashes
``messages[*].content`` RAW (``cache_aware_zmq.rs::extract_prompt_text``)
using ``add_special_tokens=false``. By default SGLang's chat
endpoint would wrap ``prefix`` in the model's chat template before
tokenizing — adding role tags, end-of-turn markers, and a
generation prompt — and the resulting block hashes would never
match what the router computes from raw content.
The test launches each worker with ``--chat-template
<PASSTHROUGH_CHAT_TEMPLATE_PATH>``: a Jinja template that emits
only ``messages[*].content`` (the same shape the router extracts),
and which combines with Transformers' ``apply_chat_template(
tokenize=True, add_special_tokens=False)`` to produce the same
token stream the router will compute. So warm and route hash the
same blocks via the same endpoint.
"""
r = httpx.post(
f"{worker_url}/v1/chat/completions",
json={
"model": model_id,
"messages": [{"role": "user", "content": prefix}],
"max_tokens": 4,
"stream": False,
},
timeout=60.0,
)
assert (
r.status_code == 200
), f"direct warm to {worker_url} failed: HTTP {r.status_code} {r.text!r}"
def _route_through(router_url: str, model_id: str, prompt: str) -> str:
"""Send one request through ``router_url``; return which worker handled it.
Computed by diffing the per-worker success-counter on ``/metrics``
around the call. Asserts exactly one worker absorbed the request
(no partial counts, no cancellation race).
"""
before = _success_counts_by_worker(router_url)
code = _send_chat(router_url, model_id, prompt)
assert code == 200, f"request to {router_url} failed: HTTP {code}"
after = _success_counts_by_worker(router_url)
deltas = {w: after.get(w, 0) - before.get(w, 0) for w in set(after) | set(before)}
winners = [w for w, d in deltas.items() if d > 0]
assert (
len(winners) == 1
), f"expected exactly one worker delta on {router_url}, got {deltas}"
return winners[0]
@pytest.mark.real_gpu
@pytest.mark.slow
def test_two_routers_route_by_prefix_content(
router_binary, # noqa: ARG001 — fixture forces release-binary presence
gpu_allocator,
):
"""Each router must route by prefix CONTENT, agreeing across routers.
With each worker direct-warmed by a different disjoint prefix, the
only way a router can route ``PREFIX_X → worker_x`` AND
``PREFIX_Y → worker_y`` is by consulting a HashTree populated from
the BlockStored events the workers emit. Min-load fallback (the
failure mode when no SUB socket opened) is content-blind and would
route both prefixes to whichever worker its tiebreaker prefers.
"""
spec = get_model_spec("qwen3-0.6b")
gpus = gpu_allocator.acquire(2)
# Passthrough chat template — see _direct_warm for the rationale. Both
# workers must run with the same template; otherwise their KV blocks
# would hash template-wrapped tokens while the router hashes raw
# content, and every lookup would miss the tree.
worker_chat_template_args = ["--chat-template", PASSTHROUGH_CHAT_TEMPLATE_PATH]
try:
with (
spawn_worker(
"qwen3-0.6b",
gpu_ids=[gpus[0]],
enable_kv_events=True,
extra_args=worker_chat_template_args,
) as worker_x,
spawn_worker(
"qwen3-0.6b",
gpu_ids=[gpus[1]],
enable_kv_events=True,
extra_args=worker_chat_template_args,
) as worker_y,
Gateway() as router_a,
Gateway() as router_b,
):
worker_urls = [worker_x.url, worker_y.url]
for gw in (router_a, router_b):
gw.start_regular(
model_id=spec["model"],
tokenizer_path=spec["model"],
worker_urls=worker_urls,
policy="cache_aware_zmq",
timeout=120.0,
)
# 1. Direct-warm each worker with its own prefix. Must happen
# AFTER both routers have started — ZMQ PUB/SUB doesn't
# replay messages emitted before SUB attaches, so any
# BlockStored event predating subscription is lost and
# the HashTree never sees it.
_direct_warm(worker_x.url, spec["model"], PREFIX_X)
_direct_warm(worker_y.url, spec["model"], PREFIX_Y)
# 2. Drain the SUB mpsc + pump-apply path. Sub-second under
# loopback ZMQ; 2 s leaves comfortable headroom.
time.sleep(2.0)
# 3. Content-routing assertion (×4): each prefix must land
# on the worker that holds it, on either router.
#
# The four assertions below are independently strong:
# min-load fallback routes both prefixes on a given
# router to a single default worker, so for ANY broken-
# fan-out scenario at least one of the four fails.
for router, label in ((router_a, "A"), (router_b, "B")):
landed = _route_through(router.base_url, spec["model"], PREFIX_X)
assert landed == worker_x.url, (
f"router {label}: PREFIX_X must route to worker_x "
f"({worker_x.url}); landed on {landed}. "
f"Likely cause: HashTree is empty — KV-event "
f"subscriber never opened, or BlockStored events "
f"never reached the pump."
)
landed = _route_through(router.base_url, spec["model"], PREFIX_Y)
assert landed == worker_y.url, (
f"router {label}: PREFIX_Y must route to worker_y "
f"({worker_y.url}); landed on {landed}. "
f"Likely cause: HashTree is empty — KV-event "
f"subscriber never opened, or BlockStored events "
f"never reached the pump."
)
finally:
gpu_allocator.release(gpus)
@@ -0,0 +1,98 @@
"""Basic chat-completions correctness — ported from SMG's
``e2e_test/chat_completions/test_validation.py``, narrowed to the
subset that exercises sgl-router (not SMG's per-message validators).
The shape:
- single-worker regular-mode router
- non-streaming + streaming chat completion
- assistant message non-empty, role correct, finish_reason set
These are the smoke tests that run first; if they pass, the heavier
multi-worker acceptance tests are worth running.
"""
from __future__ import annotations
import httpx
import pytest
from infra.gateway import Gateway
from infra.model_pool import spawn_worker
from infra.model_specs import get_model_spec
@pytest.mark.real_gpu
def test_chat_non_streaming_returns_assistant_message(
router_binary, # noqa: ARG001
gpu_allocator,
):
gpu = gpu_allocator.acquire(1)
try:
with spawn_worker("qwen3-0.6b", gpu_ids=gpu) as worker:
spec = get_model_spec("qwen3-0.6b")
with Gateway() as gw:
gw.start_regular(
model_id=spec["model"],
tokenizer_path=spec["model"],
worker_urls=[worker.url],
timeout=120.0,
)
resp = httpx.post(
f"{gw.base_url}/v1/chat/completions",
json={
"model": spec["model"],
"messages": [{"role": "user", "content": "Say hi."}],
"max_tokens": 16,
"stream": False,
},
timeout=60.0,
)
assert resp.status_code == 200, resp.text
body = resp.json()
choice = body["choices"][0]
assert choice["message"]["role"] == "assistant"
assert choice["message"][
"content"
], f"empty assistant content: {choice!r}"
assert choice.get("finish_reason"), choice
finally:
gpu_allocator.release(gpu)
@pytest.mark.real_gpu
def test_chat_streaming_emits_sse_chunks_with_done(
router_binary, # noqa: ARG001
gpu_allocator,
):
gpu = gpu_allocator.acquire(1)
try:
with spawn_worker("qwen3-0.6b", gpu_ids=gpu) as worker:
spec = get_model_spec("qwen3-0.6b")
with Gateway() as gw:
gw.start_regular(
model_id=spec["model"],
tokenizer_path=spec["model"],
worker_urls=[worker.url],
timeout=120.0,
)
chunks: list[str] = []
with httpx.stream(
"POST",
f"{gw.base_url}/v1/chat/completions",
json={
"model": spec["model"],
"messages": [{"role": "user", "content": "Say hi."}],
"max_tokens": 16,
"stream": True,
},
timeout=60.0,
) as resp:
assert resp.status_code == 200, resp.read().decode()
for line in resp.iter_lines():
if line.startswith("data:"):
chunks.append(line.strip())
assert len(chunks) >= 2, f"expected >=2 SSE chunks, got: {chunks}"
assert any(
"[DONE]" in c for c in chunks
), f"no [DONE] terminator in stream: {chunks}"
finally:
gpu_allocator.release(gpu)
@@ -0,0 +1,327 @@
"""Pytest fixtures for ``experimental/sgl-router/tests/e2e/``.
Two flavors of fixtures coexist here:
1. **Session-scoped smoke fixtures** (``sglang_server`` + ``router``) —
launch ONE SGLang worker + ONE router on fixed ports for the whole
test session. Used by the lightweight ``test_chat_smoke.py`` /
``test_tokenize_smoke.py`` files. These are the cheap "did the
binary start at all" sanity tests.
2. **Per-test multi-worker fixtures** (``router_binary`` +
``gpu_allocator``) — just enough infra for the acceptance tests in
``chat_completions/`` to bring up their own multi-worker
topologies. Backed by the ``infra.gateway.Gateway`` and
``infra.model_pool.spawn_worker`` helpers.
Both sets share the same release binary; ``SGL_ROUTER_BINARY`` env var
overrides the path for both.
"""
from __future__ import annotations
import logging
import os
import signal
import subprocess
import sys
import tempfile
import threading
import time
from collections.abc import Iterator
from pathlib import Path
import httpx
import pytest
logger = logging.getLogger(__name__)
# Make `from infra import gateway, model_pool, model_specs` resolve from
# tests under tests/e2e/ without requiring a sibling `__init__.py` chain.
# Mirrors SMG's e2e_test/conftest.py sys.path setup.
_E2E_DIR = Path(__file__).resolve().parent
if str(_E2E_DIR) not in sys.path:
sys.path.insert(0, str(_E2E_DIR))
MODEL = "Qwen/Qwen3-0.6B"
SGLANG_PORT = 30000
ROUTER_PORT = 8090
# Path to the release binary. This file lives at
# `experimental/sgl-router/tests/e2e/conftest.py`, so:
# parent = tests/e2e/
# parent.parent = tests/
# parent.parent.parent = experimental/sgl-router/ ← cargo workspace root
# A previous version used `parent.parent / "target"`, which pointed at
# `experimental/sgl-router/tests/target/` and silently broke every
# fixture that tries to launch the router binary (CI's
# `cargo build --release` lands the artifact at
# `experimental/sgl-router/target/release/sgl-router`, not under
# `tests/`).
_SGL_ROUTER_ROOT = Path(__file__).parent.parent.parent
_BINARY = (
Path(os.environ.get("CARGO_TARGET_DIR", str(_SGL_ROUTER_ROOT / "target")))
/ "release"
/ "sgl-router"
)
def _wait_http(url: str, timeout: int = 120) -> None:
"""Poll *url* until it returns 2xx or raises RuntimeError on timeout."""
deadline = time.time() + timeout
last_exc: Exception | None = None
while time.time() < deadline:
try:
resp = httpx.get(url, timeout=5)
if resp.status_code < 300:
return
except Exception as exc: # noqa: BLE001
last_exc = exc
time.sleep(5)
raise RuntimeError(
f"Timed out waiting for {url} after {timeout}s (last error: {last_exc})"
)
@pytest.fixture(scope="session")
def sglang_server():
"""Launch a real SGLang server on port 30000 and wait until healthy."""
# Stream the server's stdout/stderr to a file rather than capturing
# to subprocess.PIPE. The launch_server startup log is verbose (model
# download, JIT warmup, NCCL init); once a PIPE'd output fills its
# ~64 KB OS buffer with nothing reading it, the SGLang process
# blocks on stdout write and never reaches "Server started" — the
# health probe then times out at 300 s and we have no visibility
# into *why*. A real log file fixes both (no buffer pressure, and
# the file is dumped on failure for triage).
log_path = Path(tempfile.gettempdir()) / f"sglang-server-{SGLANG_PORT}.log"
log_handle = open(log_path, "w", buffering=1) # line-buffered
proc = subprocess.Popen(
[
"python3",
"-m",
"sglang.launch_server",
"--model-path",
MODEL,
"--port",
str(SGLANG_PORT),
"--tp",
"1",
],
stdout=log_handle,
stderr=subprocess.STDOUT,
)
try:
_wait_http(f"http://localhost:{SGLANG_PORT}/health", timeout=300)
except Exception:
# Dump the server log so the operator can see why startup failed
# (model download error, port conflict, OOM, JIT crash, etc.).
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=30)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
log_handle.flush()
log_handle.close()
try:
tail = log_path.read_text(errors="replace").splitlines()[-200:]
except OSError:
tail = ["(server log unreadable)"]
logger.error(
"sglang_server fixture failed; last 200 log lines from %s:\n%s",
log_path,
"\n".join(tail),
)
raise
yield f"http://localhost:{SGLANG_PORT}"
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=30)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
log_handle.flush()
log_handle.close()
def _find_tokenizer_path(model: str) -> str:
"""Locate the tokenizer.json for *model* from the local HF Hub cache.
Falls back to the model string itself (a valid HF Hub repo identifier
that dynamo-tokenizers can resolve at runtime) when the cache is absent.
"""
try:
from huggingface_hub import try_to_load_from_cache # type: ignore[import]
path = try_to_load_from_cache(model, "tokenizer.json")
if path and Path(path).is_file():
return str(path)
except Exception: # noqa: BLE001
pass
# Let dynamo-tokenizers resolve the repo identifier directly.
return model
def build_smoke_router_config(
*,
host: str,
port: int,
model: str,
tokenizer_path: str,
sglang_url: str,
) -> str:
"""Build the TOML the smoke `router` fixture writes to disk.
Returns ``main_config_text`` carrying ``[server]``, ``[[models]]``,
and ``[discovery] backend = "static_urls"`` with the worker URL
inline. The Rust ``Config`` struct requires a ``[discovery]``
section (``DiscoveryConfig`` has no ``#[serde(default)]``) and has
no top-level ``workers`` field. The previous ``static_file``
backend was replaced by ``static_urls`` (which holds the URL list
inline rather than via a side-car file).
"""
return f"""\
[server]
host = "{host}"
port = {port}
[[models]]
id = "{model}"
tokenizer_path = "{tokenizer_path}"
[discovery]
backend = "static_urls"
[discovery.static_urls]
urls = ["{sglang_url}"]
"""
@pytest.fixture(scope="session")
def router(sglang_server): # noqa: ARG001 (sglang_server must start first)
"""Launch sgl-router on port 8090 pointed at the SGLang worker."""
tok_path = _find_tokenizer_path(MODEL)
cfg_handle = tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False)
cfg_path = Path(cfg_handle.name)
main_text = build_smoke_router_config(
host="0.0.0.0",
port=ROUTER_PORT,
model=MODEL,
tokenizer_path=tok_path,
sglang_url=f"http://localhost:{SGLANG_PORT}",
)
cfg_handle.write(main_text)
cfg_handle.close()
try:
proc = subprocess.Popen(
[str(_BINARY), "--config", str(cfg_path)],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
try:
_wait_http(f"http://localhost:{ROUTER_PORT}/readyz", timeout=60)
except Exception:
proc.send_signal(signal.SIGTERM)
proc.wait(timeout=30)
raise
yield f"http://localhost:{ROUTER_PORT}"
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=30)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
finally:
cfg_path.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Per-test multi-worker acceptance fixtures
# ---------------------------------------------------------------------------
def _detect_gpu_count() -> int:
"""Count visible GPUs via ``nvidia-smi``. Returns 0 when no NVIDIA GPU
is available (CI on CPU-only runners, dev laptops, etc.).
"""
try:
out = subprocess.check_output(
["nvidia-smi", "--query-gpu=index", "--format=csv,noheader"],
stderr=subprocess.DEVNULL,
timeout=5.0,
)
except (FileNotFoundError, subprocess.SubprocessError):
return 0
return len([ln for ln in out.decode().splitlines() if ln.strip()])
class GPUAllocator:
"""Single-process GPU index allocator. Test-scoped; not safe for
cross-process use (pytest-xdist) — each worker would race over the
full GPU set. Acceptance tests run serially, so this is fine.
"""
def __init__(self, total: int):
self.total = total
self._free: list[int] = list(range(total))
self._lock = threading.Lock()
def acquire(self, n: int = 1) -> list[int]:
with self._lock:
if n > len(self._free):
raise pytest.skip.Exception(
f"requested {n} GPUs, only {len(self._free)}/{self.total} free"
)
picked = self._free[:n]
self._free = self._free[n:]
return picked
def release(self, ids: list[int]) -> None:
with self._lock:
self._free.extend(ids)
self._free.sort()
@pytest.fixture(scope="session")
def router_binary() -> Path:
"""Locate the release ``sgl-router`` binary or skip the session.
Used by the multi-worker acceptance tests (which spawn their own
Gateway per test instead of using the session-scoped ``router``
fixture).
"""
env_path = os.environ.get("SGL_ROUTER_BINARY")
candidates: list[Path] = []
if env_path:
candidates.append(Path(env_path))
candidates.append(_BINARY)
for c in candidates:
if c.exists():
return c
pytest.skip(
"sgl-router release binary not found at any of: "
+ ", ".join(str(c) for c in candidates)
+ ". Build with `cargo build --release` in experimental/sgl-router/."
)
@pytest.fixture(scope="session")
def gpu_allocator() -> Iterator[GPUAllocator]:
"""Session-scoped GPU index allocator. Skips the entire session when
no GPUs are visible — acceptance tests under chat_completions/ are
real-GPU.
"""
n = _detect_gpu_count()
if n == 0:
pytest.skip(
"no NVIDIA GPUs visible to nvidia-smi; acceptance tests are GPU-only"
)
yield GPUAllocator(n)
@@ -0,0 +1,404 @@
"""Minimal sgl-router Gateway class — adapted from SMG's e2e_test/infra/gateway.py.
Differences from SMG:
- SMG drives a Python launcher (`python3 -m sglang_router.launch_router`)
with worker URLs on the CLI.
- sgl-router uses a Rust binary (`experimental/sgl-router/target/release/sgl-router`)
with a TOML config file. Worker discovery is config-file-based; this
Gateway writes a TOML to a tempfile and execs the binary with
`--config <tempfile>`.
Supported lifecycles:
- Regular mode: one model, N worker URLs, single policy.
- PD mode: one model, prefill_workers + decode_workers (lists of URLs),
discovery emits separate `WorkerMode::Prefill` / `WorkerMode::Decode`
entries. The router resolves PD pool isolation at request time.
Use as a context manager:
with Gateway() as gw:
gw.start_regular(model_path="...", worker_urls=[...])
resp = httpx.post(f"{gw.base_url}/v1/chat/completions", json=...)
or pytest fixture style (see e2e_test/conftest.py).
"""
from __future__ import annotations
import logging
import os
import signal
import socket
import subprocess
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# Repo-relative path to the release binary. Set ``SGL_ROUTER_BINARY`` to
# override (e.g. a debug build, or a non-default ``CARGO_TARGET_DIR``).
# This file is at `experimental/sgl-router/tests/e2e/infra/gateway.py`,
# so four `.parent` hops to reach the sgl-router workspace root
# (infra → e2e → tests → sgl-router). Cargo lands the binary at
# `experimental/sgl-router/target/release/sgl-router`. A previous
# version used three hops and pointed at `tests/target/`, which
# would have broken any test that actually launches the router via
# this helper.
DEFAULT_BINARY = (
Path(__file__).resolve().parent.parent.parent.parent
/ "target"
/ "release"
/ "sgl-router"
)
def _get_open_port() -> int:
"""Reserve an ephemeral TCP port in [20000, 55535].
The router itself doesn't have the ``port + 10000`` gRPC-derivation
constraint that SGLang's launch_server does, but we cap the range
anyway so the e2e helpers behave consistently across components.
"""
for _ in range(50):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
if 20000 <= port <= 55535:
return port
raise RuntimeError(
"could not allocate an ephemeral port in [20000, 55535] after 50 tries"
)
def _resolve_tokenizer_path(tokenizer_path: str) -> str:
"""Resolve a HuggingFace repo ID to a local ``tokenizer.json`` path.
sgl-router's tokenizer loader treats the input as a filesystem path and
inspects its extension; a bare HF id like ``Qwen/Qwen3-0.6B`` looks
like a file with extension ``.6B`` and is rejected. When the HF Hub
cache already has the tokenizer, point the loader at the on-disk
``tokenizer.json`` directly. Pass paths/URLs through unchanged.
"""
p = Path(tokenizer_path)
if p.exists():
return str(p)
try:
from huggingface_hub import try_to_load_from_cache # type: ignore[import]
cached = try_to_load_from_cache(tokenizer_path, "tokenizer.json")
if cached and Path(cached).is_file():
return str(cached)
except Exception: # noqa: BLE001
pass
return tokenizer_path
@dataclass
class WorkerInfo:
"""Worker visible to the gateway via ``/v1/models``-style introspection.
Mirrors SMG's WorkerInfo shape so test code reads the same. sgl-router
does not currently surface a `/v1/workers` admin API — this is a
placeholder for a future admin surface; current tests scrape
`/metrics` for per-worker observability instead.
"""
id: str
url: str
model: str | None = None
status: str = "unknown"
metadata: dict[str, Any] = field(default_factory=dict)
class Gateway:
"""Lifecycle-managed sgl-router instance for e2e tests.
Not thread-safe; assume one Gateway per test (or per fixture scope).
"""
def __init__(
self,
host: str = "127.0.0.1",
port: int | None = None,
binary: Path | None = None,
proxy_request_timeout_secs: int | None = None,
stale_request_timeout_secs: int | None = None,
):
self.host = host
self.port = port or _get_open_port()
self.base_url = f"http://{self.host}:{self.port}"
# Resolve binary from env override, explicit arg, or repo default.
env_binary = os.environ.get("SGL_ROUTER_BINARY")
if binary is not None:
self.binary = Path(binary)
elif env_binary:
self.binary = Path(env_binary)
else:
self.binary = DEFAULT_BINARY
# Test-side overrides for the router's tunables. Both default to
# `None`, in which case the router uses its production defaults
# (60 s proxy timeout, 300 s stale-request timeout). Tests set
# these short so per-request failures and stale-request expiry
# surface within the test's wall-time budget.
self.proxy_request_timeout_secs = proxy_request_timeout_secs
self.stale_request_timeout_secs = stale_request_timeout_secs
self.process: subprocess.Popen | None = None
self._config_path: Path | None = None
self._started: bool = False
# Track child workers we spawned so __exit__ can tear them down.
self._owned_workers: list[subprocess.Popen] = []
# ----- context manager -------------------------------------------------
def __enter__(self) -> "Gateway":
return self
def __exit__(self, *exc) -> None:
self.shutdown()
# ----- start ----------------------------------------------------------
def start_regular(
self,
*,
model_id: str,
tokenizer_path: str,
worker_urls: list[str],
policy: str = "round_robin",
extra_models: list[dict] | None = None,
timeout: float = 60.0,
) -> None:
"""Start the router in regular (non-PD) mode.
Args:
model_id: The model identifier the router will dispatch under.
tokenizer_path: Path or HF ID for the tokenizer the router uses
for cache-aware tokenization.
worker_urls: URLs of already-running ``sglang.launch_server``
instances. The router uses ``static_urls`` discovery;
each worker's mode (plain) and any disaggregation
metadata are learned from ``/server_info``.
policy: Policy kind — ``round_robin``, ``random``, ``power_of_two``,
or ``cache_aware_zmq``.
timeout: How long to wait for ``/readyz`` before giving up.
"""
self._launch(
self._build_config(
model_id=model_id,
tokenizer_path=tokenizer_path,
urls=list(worker_urls),
policy=policy,
extra_models=extra_models or [],
),
timeout=timeout,
)
def start_pd(
self,
*,
model_id: str,
tokenizer_path: str,
prefill_urls: list[str],
decode_urls: list[str],
policy: str = "round_robin",
timeout: float = 60.0,
) -> None:
"""Start the router in PD-disaggregated mode.
All prefill + decode URLs go into one ``static_urls`` list. The
router seeds each worker as ``WorkerMode::Plain`` and the
manager's ``/server_info`` introspect step overrides mode +
``bootstrap_port`` from the worker's self-disclosure. Workers
must have been launched with ``--disaggregation-mode`` and
``--disaggregation-bootstrap-port`` for the PD role to be
picked up (see ``model_pool.spawn_worker``); modern SGLang is
assumed.
"""
self._launch(
self._build_config(
model_id=model_id,
tokenizer_path=tokenizer_path,
urls=list(prefill_urls) + list(decode_urls),
policy=policy,
extra_models=[],
),
timeout=timeout,
)
# ----- shutdown --------------------------------------------------------
def shutdown(self) -> None:
"""SIGTERM the router; SIGKILL after 30s. Idempotent."""
if self.process is not None and self.process.poll() is None:
try:
self.process.send_signal(signal.SIGTERM)
try:
self.process.wait(timeout=30)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
except ProcessLookupError:
pass
self.process = None
if self._config_path and self._config_path.exists():
self._config_path.unlink(missing_ok=True)
self._config_path = None
self._started = False
# Tear down any owned upstream workers.
for w in self._owned_workers:
if w.poll() is None:
try:
w.send_signal(signal.SIGTERM)
try:
w.wait(timeout=30)
except subprocess.TimeoutExpired:
w.kill()
w.wait()
except ProcessLookupError:
pass
self._owned_workers.clear()
# ----- HTTP introspection helpers -------------------------------------
def healthy(self, timeout: float = 5.0) -> bool:
try:
resp = httpx.get(f"{self.base_url}/healthz", timeout=timeout)
return resp.status_code == 200
except (httpx.RequestError, httpx.TimeoutException):
return False
def ready(self, timeout: float = 5.0) -> bool:
try:
resp = httpx.get(f"{self.base_url}/readyz", timeout=timeout)
return resp.status_code == 200
except (httpx.RequestError, httpx.TimeoutException):
return False
def metrics_text(self, timeout: float = 5.0) -> str | None:
try:
resp = httpx.get(f"{self.base_url}/metrics", timeout=timeout)
if resp.status_code == 200:
return resp.text
return None
except (httpx.RequestError, httpx.TimeoutException):
return None
# ----- internals ------------------------------------------------------
def _build_config(
self,
*,
model_id: str,
tokenizer_path: str,
urls: list[str],
policy: str,
extra_models: list[dict],
) -> str:
resolved_tokenizer = _resolve_tokenizer_path(tokenizer_path)
extra_model_toml = ""
for em in extra_models:
extra_model_toml += (
f'\n[[models]]\nid = "{em["id"]}"\n'
f'tokenizer_path = "{_resolve_tokenizer_path(em["tokenizer_path"])}"\n'
f'policy = "{em.get("policy", policy)}"\n'
)
# Optional tunables — only emit the [proxy] and [active_load]
# sections if a test has overridden them, so production defaults
# apply otherwise.
proxy_section = ""
if self.proxy_request_timeout_secs is not None:
proxy_section = (
f"\n[proxy]\nrequest_timeout_secs = {self.proxy_request_timeout_secs}\n"
)
active_load_section = ""
if self.stale_request_timeout_secs is not None:
active_load_section = (
f"\n[active_load]\nstale_request_timeout_secs = "
f"{self.stale_request_timeout_secs}\n"
)
urls_toml = ", ".join(f'"{u}"' for u in urls)
return f"""\
[server]
host = "{self.host}"
port = {self.port}
[[models]]
id = "{model_id}"
tokenizer_path = "{resolved_tokenizer}"
policy = "{policy}"
{extra_model_toml}
[discovery]
backend = "static_urls"
[discovery.static_urls]
urls = [{urls_toml}]
{proxy_section}{active_load_section}"""
def _launch(self, config_text: str, *, timeout: float) -> None:
if not self.binary.exists():
raise RuntimeError(
f"sgl-router binary not found at {self.binary}. "
"Build it first: `cd experimental/sgl-router && cargo build --release` "
"or set SGL_ROUTER_BINARY to the binary path."
)
# Write the main config.
fd, path = tempfile.mkstemp(suffix=".toml", prefix="sgl-router-")
os.close(fd)
self._config_path = Path(path)
self._config_path.write_text(config_text, encoding="utf-8")
logger.info("sgl-router config: %s", self._config_path)
logger.debug("sgl-router config text:\n%s", config_text)
self.process = subprocess.Popen(
[str(self.binary), "--config", str(self._config_path)],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True,
)
try:
self._wait_ready(timeout=timeout)
except Exception:
self.shutdown()
raise
self._started = True
def _wait_ready(self, *, timeout: float) -> None:
deadline = time.time() + timeout
last_exc: Exception | None = None
while time.time() < deadline:
if self.process is not None and self.process.poll() is not None:
# Process exited early — surface stdout/stderr.
out = b""
try:
if self.process.stdout is not None:
out = self.process.stdout.read() or b""
except Exception: # noqa: BLE001
pass
raise RuntimeError(
f"sgl-router exited during startup with code "
f"{self.process.returncode}. output:\n{out.decode(errors='replace')}",
)
try:
resp = httpx.get(f"{self.base_url}/readyz", timeout=2.0)
if resp.status_code == 200:
return
except (httpx.RequestError, httpx.TimeoutException) as exc:
last_exc = exc
time.sleep(0.5)
raise TimeoutError(
f"sgl-router did not become ready at {self.base_url} within {timeout}s "
f"(last error: {last_exc})"
)
@@ -0,0 +1,228 @@
"""Minimal SGLang worker spawner for sgl-router e2e tests.
Adapted from SMG's e2e_test/infra/model_pool.py — the 1200-line original
manages a pool of long-lived workers across many tests; here we only
need a thin wrapper around ``sglang.launch_server`` that:
- allocates GPU(s) for the worker (via ``CUDA_VISIBLE_DEVICES``),
- spawns ``python3 -m sglang.launch_server`` with the right args,
- waits for ``/health`` to come up,
- optionally injects ``--kv-events-config`` so the worker exposes
the ``kv_events`` block on ``/server_info``.
A test owns a ``ModelInstance`` for its duration; teardown shuts the
worker down. No cross-test pooling — the acceptance tests are slow
enough already (model load dominates) that pooling complexity wasn't
worth porting.
"""
from __future__ import annotations
import json
import logging
import os
import signal
import socket
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
import httpx
from .model_specs import get_model_spec
logger = logging.getLogger(__name__)
# Passthrough Jinja chat template that emits ONLY `messages[*].content`
# joined with `\n` — matching the router's cache_aware_zmq prompt
# extraction. A worker launched with
# ``--chat-template <PASSTHROUGH_CHAT_TEMPLATE_PATH>`` tokenizes the
# raw content string, so its KV-block hashes align with what the
# router computes from the same chat-completions request. Test-only.
PASSTHROUGH_CHAT_TEMPLATE_PATH = str(
Path(__file__).parent / "passthrough_chat_template.jinja"
)
def _get_open_port() -> int:
"""Allocate an ephemeral TCP port in the range [20000, 55535].
SGLang derives its internal gRPC port as ``http_port + 10000``; if the
kernel hands us an ephemeral port above 55535, that derivation overflows
65535 and ``ServerArgs.__post_init__`` rejects it. Retrying a bounded
number of times keeps us safely below the ceiling without hand-rolling
a port registry.
"""
for _ in range(50):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
if 20000 <= port <= 55535:
return port
raise RuntimeError(
"could not allocate an ephemeral port in [20000, 55535] after 50 tries; "
"SGLang derives its internal gRPC port as http_port + 10000 and "
"rejects values above 65535"
)
@dataclass
class ModelInstance:
"""A running ``sglang.launch_server`` process.
Use as a context manager:
with spawn_worker("qwen3-0.6b", gpu_ids=[0]) as inst:
httpx.post(f"{inst.url}/generate", ...)
"""
url: str
port: int
process: subprocess.Popen
model_id: str
gpu_ids: list[int] = field(default_factory=list)
kv_events_endpoint: str | None = None
def __enter__(self) -> "ModelInstance":
return self
def __exit__(self, *exc) -> None:
self.shutdown()
def shutdown(self) -> None:
if self.process is not None and self.process.poll() is None:
try:
self.process.send_signal(signal.SIGTERM)
try:
self.process.wait(timeout=60)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
except ProcessLookupError:
pass
def spawn_worker(
model_id: str,
*,
gpu_ids: list[int],
port: int | None = None,
enable_kv_events: bool = False,
kv_events_port: int | None = None,
disagg_mode: str | None = None,
bootstrap_port: int | None = None,
extra_args: list[str] | None = None,
timeout: float = 600.0,
) -> ModelInstance:
"""Spawn a single ``sglang.launch_server`` and wait for ``/health``.
Args:
model_id: Key into :data:`model_specs.MODEL_SPECS`.
gpu_ids: Concrete GPU indices to bind via ``CUDA_VISIBLE_DEVICES``.
port: HTTP port; auto-assigned if None.
enable_kv_events: If True, inject ``--kv-events-config`` with a
ZMQ publisher so the router's introspection picks up the
kv_events block from ``/server_info`` (Patch 1).
kv_events_port: ZMQ publisher port. Auto-assigned if None and
``enable_kv_events`` is True.
disagg_mode: "prefill" or "decode" for PD-disagg launches; passed
through as ``--disaggregation-mode``.
bootstrap_port: PD-disagg bootstrap port (prefill side only).
extra_args: Additional CLI args appended verbatim.
timeout: Health-check timeout. Cold-start on a fresh GPU can be
slow; default is 10 minutes.
"""
spec = get_model_spec(model_id)
port = port or _get_open_port()
base_url = f"http://127.0.0.1:{port}"
cmd = [
"python3",
"-m",
"sglang.launch_server",
"--model-path",
spec["model"],
"--port",
str(port),
"--host",
"127.0.0.1",
"--tp",
str(spec.get("tp", 1)),
]
cmd.extend(spec.get("worker_args", []) or [])
kv_events_endpoint: str | None = None
if enable_kv_events:
kv_port = kv_events_port or _get_open_port()
kv_events_endpoint = f"tcp://*:{kv_port}"
kv_cfg = {
"publisher": "zmq",
"endpoint": kv_events_endpoint,
"topic": "kv",
}
cmd.extend(["--kv-events-config", json.dumps(kv_cfg)])
if disagg_mode is not None:
cmd.extend(["--disaggregation-mode", disagg_mode])
if bootstrap_port is not None:
cmd.extend(["--disaggregation-bootstrap-port", str(bootstrap_port)])
if extra_args:
cmd.extend(extra_args)
env = os.environ.copy()
env["CUDA_VISIBLE_DEVICES"] = ",".join(str(g) for g in gpu_ids)
logger.info(
"spawning sglang worker: model=%s port=%d gpus=%s disagg=%s",
model_id,
port,
gpu_ids,
disagg_mode,
)
proc = subprocess.Popen(
cmd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True,
)
inst = ModelInstance(
url=base_url,
port=port,
process=proc,
model_id=model_id,
gpu_ids=list(gpu_ids),
kv_events_endpoint=kv_events_endpoint,
)
# Wait for /health. Cold-start on H200 with weights uncached can take
# ~5 minutes; CI configurations should pre-warm.
deadline = time.time() + timeout
while time.time() < deadline:
if proc.poll() is not None:
out = b""
try:
if proc.stdout is not None:
out = proc.stdout.read() or b""
except Exception: # noqa: BLE001
pass
raise RuntimeError(
f"sglang worker exited during startup with code {proc.returncode}; "
f"cmd: {' '.join(cmd)}\noutput:\n{out.decode(errors='replace')}",
)
try:
resp = httpx.get(f"{base_url}/health", timeout=2.0)
if resp.status_code == 200:
logger.info("sglang worker ready at %s", base_url)
return inst
except (httpx.RequestError, httpx.TimeoutException):
pass
time.sleep(2.0)
inst.shutdown()
raise TimeoutError(
f"sglang worker did not become healthy at {base_url} within {timeout}s",
)
@@ -0,0 +1,77 @@
"""Model specifications for sgl-router e2e tests.
Adapted from SMG's e2e_test/infra/model_specs.py. The same dict-of-dicts
shape (so test code reads the same) but the entries are narrower —
sgl-router tests today target small/medium models only; the larger
function-calling / reasoning models from SMG are out of scope.
Each entry:
- model: HuggingFace path or local path (env-resolved)
- memory_gb: estimated single-GPU footprint
- tp: tensor-parallel size (= GPUs needed)
- features: feature tags for filtering
- worker_args: optional extra `sglang.launch_server` flags
"""
from __future__ import annotations
import os
# Local-cache root for CI / cluster nodes that pre-download HF weights.
# Mirrors the SMG `ROUTER_LOCAL_MODEL_PATH` env var.
ROUTER_LOCAL_MODEL_PATH = os.environ.get("ROUTER_LOCAL_MODEL_PATH", "")
def _resolve_model_path(hf_path: str) -> str:
"""Prefer a local copy of the model when one exists under
``ROUTER_LOCAL_MODEL_PATH``; otherwise fall back to the HuggingFace ID.
"""
if ROUTER_LOCAL_MODEL_PATH:
local_path = os.path.join(ROUTER_LOCAL_MODEL_PATH, hf_path)
if os.path.exists(local_path):
return local_path
return hf_path
MODEL_SPECS: dict[str, dict] = {
# Fast-start tiny model for convergence / decode-affinity / stale-request
# tests. Single GPU, ~2 GB weights, sub-30s start on a warm cache.
"qwen3-0.6b": {
"model": _resolve_model_path("Qwen/Qwen3-0.6B"),
"memory_gb": 4,
"tp": 1,
"features": ["chat", "streaming"],
},
# Standard small chat model — matches SMG's `llama-1b` entry.
"llama-1b": {
"model": _resolve_model_path("meta-llama/Llama-3.2-1B-Instruct"),
"memory_gb": 4,
"tp": 1,
"features": ["chat", "streaming"],
},
# Primary 8B chat model — matches SMG's `llama-8b`.
"llama-8b": {
"model": _resolve_model_path("meta-llama/Llama-3.1-8B-Instruct"),
"memory_gb": 16,
"tp": 1,
"features": ["chat", "streaming"],
},
}
def get_model_spec(model_id: str) -> dict:
"""Return the spec dict for ``model_id``; KeyError if absent."""
if model_id not in MODEL_SPECS:
raise KeyError(
f"Unknown model: {model_id}. Available: {list(MODEL_SPECS.keys())}"
)
return MODEL_SPECS[model_id]
def get_models_with_feature(feature: str) -> list[str]:
"""Filter model IDs by feature tag (e.g. ``streaming``, ``chat``)."""
return [
model_id
for model_id, spec in MODEL_SPECS.items()
if feature in spec.get("features", [])
]
@@ -0,0 +1,13 @@
{#-
Passthrough chat template for cache-aware-zmq e2e tests.
Emits ONLY `messages[*].content` joined with `\n` — no role markers,
no special tokens, no generation prompt. This is the SAME shape the
router's cache_aware_zmq policy produces in `extract_prompt_text`,
so a worker launched with `--chat-template <this file>` tokenizes the
same string the router will tokenize for routing — making block
hashes align across worker KV cache and router HashTree.
Use only for tests; not appropriate for any real chat workload.
-#}
{{- messages | map(attribute='content') | join('\n') -}}
@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn
COPY fake_worker.py .
EXPOSE 30000
CMD ["python", "fake_worker.py"]
@@ -0,0 +1,39 @@
# syntax=docker/dockerfile:1.6
# Build sgl-router binary for k8s integration E2E.
# Context root: repo root (one level above experimental/sgl-router/).
# Matches rust-toolchain.toml's pinned channel, avoiding an in-build rustup channel-sync.
FROM rust:1.90-bookworm AS builder
# Pin to the exact toolchain pre-installed in the base image so rustup
# doesn't try to sync the channel manifest when it sees rust-toolchain.toml's
# `channel = "1.90"`.
ENV RUSTUP_TOOLCHAIN=1.90.0
# libssl-dev + pkg-config ship with rust:1.90-bookworm already; no apt-get needed.
WORKDIR /build
# Copy just the sgl-router crate (context is the repo root)
COPY experimental/sgl-router /build/experimental/sgl-router
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/build/experimental/sgl-router/target \
cd /build/experimental/sgl-router \
&& cargo build --release --bin sgl-router \
&& cp target/release/sgl-router /usr/local/bin/sgl-router
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/bin/sgl-router /usr/local/bin/sgl-router
# Tiny tokenizer fixture used by the E2E config
COPY experimental/sgl-router/tests/fixtures/tiny_tokenizer.json /etc/tokenizer/tiny.json
EXPOSE 8090
ENTRYPOINT ["sgl-router"]
@@ -0,0 +1,251 @@
"""Pytest configuration for sgl-router K8s integration tests.
These tests require:
- A kind cluster named 'sgl-router-kind'
- The sgl-router:e2e and sgl-router-fake-worker:e2e images loaded into kind
- kubectl configured to use the kind-sgl-router-kind context
Setup: ./tests/e2e/k8s_integration/setup.sh
Teardown: ./tests/e2e/k8s_integration/setup.sh teardown
"""
from __future__ import annotations
import logging
import socket
import subprocess
import time
import httpx
import pytest
logger = logging.getLogger(__name__)
NAMESPACE = "sgl-router-test"
CLUSTER_NAME = "sgl-router-kind"
KUBECTL_CONTEXT = f"kind-{CLUSTER_NAME}"
# sgl-router discovery reconciliation: if the watcher misses an event the
# reconciler fires within ~60s. Tests that exercise removal wait up to 90s.
RECONCILIATION_WAIT_SECS = 90
# Errors safe to retry while polling (transport-level only — HTTP 4xx/5xx
# are intentionally NOT included so real regressions surface immediately).
_TRANSIENT_ERRORS = (
httpx.TransportError,
httpx.TimeoutException,
ConnectionError,
OSError,
)
def pytest_configure(config):
config.addinivalue_line(
"markers",
"slow: marks tests that wait for multiple reconciliation cycles "
"(deselect with '-m \"not slow\"')",
)
def _kubectl(
*args: str,
check: bool = True,
capture: bool = True,
) -> subprocess.CompletedProcess:
cmd = ["kubectl", "--context", KUBECTL_CONTEXT, *args]
logger.debug("Running: %s", " ".join(cmd))
return subprocess.run(cmd, capture_output=capture, text=True, check=check)
def _apply_from_stdin(yaml_content: str) -> subprocess.CompletedProcess:
return subprocess.run(
["kubectl", "--context", KUBECTL_CONTEXT, "apply", "-f", "-"],
input=yaml_content,
capture_output=True,
text=True,
check=True,
)
def _wait_for_deployment_ready(
name: str,
namespace: str = NAMESPACE,
timeout: int = 180,
) -> None:
_kubectl(
"rollout",
"status",
f"deployment/{name}",
"-n",
namespace,
f"--timeout={timeout}s",
)
def _wait_for_pod_ready(
name: str,
namespace: str = NAMESPACE,
timeout: int = 120,
) -> None:
_kubectl(
"wait",
"--for=condition=Ready",
f"pod/{name}",
"-n",
namespace,
f"--timeout={timeout}s",
)
def _wait_for_port(port: int, proc: subprocess.Popen, timeout: int = 15) -> None:
"""Poll until a TCP connection to localhost:port succeeds."""
deadline = time.time() + timeout
while time.time() < deadline:
if proc.poll() is not None:
stderr = proc.stderr.read().decode() if proc.stderr else ""
raise RuntimeError(f"port-forward process exited early: {stderr}")
try:
with socket.create_connection(("127.0.0.1", port), timeout=1):
return
except OSError:
time.sleep(0.5)
raise TimeoutError(f"Port {port} not ready after {timeout}s")
def _port_forward_start(
namespace: str,
service: str,
local_port: int,
remote_port: int,
) -> subprocess.Popen:
"""Start kubectl port-forward and wait until the port is reachable."""
cmd = [
"kubectl",
"--context",
KUBECTL_CONTEXT,
"port-forward",
f"svc/{service}",
f"{local_port}:{remote_port}",
"-n",
namespace,
]
logger.info("Starting port-forward: %s", " ".join(cmd))
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
_wait_for_port(local_port, proc)
return proc
def _cleanup_port_forward(name: str, pf: subprocess.Popen) -> None:
try:
pf.terminate()
pf.wait(timeout=10)
except subprocess.TimeoutExpired:
logger.warning(
"Port-forward %s did not exit on SIGTERM after 10s; killing", name
)
pf.kill()
try:
pf.wait(timeout=5)
except subprocess.TimeoutExpired:
logger.warning("Port-forward %s still running after SIGKILL", name)
except Exception as exc:
logger.warning("Error cleaning up %s port-forward: %s", name, exc)
rc = pf.returncode
stderr = pf.stderr.read().decode() if pf.stderr else ""
if rc != -15:
suffix = f": {stderr.strip()}" if stderr.strip() else ""
logger.warning("Port-forward %s exited rc=%s%s", name, rc, suffix)
else:
logger.debug("Port-forward %s exited cleanly (rc=%s)", name, rc)
def _poll_until(
predicate,
description: str,
timeout: int,
interval: float = 5,
) -> bool:
"""Poll predicate until True, or raise TimeoutError.
Only transient network errors are retried; HTTP status errors and
programming errors propagate immediately.
"""
deadline = time.time() + timeout
last_error = None
attempts = 0
while time.time() < deadline:
try:
attempts += 1
if predicate():
logger.info(
"Condition met: %s (after %d attempts)", description, attempts
)
return True
except _TRANSIENT_ERRORS as exc:
last_error = exc
logger.debug("Transient error on attempt %d: %s", attempts, exc)
time.sleep(interval)
msg = f"Timeout waiting for: {description} (after {timeout}s, {attempts} attempts)"
if last_error:
msg += f" — last error: {last_error}"
raise TimeoutError(msg)
def _get_router_url(router_base: str) -> str:
return router_base
def _router_is_healthy(router_base: str) -> bool:
try:
r = httpx.get(f"{router_base}/healthz", timeout=3.0)
return r.status_code == 200
except Exception:
return False
@pytest.fixture(scope="session")
def k8s_cluster():
"""Assert the kind cluster exists and kubectl context is reachable."""
result = subprocess.run(
["kind", "get", "clusters"],
capture_output=True,
text=True,
check=True,
)
if CLUSTER_NAME not in result.stdout.splitlines():
pytest.skip(
f"kind cluster '{CLUSTER_NAME}' not found — run "
f"./tests/e2e/k8s_integration/setup.sh first"
)
_kubectl("cluster-info")
return True
@pytest.fixture(scope="function")
def router_port_forward(k8s_cluster):
"""Per-test port-forward to sgl-router service.
Function-scoped because some tests (notably
test_lifecycle.TestRouterRestart) force-delete the router pod;
a session-scoped port-forward would be bound to the deleted pod's
network namespace and stay dead for all subsequent tests in the
suite. Per-test setup costs ~1-2s.
"""
_wait_for_deployment_ready("sgl-router")
pf = _port_forward_start(NAMESPACE, "sgl-router", 8090, 8090)
try:
_poll_until(
lambda: _router_is_healthy("http://127.0.0.1:8090"),
"sgl-router /healthz returns 200",
timeout=30,
interval=1,
)
yield "http://127.0.0.1:8090"
finally:
_cleanup_port_forward("sgl-router", pf)
@pytest.fixture(scope="function")
def router_url(router_port_forward):
return router_port_forward
@@ -0,0 +1,73 @@
"""Minimal fake SGLang worker for kind E2E integration testing.
Responds to:
GET /health -> {"status": "ok"}
GET /server_info -> {"served_model_name": MODEL_ID}
GET /v1/models -> list with a single MODEL_ID model entry
POST /v1/chat/completions -> echoes the last user message back
"""
from __future__ import annotations
import os
import uvicorn
from fastapi import FastAPI, Request
app = FastAPI()
MODEL_ID = os.environ.get("MODEL_ID", "tiny")
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/server_info")
async def server_info():
# The sgl-router worker manager fetches this on every Added event and
# uses `served_model_name` to populate the registry's model index.
return {"served_model_name": MODEL_ID}
@app.get("/v1/models")
async def models():
return {
"object": "list",
"data": [
{
"id": MODEL_ID,
"object": "model",
"created": 0,
"owned_by": "sglang",
}
],
}
@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
payload = await request.json()
messages = payload.get("messages", [])
last_content = messages[-1]["content"] if messages else ""
return {
"id": "chatcmpl-mock",
"object": "chat.completion",
"model": payload.get("model", MODEL_ID),
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": f"echo: {last_content}",
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=30000)
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: sgl-router-test
@@ -0,0 +1,33 @@
# Cluster-wide RBAC for the cross-namespace discovery test.
# Distinct ServiceAccount/ClusterRole names to avoid collision with
# the namespace-scoped Role in rbac.yaml used by the default router.
apiVersion: v1
kind: ServiceAccount
metadata:
name: sgl-router-cluster
namespace: sgl-router-test
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: sgl-router-cluster
rules:
- apiGroups: ["discovery.k8s.io"]
resources: ["endpointslices"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["services", "pods"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: sgl-router-cluster
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: sgl-router-cluster
subjects:
- kind: ServiceAccount
name: sgl-router-cluster
namespace: sgl-router-test
@@ -0,0 +1,34 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: sgl-router
namespace: sgl-router-test
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: sgl-router
namespace: sgl-router-test
rules:
# EndpointSlice watch (k8s discovery backend)
- apiGroups: ["discovery.k8s.io"]
resources: ["endpointslices"]
verbs: ["get", "list", "watch"]
# Service list/watch (needed to resolve EndpointSlice owner)
- apiGroups: [""]
resources: ["services", "pods"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: sgl-router
namespace: sgl-router-test
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: sgl-router
subjects:
- kind: ServiceAccount
name: sgl-router
namespace: sgl-router-test
@@ -0,0 +1,60 @@
# sgl-router deployment with ClusterRole for cross-namespace discovery test.
# Watches workers in ALL namespaces via cluster-scoped EndpointSlice access.
apiVersion: apps/v1
kind: Deployment
metadata:
name: sgl-router-cluster
namespace: sgl-router-test
spec:
replicas: 1
selector:
matchLabels:
app: sgl-router-cluster
template:
metadata:
labels:
app: sgl-router-cluster
spec:
serviceAccountName: sgl-router-cluster
containers:
- name: router
image: sgl-router:e2e
imagePullPolicy: Never
args:
- "--config"
- "/etc/config/router-cluster.toml"
ports:
- containerPort: 8091
name: http
readinessProbe:
httpGet:
path: /readyz
port: 8091
initialDelaySeconds: 3
periodSeconds: 3
livenessProbe:
httpGet:
path: /healthz
port: 8091
initialDelaySeconds: 5
periodSeconds: 10
volumeMounts:
- name: config
mountPath: /etc/config
volumes:
- name: config
configMap:
name: sgl-router-cluster-config
---
apiVersion: v1
kind: Service
metadata:
name: sgl-router-cluster
namespace: sgl-router-test
spec:
selector:
app: sgl-router-cluster
ports:
- name: http
port: 8091
targetPort: 8091
@@ -0,0 +1,58 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: sgl-router
namespace: sgl-router-test
spec:
replicas: 1
selector:
matchLabels:
app: sgl-router
template:
metadata:
labels:
app: sgl-router
spec:
serviceAccountName: sgl-router
containers:
- name: router
image: sgl-router:e2e
imagePullPolicy: Never
args:
- "--config"
- "/etc/config/router.toml"
ports:
- containerPort: 8090
name: http
readinessProbe:
httpGet:
path: /readyz
port: 8090
initialDelaySeconds: 3
periodSeconds: 3
livenessProbe:
httpGet:
path: /healthz
port: 8090
initialDelaySeconds: 5
periodSeconds: 10
volumeMounts:
- name: config
mountPath: /etc/config
volumes:
- name: config
configMap:
name: sgl-router-config
---
apiVersion: v1
kind: Service
metadata:
name: sgl-router
namespace: sgl-router-test
spec:
selector:
app: sgl-router
ports:
- name: http
port: 8090
targetPort: 8090
@@ -0,0 +1,2 @@
httpx==0.27.2
pytest==8.3.3
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env bash
# Bootstrap a kind cluster for sgl-router K8s integration E2E tests.
#
# Prerequisites: Docker, kind, kubectl
#
# Usage:
# ./tests/e2e/k8s_integration/setup.sh # full setup
# ./tests/e2e/k8s_integration/setup.sh teardown # delete the cluster
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../../.." && pwd)" # repo root (above experimental/)
SGL_ROUTER_DIR="${REPO_ROOT}/experimental/sgl-router"
CLUSTER_NAME="${CLUSTER:-sgl-router-kind}"
NAMESPACE="${NAMESPACE:-sgl-router-test}"
CONTEXT="kind-${CLUSTER_NAME}"
MANIFESTS_DIR="${SCRIPT_DIR}/manifests"
log() { echo "==> $*"; }
teardown() {
log "Tearing down cluster '${CLUSTER_NAME}'..."
if kind get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME}$"; then
kind delete cluster --name "${CLUSTER_NAME}"
else
log "Cluster '${CLUSTER_NAME}' not found, nothing to tear down."
fi
log "Done."
}
if [[ "${1:-}" == "teardown" ]]; then
teardown
exit 0
fi
# ---------------------------------------------------------------------------
# Step 1: Create kind cluster (idempotent)
# ---------------------------------------------------------------------------
if kind get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME}$"; then
log "Kind cluster '${CLUSTER_NAME}' already exists — reusing."
else
log "Creating kind cluster '${CLUSTER_NAME}'..."
kind create cluster --name "${CLUSTER_NAME}" --wait 60s
fi
kubectl config use-context "${CONTEXT}"
# ---------------------------------------------------------------------------
# Step 2: Build Docker images (unless SKIP_DOCKER_BUILD=1)
# ---------------------------------------------------------------------------
if [[ "${SKIP_DOCKER_BUILD:-}" == "1" ]]; then
log "SKIP_DOCKER_BUILD=1 — skipping docker build; expecting images to exist locally."
for img in sgl-router:e2e sgl-router-fake-worker:e2e; do
if ! docker image inspect "${img}" >/dev/null 2>&1; then
log "ERROR: ${img} not found locally; cannot continue without building."
exit 1
fi
done
else
log "Building sgl-router:e2e from ${REPO_ROOT} ..."
docker build \
-f "${SCRIPT_DIR}/Dockerfile.router" \
-t sgl-router:e2e \
"${REPO_ROOT}"
log "Building sgl-router-fake-worker:e2e ..."
docker build \
-f "${SCRIPT_DIR}/Dockerfile.fake_worker" \
-t sgl-router-fake-worker:e2e \
"${SCRIPT_DIR}"
fi
# ---------------------------------------------------------------------------
# Step 3: Load images into kind
# ---------------------------------------------------------------------------
log "Loading images into kind cluster '${CLUSTER_NAME}'..."
kind load docker-image sgl-router:e2e --name "${CLUSTER_NAME}"
kind load docker-image sgl-router-fake-worker:e2e --name "${CLUSTER_NAME}"
# ---------------------------------------------------------------------------
# Step 4: Apply namespace and RBAC
# ---------------------------------------------------------------------------
log "Applying namespace and RBAC..."
kubectl --context "${CONTEXT}" apply -f "${MANIFESTS_DIR}/namespace.yaml"
kubectl --context "${CONTEXT}" apply -f "${MANIFESTS_DIR}/rbac.yaml"
# ---------------------------------------------------------------------------
# Step 5: Deploy 3 fake-worker replicas behind a Service
# The Service causes K8s to auto-create an EndpointSlice, which
# the sgl-router K8s discovery backend watches.
# ---------------------------------------------------------------------------
log "Deploying fake-worker Deployment + Service (3 replicas, app=sglang)..."
kubectl --context "${CONTEXT}" -n "${NAMESPACE}" apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: fake-worker
namespace: ${NAMESPACE}
labels:
app: sglang
spec:
replicas: 3
selector:
matchLabels:
app: sglang
template:
metadata:
labels:
app: sglang
spec:
containers:
- name: worker
image: sgl-router-fake-worker:e2e
imagePullPolicy: Never
ports:
- containerPort: 30000
readinessProbe:
httpGet:
path: /health
port: 30000
initialDelaySeconds: 2
periodSeconds: 3
---
apiVersion: v1
kind: Service
metadata:
name: fake-worker
namespace: ${NAMESPACE}
labels:
app: sglang
spec:
selector:
app: sglang
ports:
- port: 30000
targetPort: 30000
EOF
log "Waiting for fake-worker rollout..."
kubectl --context "${CONTEXT}" -n "${NAMESPACE}" rollout status deployment/fake-worker --timeout=120s
# ---------------------------------------------------------------------------
# Step 6: Create sgl-router ConfigMap with k8s discovery pointing at the
# namespace where fake-worker pods live.
# ---------------------------------------------------------------------------
log "Creating sgl-router-config ConfigMap..."
ROUTER_CONFIG="[server]
host = \"0.0.0.0\"
port = 8090
[[models]]
id = \"tiny\"
tokenizer_path = \"/etc/tokenizer/tiny.json\"
policy = \"round_robin\"
# Aggressive breaker so a terminating pod's connection-refused
# immediately excludes it from the next request's candidate set —
# the reconciliation tests scale workers rapidly and depend on
# fast worker eviction to absorb the churn.
circuit_breaker = { threshold = 1, cool_down_secs = 5 }
[discovery]
backend = \"k8s\"
[discovery.k8s]
namespace = \"${NAMESPACE}\"
label_selector = \"app=sglang\""
kubectl --context "${CONTEXT}" -n "${NAMESPACE}" create configmap sgl-router-config \
--from-literal=router.toml="${ROUTER_CONFIG}" \
--dry-run=client -o yaml \
| kubectl --context "${CONTEXT}" apply -f -
# ---------------------------------------------------------------------------
# Step 7: Deploy sgl-router
# ---------------------------------------------------------------------------
log "Deploying sgl-router..."
kubectl --context "${CONTEXT}" apply -f "${MANIFESTS_DIR}/router.yaml"
log "Waiting for sgl-router rollout..."
kubectl --context "${CONTEXT}" -n "${NAMESPACE}" rollout status deployment/sgl-router --timeout=300s
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
log ""
log "Setup complete! Run the integration tests with:"
log " pytest tests/e2e/k8s_integration/ -v -s"
log ""
log "To tear down:"
log " ./tests/e2e/k8s_integration/setup.sh teardown"
@@ -0,0 +1,265 @@
"""Cross-namespace service discovery integration test.
Validates that a sgl-router instance with cluster-wide RBAC and no namespace
filter in its k8s discovery config watches EndpointSlices in all namespaces.
Workers deployed in a second namespace (sgl-router-test-extra) must be
discovered alongside those in the primary namespace.
This test deploys a separate router Deployment (sgl-router-cluster) with a
ClusterRole that grants EndpointSlice access across all namespaces.
Run with:
pytest tests/e2e/k8s_integration/test_cross_namespace.py -v -s
"""
from __future__ import annotations
import json
import logging
import subprocess
from pathlib import Path
import httpx
import pytest
from conftest import (
KUBECTL_CONTEXT,
NAMESPACE,
_apply_from_stdin,
_cleanup_port_forward,
_kubectl,
_poll_until,
_port_forward_start,
_wait_for_deployment_ready,
logger,
)
MANIFESTS_DIR = Path(__file__).parent / "manifests"
EXTRA_NAMESPACE = "sgl-router-test-extra"
CLUSTER_ROUTER_PORT = 8093
def _deploy_fake_worker_in_ns(name: str, namespace: str) -> None:
"""Deploy a fake-worker pod with imagePullPolicy=Never in the given namespace."""
pod_manifest = {
"apiVersion": "v1",
"kind": "Pod",
"metadata": {
"name": name,
"namespace": namespace,
"labels": {"app": "sglang", "cross-ns-test": "true"},
},
"spec": {
"containers": [
{
"name": "worker",
"image": "sgl-router-fake-worker:e2e",
"imagePullPolicy": "Never",
"ports": [{"containerPort": 30000}],
"readinessProbe": {
"httpGet": {"path": "/health", "port": 30000},
"initialDelaySeconds": 2,
"periodSeconds": 3,
},
}
]
},
}
proc = subprocess.run(
["kubectl", "--context", KUBECTL_CONTEXT, "apply", "-f", "-"],
input=json.dumps(pod_manifest),
capture_output=True,
text=True,
check=False,
)
if proc.returncode != 0:
raise RuntimeError(
f"Failed to deploy pod {name} in namespace {namespace} "
f"(rc={proc.returncode}): {proc.stderr.strip()!r}"
)
logger.info("Deployed worker %s in namespace %s", name, namespace)
def _safe_delete_pod(name: str, namespace: str) -> None:
try:
_kubectl(
"delete",
"pod",
name,
"-n",
namespace,
"--ignore-not-found",
"--force",
"--grace-period=0",
)
except Exception as exc:
logger.warning("Cleanup failed for pod %s in ns %s: %s", name, namespace, exc)
def _ensure_namespace(name: str) -> None:
manifest = {"apiVersion": "v1", "kind": "Namespace", "metadata": {"name": name}}
_apply_from_stdin(json.dumps(manifest))
def _ensure_service_in_ns(namespace: str, selector: str = "app=sglang") -> None:
"""Create a Service so K8s auto-creates an EndpointSlice for cross-ns workers.
Service `metadata.labels` propagates to the auto-created EndpointSlice's
labels — and the cluster-scoped router filters slices server-side by
`app=sglang,cross-ns-test=true`. Without those labels on the Service,
its EndpointSlice gets filtered out and the cross-ns worker is invisible.
"""
svc_manifest = {
"apiVersion": "v1",
"kind": "Service",
"metadata": {
"name": "fake-worker",
"namespace": namespace,
"labels": {"app": "sglang", "cross-ns-test": "true"},
},
"spec": {
"selector": {"app": "sglang", "cross-ns-test": "true"},
"ports": [{"port": 30000, "targetPort": 30000}],
},
}
_apply_from_stdin(json.dumps(svc_manifest))
def _can_route(router_url: str) -> bool:
try:
r = httpx.post(
f"{router_url}/v1/chat/completions",
json={
"model": "tiny",
"messages": [{"role": "user", "content": "cross-ns"}],
},
timeout=8.0,
)
return r.status_code == 200
except Exception:
return False
@pytest.fixture(scope="module")
def cluster_scoped_router(k8s_cluster):
"""Deploy the cluster-scoped RBAC + router, plus a second namespace."""
rbac_manifest = MANIFESTS_DIR / "rbac-cluster-scoped.yaml"
router_manifest = MANIFESTS_DIR / "router-cluster-scoped.yaml"
_kubectl("apply", "-f", str(rbac_manifest))
_ensure_namespace(EXTRA_NAMESPACE)
_ensure_service_in_ns(EXTRA_NAMESPACE)
# ConfigMap for the cluster-scoped router: empty namespace = watch all
cluster_config = """[server]
host = "0.0.0.0"
port = 8091
[[models]]
id = "tiny"
tokenizer_path = "/etc/tokenizer/tiny.json"
policy = "round_robin"
[discovery]
backend = "k8s"
[discovery.k8s]
namespace = ""
label_selector = "app=sglang,cross-ns-test=true"
"""
_kubectl(
"create",
"configmap",
"sgl-router-cluster-config",
f"--from-literal=router-cluster.toml={cluster_config}",
"-n",
NAMESPACE,
"--dry-run=client",
"-o",
"yaml",
check=True,
)
# pipe through apply
proc = _kubectl(
"create",
"configmap",
"sgl-router-cluster-config",
f"--from-literal=router-cluster.toml={cluster_config}",
"-n",
NAMESPACE,
"--dry-run=client",
"-o",
"yaml",
)
_apply_from_stdin(proc.stdout)
_kubectl("apply", "-f", str(router_manifest))
# The cluster-scoped router's /readyz blocks on registry-not-empty, so
# without at least one matching worker the rollout-status check below
# would hang for 180s. Deploy a "bootstrap" worker in EXTRA_NAMESPACE
# with the label_selector match (app=sglang,cross-ns-test=true) so the
# router's k8s discovery picks it up before the readiness probe runs.
# The test body adds a SECOND worker later to verify dynamic discovery.
bootstrap_worker = "cross-ns-worker-bootstrap"
_deploy_fake_worker_in_ns(bootstrap_worker, EXTRA_NAMESPACE)
pf = None
try:
_wait_for_deployment_ready("sgl-router-cluster")
pf = _port_forward_start(
NAMESPACE, "sgl-router-cluster", CLUSTER_ROUTER_PORT, 8091
)
yield f"http://127.0.0.1:{CLUSTER_ROUTER_PORT}"
finally:
if pf is not None:
_cleanup_port_forward("cluster_router", pf)
_safe_delete_pod(bootstrap_worker, EXTRA_NAMESPACE)
_kubectl(
"delete", "-f", str(router_manifest), "--ignore-not-found", check=False
)
_kubectl("delete", "-f", str(rbac_manifest), "--ignore-not-found", check=False)
_kubectl(
"delete",
"namespace",
EXTRA_NAMESPACE,
"--ignore-not-found",
"--wait=true",
"--timeout=60s",
check=False,
)
class TestClusterWideDiscovery:
"""Router with ClusterRole and no namespace filter sees workers in every namespace."""
def test_router_routes_to_worker_in_extra_namespace(self, cluster_scoped_router):
"""Deploy one fake-worker pod in the extra namespace behind a Service;
the cluster-scoped router must discover it (via its EndpointSlice) and
successfully route a chat completion to it."""
router_url = cluster_scoped_router
worker_name = "cross-ns-worker-extra"
try:
_deploy_fake_worker_in_ns(worker_name, EXTRA_NAMESPACE)
_poll_until(
lambda: _can_route(router_url),
"cluster-scoped router routes to worker in extra namespace",
timeout=60,
interval=3,
)
r = httpx.post(
f"{router_url}/v1/chat/completions",
json={
"model": "tiny",
"messages": [
{"role": "user", "content": "cross-namespace routing"}
],
},
timeout=15.0,
)
assert r.status_code == 200, f"expected 200, got {r.status_code}: {r.text}"
assert "echo:" in r.json()["choices"][0]["message"]["content"]
finally:
_safe_delete_pod(worker_name, EXTRA_NAMESPACE)
@@ -0,0 +1,84 @@
"""E2E: sgl-router K8s discovery — basic routing.
Verifies that sgl-router, configured with the k8s EndpointSlice backend,
discovers the 3 fake-worker replicas deployed by setup.sh and successfully
routes chat-completion requests to them.
"""
from __future__ import annotations
import httpx
import pytest
from conftest import NAMESPACE, _kubectl, _poll_until, logger
def _scale_fake_worker(replicas: int) -> None:
_kubectl(
"scale",
"deployment/fake-worker",
f"--replicas={replicas}",
"-n",
NAMESPACE,
)
def test_router_routes_chat_to_a_worker(router_url):
"""A /v1/chat/completions request through the router returns 200 with the
fake-worker echo payload, proving end-to-end routing works."""
r = httpx.post(
f"{router_url}/v1/chat/completions",
json={
"model": "tiny",
"messages": [{"role": "user", "content": "hello"}],
"stream": False,
},
timeout=15.0,
)
assert r.status_code == 200, f"expected 200, got {r.status_code}: {r.text}"
body = r.json()
assert "echo:" in body["choices"][0]["message"]["content"]
def test_router_lists_model(router_url):
"""GET /v1/models returns the 'tiny' model entry from the router config."""
r = httpx.get(f"{router_url}/v1/models", timeout=10.0)
assert r.status_code == 200, f"expected 200, got {r.status_code}: {r.text}"
body = r.json()
ids = [m["id"] for m in body["data"]]
assert "tiny" in ids, f"expected 'tiny' in model list, got {ids}"
def test_router_discovers_multiple_workers(router_url):
"""Scale down from 3 to 1 and back to 3 replicas; router must continue
routing successfully after each transition (EndpointSlice watch reflects
the change)."""
# First confirm baseline routing
r = httpx.post(
f"{router_url}/v1/chat/completions",
json={
"model": "tiny",
"messages": [{"role": "user", "content": "scale-test"}],
},
timeout=15.0,
)
assert r.status_code == 200
# Scale down to 1 — router should still route after reconverging
_scale_fake_worker(1)
_poll_until(
lambda: httpx.post(
f"{router_url}/v1/chat/completions",
json={
"model": "tiny",
"messages": [{"role": "user", "content": "post-scale-down"}],
},
timeout=10.0,
).status_code
== 200,
"router routes after scale-down to 1",
timeout=60,
interval=3,
)
# Restore to 3
_scale_fake_worker(3)
@@ -0,0 +1,150 @@
"""Worker lifecycle integration tests.
Covers:
1. Scaling replicas up — new EndpointSlice entries are discovered.
2. Scaling replicas down — removed endpoints are deregistered.
3. Router restart — after the router pod is killed, the Deployment restarts
it and it re-lists the existing EndpointSlice entries without duplicates.
These tests DO NOT use a /workers admin API (sgl-router does not expose
one). They verify behaviour through /v1/chat/completions responses and
by driving the deployment scale.
"""
from __future__ import annotations
import logging
import httpx
import pytest
from conftest import (
NAMESPACE,
_cleanup_port_forward,
_kubectl,
_poll_until,
_port_forward_start,
_wait_for_deployment_ready,
logger,
)
ROUTER_RESTART_PORT = 8092
def _scale(deployment: str, replicas: int) -> None:
_kubectl(
"scale", f"deployment/{deployment}", f"--replicas={replicas}", "-n", NAMESPACE
)
def _can_route(router_url: str) -> bool:
try:
r = httpx.post(
f"{router_url}/v1/chat/completions",
json={"model": "tiny", "messages": [{"role": "user", "content": "ping"}]},
timeout=8.0,
)
return r.status_code == 200
except Exception:
return False
class TestScaleUp:
"""Scaling fake-worker replicas up must not break routing."""
def test_router_routes_after_scale_up(self, router_url):
"""Restore 3 replicas (in case a prior test left 1), verify routing."""
_scale("fake-worker", 3)
_poll_until(
lambda: _can_route(router_url),
"router routes after scale-up to 3",
timeout=60,
interval=3,
)
class TestScaleDown:
"""Scaling to 0 then back up must restore routing."""
def test_router_recovers_after_scale_to_zero_and_back(self, router_url):
try:
_scale("fake-worker", 0)
# After scale-to-0 the router may return 503 (no healthy workers)
# That is expected behaviour — assert it transitions back on scale-up.
_scale("fake-worker", 2)
_poll_until(
lambda: _can_route(router_url),
"router routes again after scale-up from 0",
timeout=90,
interval=3,
)
finally:
_scale("fake-worker", 3)
class TestRouterRestart:
"""Killing the router pod forces a Deployment restart; the new pod must
re-discover workers via the EndpointSlice watch without duplicates."""
def test_router_rediscovers_workers_after_restart(self, k8s_cluster):
# Use a dedicated port to avoid clashing with the session fixture
pf_holder: list = [None]
try:
_wait_for_deployment_ready("sgl-router")
pf_holder[0] = _port_forward_start(
NAMESPACE, "sgl-router", ROUTER_RESTART_PORT, 8090
)
restart_url = f"http://127.0.0.1:{ROUTER_RESTART_PORT}"
# Baseline: routing works pre-restart
_poll_until(
lambda: _can_route(restart_url),
"baseline routing works pre-restart",
timeout=30,
interval=2,
)
# Kill the router pod — the Deployment ReplicaSet will restart it
res = _kubectl(
"get",
"pod",
"-n",
NAMESPACE,
"-l",
"app=sgl-router",
"-o",
"jsonpath={.items[0].metadata.name}",
check=False,
)
old_pod = res.stdout.strip()
if old_pod:
_kubectl(
"delete",
"pod",
old_pod,
"-n",
NAMESPACE,
"--force",
"--grace-period=0",
)
# Tear down the old port-forward before waiting for the new pod
if pf_holder[0] is not None:
_cleanup_port_forward("router-restart-pre-kill", pf_holder[0])
pf_holder[0] = None
_wait_for_deployment_ready("sgl-router")
pf_holder[0] = _port_forward_start(
NAMESPACE, "sgl-router", ROUTER_RESTART_PORT, 8090
)
# After restart, routing must come back (EndpointSlice re-watch)
_poll_until(
lambda: _can_route(restart_url),
"routing restored after router restart",
timeout=60,
interval=3,
)
finally:
if pf_holder[0] is not None:
_cleanup_port_forward("router-restart", pf_holder[0])
@@ -0,0 +1,163 @@
"""K8s discovery reconciliation integration tests.
Tests verify that:
1. The K8s EndpointSlice watcher correctly discovers new workers as Services
and backing Deployments are updated.
2. Workers are removed from the router's registry after the backing EndpointSlice
entries disappear (pod deleted / deployment scaled to 0).
3. After a simulated watch-connection interruption (router restarted), the
registry converges back to the correct worker set.
Note: sgl-router does not currently expose a Prometheus /metrics endpoint,
so the SMG-style metric assertions are not used here. Disconnect/reconnect
coverage is provided by test_lifecycle.TestRouterRestart.
"""
from __future__ import annotations
import logging
import time
import httpx
import pytest
from conftest import (
NAMESPACE,
RECONCILIATION_WAIT_SECS,
_kubectl,
_poll_until,
logger,
)
def _scale_fake_worker(replicas: int) -> None:
_kubectl(
"scale", "deployment/fake-worker", f"--replicas={replicas}", "-n", NAMESPACE
)
def _can_route(router_url: str) -> bool:
try:
r = httpx.post(
f"{router_url}/v1/chat/completions",
json={
"model": "tiny",
"messages": [{"role": "user", "content": "reconcile"}],
},
timeout=8.0,
)
return r.status_code == 200
except Exception:
return False
class TestWatcherDiscovery:
"""The EndpointSlice watcher discovers new endpoints on Deployment scale-up."""
def test_watcher_discovers_new_endpoints_on_scale_up(self, router_url):
"""Scale from 1 to 3 replicas; router must continue routing successfully."""
_scale_fake_worker(1)
# Wait for scale-down to propagate and routing to stabilise
_poll_until(
lambda: _can_route(router_url),
"router routes with 1 replica",
timeout=60,
interval=3,
)
_scale_fake_worker(3)
_poll_until(
lambda: _can_route(router_url),
"router routes with 3 replicas (after scale-up)",
timeout=60,
interval=3,
)
class TestStaleEndpointRemoval:
"""When fake-worker replicas drop, the router must stop routing to the
removed endpoints.
Because sgl-router has no /workers admin API, we verify removal
indirectly: scale to 0, assert the router returns non-200 (or at least
that scaling back to 2 restores routing), then restore.
"""
def test_routing_restores_after_scale_down_and_back_up(self, router_url):
"""Scale to 0 (no workers → expect non-200), then restore to 2.
After restore the router must route again within the reconciliation window.
"""
try:
_scale_fake_worker(0)
# Expect routing to fail eventually (503 or connection error)
deadline = time.time() + RECONCILIATION_WAIT_SECS
routing_failed = False
while time.time() < deadline:
try:
r = httpx.post(
f"{router_url}/v1/chat/completions",
json={
"model": "tiny",
"messages": [{"role": "user", "content": "no-workers"}],
},
timeout=5.0,
)
if r.status_code != 200:
routing_failed = True
break
except Exception:
routing_failed = True
break
time.sleep(3)
# If after RECONCILIATION_WAIT_SECS the router is still routing,
# that means old endpoints are cached — not necessarily wrong for
# a watcher that hasn't ticked yet, but log a warning.
if not routing_failed:
logger.warning(
"Router still returning 200 after scale-to-0; "
"EndpointSlice event may be delayed — continuing test."
)
# Restore workers and verify routing comes back
_scale_fake_worker(2)
_poll_until(
lambda: _can_route(router_url),
"routing restored after scale back up to 2",
timeout=RECONCILIATION_WAIT_SECS,
interval=3,
)
finally:
_scale_fake_worker(3)
class TestReconciliationConsistency:
"""Routing remains stable over multiple reconciliation windows with steady
worker state — no spurious deregistrations or duplicate registrations."""
@pytest.mark.slow
def test_routing_stable_over_multiple_reconciliation_cycles(self, router_url):
"""Deploy 3 workers, sample routing success over ~150s (2 reconciliation
cycles + margin), assert no interruptions."""
_scale_fake_worker(3)
_poll_until(
lambda: _can_route(router_url),
"baseline routing with 3 workers",
timeout=30,
interval=2,
)
# Sample every 15s for 150s
wait_secs = RECONCILIATION_WAIT_SECS + 60
end_time = time.time() + wait_secs
failures = []
while time.time() < end_time:
ok = _can_route(router_url)
if not ok:
failures.append(time.time())
time.sleep(15)
assert not failures, (
f"Routing failed at {len(failures)} sample(s) during stability window; "
f"timestamps: {failures}"
)

Some files were not shown because too many files have changed in this diff Show More