[Feature] Add process-local in-memory KV indexer and Router integration (#33370)
Co-authored-by: Wu, Yutong <yutong.wu@amd.com> Co-authored-by: TianDi101 <ditian12@amd.com> Co-authored-by: Zhangheng <hzh0425@apache.org>
This commit is contained in:
co-authored by
Wu, Yutong
TianDi101
Zhangheng
parent
238ba40c27
commit
360d10d6bc
@@ -109,6 +109,7 @@ async fn zmq_indexer_routes_to_publishing_worker_e2e() {
|
||||
cache_threshold: 0.0,
|
||||
balance_abs_threshold: 32,
|
||||
balance_rel_threshold: 1.1,
|
||||
kv_indexer_endpoint: None,
|
||||
},
|
||||
kv_index.tree(),
|
||||
Arc::clone(&tokenizers),
|
||||
|
||||
+153
-86
@@ -1,44 +1,28 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Content-based cross-router routing test for cache-aware-zmq.
|
||||
"""Content-based routing test for both cache-aware-zmq index backends.
|
||||
|
||||
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.
|
||||
Two SGLang workers publish KV events to two routers at once: one runs the
|
||||
local ``KvEventIndex`` (SUB straight to the workers) and one runs against an
|
||||
external KV Indexer fed by a ``kv-indexer-bridge`` per worker. Every
|
||||
subscriber attaches before the single warmup, so one pair of disjoint
|
||||
prefixes exercises both index backends without a second model load.
|
||||
|
||||
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.
|
||||
Assert on content, not on convergence: a broken event path degrades
|
||||
``cache_aware_zmq`` to content-blind min-load, which routes both prefixes to
|
||||
one worker and so fails at least one assertion below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -79,6 +63,69 @@ _REQ_TOTAL_RE = re.compile(
|
||||
_LABEL_RE = re.compile(r'(\w+)="([^"]*)"')
|
||||
|
||||
|
||||
def _open_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _run(binary: Path, env: dict[str, str], log_path: Path):
|
||||
with log_path.open("w") as log:
|
||||
process = subprocess.Popen(
|
||||
[str(binary)],
|
||||
env={**os.environ, **env},
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
yield process
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
|
||||
|
||||
def _wait_for_indexer(process: subprocess.Popen, port: int, log_path: Path) -> None:
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(
|
||||
f"KV Indexer exited during startup:\n{log_path.read_text()}"
|
||||
)
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError("timed out waiting for KV Indexer")
|
||||
|
||||
|
||||
def _wait_for_bridge(process: subprocess.Popen, log_path: Path) -> None:
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
output = log_path.read_text(errors="replace")
|
||||
if "bridge session established" in output:
|
||||
# ZMQ connect is asynchronous; let the subscription reach the PUB.
|
||||
time.sleep(0.5)
|
||||
return
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"KV Indexer Bridge exited during startup:\n{output}")
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"timed out waiting for KV Indexer Bridge:\n{output}")
|
||||
|
||||
|
||||
def _dump_logs(logs: dict[str, Path]) -> None:
|
||||
"""Print the tail of each Indexer/Bridge log so a routing failure is debuggable."""
|
||||
for name, path in logs.items():
|
||||
tail = path.read_text(errors="replace")[-4000:] if path.exists() else "<no log>"
|
||||
print(f"\n----- {name} -----\n{tail}")
|
||||
|
||||
|
||||
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)
|
||||
@@ -173,26 +220,21 @@ def _route_through(router_url: str, model_id: str, prompt: str) -> str:
|
||||
|
||||
@pytest.mark.real_gpu
|
||||
@pytest.mark.slow
|
||||
def test_two_routers_route_by_prefix_content(
|
||||
router_binary, # noqa: ARG001 — fixture forces release-binary presence
|
||||
def test_routers_route_by_prefix_content(
|
||||
router_binary,
|
||||
gpu_allocator,
|
||||
tmp_path,
|
||||
):
|
||||
"""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.
|
||||
"""
|
||||
"""Both the local ZMQ index and the external Indexer must route by content."""
|
||||
spec = get_model_spec("qwen3-0.6b")
|
||||
gpus = gpu_allocator.acquire(2)
|
||||
# Workers run with the model's REAL chat template (no override): the engine
|
||||
# caches chat-templated tokens, and the router renders the same template
|
||||
# (loaded from the model's tokenizer_config.json) before hashing. This
|
||||
# exercises the production chat-template tokenization path, which aligns
|
||||
# router query hashes with the engine's templated blocks.
|
||||
indexer_port = _open_port()
|
||||
indexer_endpoint = f"http://127.0.0.1:{indexer_port}"
|
||||
indexer_binary = router_binary.parent / "kv-indexer-server"
|
||||
bridge_binary = router_binary.parent / "kv-indexer-bridge"
|
||||
logs = {
|
||||
name: tmp_path / f"{name}.log" for name in ("indexer", "bridge-x", "bridge-y")
|
||||
}
|
||||
try:
|
||||
with (
|
||||
spawn_worker(
|
||||
@@ -205,54 +247,79 @@ def test_two_routers_route_by_prefix_content(
|
||||
gpu_ids=[gpus[1]],
|
||||
enable_kv_events=True,
|
||||
) as worker_y,
|
||||
Gateway() as router_a,
|
||||
Gateway() as router_b,
|
||||
_run(
|
||||
indexer_binary,
|
||||
{"KV_INDEXER_LISTEN_ADDR": f"127.0.0.1:{indexer_port}"},
|
||||
logs["indexer"],
|
||||
) as indexer,
|
||||
):
|
||||
_wait_for_indexer(indexer, indexer_port, logs["indexer"])
|
||||
worker_urls = [worker_x.url, worker_y.url]
|
||||
for gw in (router_a, router_b):
|
||||
gw.start_regular(
|
||||
|
||||
def bridge_env(worker, worker_id: str) -> dict[str, str]:
|
||||
assert worker.kv_events_endpoint is not None
|
||||
return {
|
||||
"KV_INDEXER_WORKER_ID": worker_id,
|
||||
"KV_INDEXER_WORKER_ADDRESS": worker.url,
|
||||
"KV_INDEXER_ENDPOINT": indexer_endpoint,
|
||||
"SGLANG_KV_EVENT_ENDPOINT": worker.kv_events_endpoint.replace(
|
||||
"*", "127.0.0.1"
|
||||
),
|
||||
"SGLANG_KV_EVENT_TOPIC": "kv",
|
||||
}
|
||||
|
||||
with (
|
||||
_run(
|
||||
bridge_binary, bridge_env(worker_x, "worker-x"), logs["bridge-x"]
|
||||
) as bridge_x,
|
||||
_run(
|
||||
bridge_binary, bridge_env(worker_y, "worker-y"), logs["bridge-y"]
|
||||
) as bridge_y,
|
||||
Gateway() as local,
|
||||
Gateway() as external,
|
||||
):
|
||||
local.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."
|
||||
external.start_regular(
|
||||
model_id=spec["model"],
|
||||
tokenizer_path=spec["model"],
|
||||
worker_urls=worker_urls,
|
||||
policy="cache_aware_zmq",
|
||||
kv_indexer_endpoint=indexer_endpoint,
|
||||
timeout=120.0,
|
||||
)
|
||||
|
||||
_wait_for_bridge(bridge_x, logs["bridge-x"])
|
||||
_wait_for_bridge(bridge_y, logs["bridge-y"])
|
||||
|
||||
_direct_warm(worker_x.url, spec["model"], PREFIX_X)
|
||||
_direct_warm(worker_y.url, spec["model"], PREFIX_Y)
|
||||
time.sleep(2.0)
|
||||
|
||||
try:
|
||||
for router, label in (
|
||||
(local, "local-index"),
|
||||
(external, "external-indexer"),
|
||||
):
|
||||
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.url}; landed on {landed}"
|
||||
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.url}; landed on {landed}"
|
||||
except Exception:
|
||||
_dump_logs(logs)
|
||||
raise
|
||||
finally:
|
||||
gpu_allocator.release(gpus)
|
||||
|
||||
@@ -172,6 +172,7 @@ class Gateway:
|
||||
tokenizer_path: str,
|
||||
worker_urls: list[str],
|
||||
policy: str = "round_robin",
|
||||
kv_indexer_endpoint: str | None = None,
|
||||
timeout: float = 60.0,
|
||||
) -> None:
|
||||
"""Start the router in regular (non-PD) mode.
|
||||
@@ -186,6 +187,7 @@ class Gateway:
|
||||
metadata are learned from ``/server_info``.
|
||||
policy: Policy kind — ``round_robin``, ``random``, ``power_of_two``,
|
||||
or ``cache_aware_zmq``.
|
||||
kv_indexer_endpoint: Optional external KV Indexer gRPC endpoint.
|
||||
timeout: How long to wait for ``/readyz`` before giving up.
|
||||
"""
|
||||
self._launch(
|
||||
@@ -194,6 +196,7 @@ class Gateway:
|
||||
tokenizer_path=tokenizer_path,
|
||||
urls=list(worker_urls),
|
||||
policy=policy,
|
||||
kv_indexer_endpoint=kv_indexer_endpoint,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
@@ -293,6 +296,7 @@ class Gateway:
|
||||
tokenizer_path: str,
|
||||
urls: list[str],
|
||||
policy: str,
|
||||
kv_indexer_endpoint: str | None = None,
|
||||
) -> list[str]:
|
||||
resolved_tokenizer = _resolve_tokenizer_path(tokenizer_path)
|
||||
|
||||
@@ -317,6 +321,8 @@ class Gateway:
|
||||
"--stale-request-timeout-secs",
|
||||
str(self.stale_request_timeout_secs),
|
||||
]
|
||||
if kv_indexer_endpoint is not None:
|
||||
args += ["--kv-indexer-endpoint", kv_indexer_endpoint]
|
||||
# `--worker-urls` is multi-valued; keep it last so clap doesn't
|
||||
# absorb a following flag as a URL.
|
||||
args += ["--worker-urls", *urls]
|
||||
|
||||
@@ -10,7 +10,11 @@ FROM rust:1.90-bookworm AS builder
|
||||
# `channel = "1.90"`.
|
||||
ENV RUSTUP_TOOLCHAIN=1.90.0
|
||||
|
||||
# libssl-dev + pkg-config ship with rust:1.90-bookworm already; no apt-get needed.
|
||||
# libssl-dev + pkg-config ship with rust:1.90-bookworm already; protoc does not,
|
||||
# and the Indexer's build script needs it to compile the KV-indexer protos.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends protobuf-compiler \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
||||
@@ -12,18 +12,10 @@
|
||||
//! doesn't render tool schemas, so its ids would diverge from the engine).
|
||||
//! * A request with multimodal (array) content → `input_ids` omitted (a text
|
||||
//! tokenizer can't represent image content).
|
||||
//!
|
||||
//! The model id contains `deepseek-v4` so the tokenizer registry auto-attaches
|
||||
//! the built-in V4 chat encoder — the engine-equivalent path — without a
|
||||
//! template fixture.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, CacheAwareConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig,
|
||||
PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use sgl_router::policies::factory::build_registry;
|
||||
use sgl_router::policies::kv_events::{BlockSizeOracle, HashTree};
|
||||
@@ -36,33 +28,9 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::common::cache_aware_fixture::{config, MODEL};
|
||||
use crate::common::mock_worker::MockWorker;
|
||||
|
||||
const MODEL: &str = "deepseek-v4-tiny";
|
||||
|
||||
fn config() -> Config {
|
||||
Config {
|
||||
server: ServerConfig {
|
||||
host: "0".into(),
|
||||
port: 0,
|
||||
},
|
||||
observability: ObservabilityConfig::default(),
|
||||
model: ModelConfig {
|
||||
id: MODEL.into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: PolicyKind::CacheAwareZmq,
|
||||
circuit_breaker: None,
|
||||
cache_aware: Some(CacheAwareConfig::default()),
|
||||
sticky: None,
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ctx(url: String) -> Arc<AppContext> {
|
||||
let cfg = config();
|
||||
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Shared router config for the cache-aware proxy tests.
|
||||
//!
|
||||
//! The model id contains `deepseek-v4` so the tokenizer registry auto-attaches the
|
||||
//! built-in V4 chat encoder — the engine-equivalent path — with no template fixture.
|
||||
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, CacheAwareConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig,
|
||||
PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
|
||||
pub const MODEL: &str = "deepseek-v4-tiny";
|
||||
|
||||
/// A single-model `cache_aware_zmq` router. Discovery is a placeholder because
|
||||
/// every caller installs its own `WorkerRegistry`.
|
||||
pub fn config() -> Config {
|
||||
Config {
|
||||
server: ServerConfig {
|
||||
host: "0".into(),
|
||||
port: 0,
|
||||
},
|
||||
observability: ObservabilityConfig::default(),
|
||||
model: ModelConfig {
|
||||
id: MODEL.into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: PolicyKind::CacheAwareZmq,
|
||||
circuit_breaker: None,
|
||||
cache_aware: Some(CacheAwareConfig::default()),
|
||||
sticky: None,
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@
|
||||
|
||||
//! Shared test harness re-exports.
|
||||
|
||||
pub mod cache_aware_fixture;
|
||||
pub mod mock_worker;
|
||||
pub mod streaming;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Full HTTP routing path backed by a real in-memory Indexer gRPC server.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::json;
|
||||
use sgl_kv_indexer::pb::kv_indexer_client::KvIndexerClient;
|
||||
use sgl_kv_indexer::pb::{
|
||||
ApplyExternalKvBatchRequest, ExternalKvAction, ExternalKvActionType, TierType,
|
||||
};
|
||||
use sgl_kv_indexer::{
|
||||
server_builder, GrpcPrefixIndex, InMemoryKvIndexerBackend, KvIndexerService, PrefixIndexConfig,
|
||||
};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use sgl_router::policies::factory::build_registry;
|
||||
use sgl_router::policies::kv_events::{compute_block_hashes, BlockSizeOracle, HashTree};
|
||||
use sgl_router::policies::request_tokens_for;
|
||||
use sgl_router::proxy::Proxy;
|
||||
use sgl_router::server::app::build_router;
|
||||
use sgl_router::server::app_context::AppContext;
|
||||
use sgl_router::tokenizer::TokenizerRegistry;
|
||||
use sgl_router::workers::WorkerRegistry;
|
||||
use tokio_stream::wrappers::TcpListenerStream;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::common::cache_aware_fixture::{config, MODEL};
|
||||
use crate::common::mock_worker::MockWorker;
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_indexer_routes_to_the_cached_worker() {
|
||||
let cached = MockWorker::start(vec![]).await;
|
||||
let uncached = MockWorker::start(vec![]).await;
|
||||
let cfg = config();
|
||||
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
|
||||
let body = json!({
|
||||
"model": MODEL,
|
||||
"messages": [{"role": "user", "content": "hello there friend"}],
|
||||
});
|
||||
let tokens = request_tokens_for(&tokenizers, &ModelId(MODEL.into()), &body)
|
||||
.expect("test prompt tokenizes");
|
||||
let hashes = compute_block_hashes(&tokens.ids, 1);
|
||||
assert!(!hashes.is_empty());
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let endpoint = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
server_builder()
|
||||
.add_service(KvIndexerService::new(InMemoryKvIndexerBackend::new()).into_server())
|
||||
.serve_with_incoming(TcpListenerStream::new(listener))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let mut indexer = KvIndexerClient::connect(endpoint.clone()).await.unwrap();
|
||||
indexer
|
||||
.apply_external_kv_batch(ApplyExternalKvBatchRequest {
|
||||
worker_id: "cached-worker".into(),
|
||||
seq: 1,
|
||||
actions: vec![ExternalKvAction {
|
||||
r#type: ExternalKvActionType::ActionReport as i32,
|
||||
tier: TierType::TierHbm as i32,
|
||||
hashes: hashes.clone(),
|
||||
component_masks: Vec::new(),
|
||||
block_sizes: Vec::new(),
|
||||
}],
|
||||
worker_address: cached.url.clone(),
|
||||
cache_spec: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let registry = Arc::new(WorkerRegistry::default());
|
||||
for url in [&cached.url, &uncached.url] {
|
||||
registry
|
||||
.add(WorkerSpec {
|
||||
id: WorkerId(url.clone()),
|
||||
url: url.clone(),
|
||||
mode: WorkerMode::Plain,
|
||||
model_ids: vec![ModelId(MODEL.into())],
|
||||
bootstrap_port: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let oracle = BlockSizeOracle::new();
|
||||
oracle.try_set(1).unwrap();
|
||||
let policies = Arc::new(
|
||||
build_registry(
|
||||
&cfg,
|
||||
Arc::new(HashTree::new()),
|
||||
Arc::clone(&tokenizers),
|
||||
Arc::clone(&oracle),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let mut ctx = AppContext::new(
|
||||
cfg,
|
||||
tokenizers,
|
||||
Arc::new(Proxy::new(Duration::from_secs(5)).unwrap()),
|
||||
registry,
|
||||
policies,
|
||||
);
|
||||
ctx.prefix_index = Some(Arc::new(
|
||||
GrpcPrefixIndex::new(PrefixIndexConfig {
|
||||
endpoint,
|
||||
query_deadline: Duration::from_secs(1),
|
||||
max_inflight: 4,
|
||||
})
|
||||
.unwrap(),
|
||||
));
|
||||
ctx.block_size_oracle = oracle;
|
||||
|
||||
let app = build_router(Arc::new(ctx));
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/chat/completions")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(cached.captured.lock().unwrap().last_body.is_some());
|
||||
assert!(uncached.captured.lock().unwrap().last_body.is_none());
|
||||
|
||||
server.abort();
|
||||
}
|
||||
@@ -12,6 +12,7 @@ mod common;
|
||||
|
||||
mod cache_aware_input_ids;
|
||||
mod chat_routing;
|
||||
mod external_indexer_routing;
|
||||
mod failover;
|
||||
mod graceful_shutdown;
|
||||
mod header_forwarding;
|
||||
|
||||
Reference in New Issue
Block a user