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:
co-authored by
Claude Opus 4.7
parent
aae04b1241
commit
6e8fe176be
+1
-1
@@ -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
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
target/
|
||||
*.rs.bk
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
@@ -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"]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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(®istry), 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(®istry), 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(®istry);
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 32–64 tokens each, and our `MAX_CHAT_BODY_BYTES`
|
||||
/// cap (1 MiB ≈ 250 k tokens) implies an upper bound around 4–8 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 (~30–60 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
|
||||
+60
@@ -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
@@ -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
Reference in New Issue
Block a user