Secondary tier implementation for PD disaggregation (#42285)

Signed-off-by: Liran Schour <lirans@il.ibm.com> Signed-off-by: liranschour <liranschour@users.noreply.github.com> Co-authored-by: Or Ozeri <or@ozery.com> Co-authored-by: Or Ozeri <oro@il.ibm.com>

liranschour committed Jun 30, 2026 at 07:51 UTC bec232a9146bfd61e2be83879b0b74b952818758
22 files changed +7471
docs/features/kv_offloading_usage.md
+14
@@ -120,6 +120,20 @@ To enable KV cache sharing between multiple vLLM instances using the same `root_
120 PYTHONHASHSEED=0 vllm serve ...
121 ```
122
123 +### P2P (Including P/D)
124 +
125 +The P2P tier (`type: "p2p"`) shares completed KV blocks between vLLM instances over RDMA via NIXL. Each instance binds a control socket on `host:port` and exchanges blocks directly with peers — no shared filesystem required.
126 +
127 +| Key | Required | Default | Notes |
128 +| --- | --- | --- | --- |
129 +| `type` | yes | — | Must be `p2p`. |
130 +| `host` | no | `0.0.0.0` | Address the control socket binds to. |
131 +| `port` | no | `7777` | Port for the control socket. Must be reachable from peers. |
132 +| `backends` | no | `["UCX"]` | NIXL transport backends. See [NixlConnector Usage Guide](nixl_connector_usage.md#selecting-a-nixl-transport-backend-plugin) for available backends and selection guidance. |
133 +| `num_threads` | no | `4` | NIXL agent worker threads. Only used when `backends` is UCX-only; ignored when any non-UCX backend is requested. |
134 +
135 +The `backends` and `num_threads` options mirror the conditional logic used by [`NixlConnector`](nixl_connector_usage.md#selecting-a-nixl-transport-backend-plugin): when any non-UCX backend is configured, NIXL is initialised with `backends=...`; otherwise it falls back to a UCX-only agent with the configured `num_threads`. This lets the P2P tier use a different transport (e.g. `MOONCAKE`, `GDS_MT`, `LIBFABRIC`) than the main `NixlConnector` running in the same process.
136 +
137 ## Tuning Tips
138
139 - `cpu_bytes_to_use`: a bigger CPU tier means fewer trips to slower secondary tiers and a higher hit rate. The value is total across all workers, not per-worker. Leave headroom for the rest of the host workload.
tests/v1/kv_offload/tiering/p2p/__init__.py
tests/v1/kv_offload/tiering/p2p/p2p_connector_proxy.py new
+331
@@ -0,0 +1,331 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""
4 +P2PConnector proxy server for OffloadingConnector + TieringOffloadingSpec.
5 +
6 +Unlike NixlConnector (which returns remote_host/remote_port in the prefill
7 +response), OffloadingConnector does not embed connector coordinates in its
8 +response. This proxy injects the prefiller's P2PConnector address into
9 +kv_transfer_params before forwarding the decode request so the decoder knows
10 +where to pull KV blocks from.
11 +
12 +Usage:
13 + .venv/bin/python p2p_connector_proxy.py \
14 + --port 8192 \
15 + --prefiller-host 127.0.0.1 --prefiller-port 8100 \
16 + --decoder-host 127.0.0.1 --decoder-port 8200 \
17 + --p2p-connector-host 127.0.0.1 --p2p-connector-port 7777
18 +"""
19 +
20 +import argparse
21 +import asyncio
22 +import itertools
23 +import logging
24 +import os
25 +import uuid
26 +from contextlib import asynccontextmanager
27 +
28 +import httpx
29 +from fastapi import FastAPI, Request
30 +from fastapi.responses import StreamingResponse
31 +
32 +logger = logging.getLogger(__name__)
33 +logger.setLevel(logging.DEBUG)
34 +
35 +
36 +@asynccontextmanager
37 +async def lifespan(app: FastAPI):
38 + app.state.prefill_clients = []
39 + app.state.decode_clients = []
40 +
41 + for i, (host, port) in enumerate(global_args.prefiller_instances):
42 + app.state.prefill_clients.append(
43 + {
44 + "client": httpx.AsyncClient(
45 + timeout=None,
46 + base_url=f"http://{host}:{port}/v1",
47 + limits=httpx.Limits(
48 + max_connections=None, max_keepalive_connections=None
49 + ),
50 + ),
51 + "host": host,
52 + "port": port,
53 + "id": i,
54 + }
55 + )
56 +
57 + for i, (host, port) in enumerate(global_args.decoder_instances):
58 + app.state.decode_clients.append(
59 + {
60 + "client": httpx.AsyncClient(
61 + timeout=None,
62 + base_url=f"http://{host}:{port}/v1",
63 + limits=httpx.Limits(
64 + max_connections=None, max_keepalive_connections=None
65 + ),
66 + ),
67 + "host": host,
68 + "port": port,
69 + "id": i,
70 + }
71 + )
72 +
73 + app.state.prefill_iterator = itertools.cycle(range(len(app.state.prefill_clients)))
74 + app.state.decode_iterator = itertools.cycle(range(len(app.state.decode_clients)))
75 +
76 + mode = "decoder-first" if global_args.decoder_first else "prefiller-first"
77 + pd_host = global_args.p2p_connector_host
78 + pd_port = global_args.p2p_connector_port
79 + print(
80 + f"Proxy ready [{mode}]: "
81 + f"{len(app.state.prefill_clients)} prefiller(s), "
82 + f"{len(app.state.decode_clients)} decoder(s). "
83 + f"P2PConnector at {pd_host}:{pd_port}"
84 + )
85 + yield
86 +
87 + for ci in app.state.prefill_clients:
88 + await ci["client"].aclose()
89 + for ci in app.state.decode_clients:
90 + await ci["client"].aclose()
91 +
92 +
93 +app = FastAPI(lifespan=lifespan)
94 +
95 +
96 +def parse_args():
97 + p = argparse.ArgumentParser()
98 + p.add_argument("--port", type=int, default=8192)
99 + p.add_argument("--host", type=str, default="127.0.0.1")
100 + p.add_argument("--prefiller-hosts", type=str, nargs="+", default=["127.0.0.1"])
101 + p.add_argument("--prefiller-ports", type=int, nargs="+", default=[8100])
102 + p.add_argument("--decoder-hosts", type=str, nargs="+", default=["127.0.0.1"])
103 + p.add_argument("--decoder-ports", type=int, nargs="+", default=[8200])
104 + # P2PConnector coordinates of the prefiller — injected into decode requests.
105 + p.add_argument(
106 + "--p2p-connector-host",
107 + type=str,
108 + default="127.0.0.1",
109 + help="Host of the prefiller's P2PConnector ZMQ socket",
110 + )
111 + p.add_argument(
112 + "--p2p-connector-port",
113 + type=int,
114 + default=7777,
115 + help="Port of the prefiller's P2PConnector ZMQ socket",
116 + )
117 + # P2PConnector coordinates of the decoder — injected into prefill requests
118 + # so the prefiller's submit_store can resolve the peer to push KV to.
119 + p.add_argument(
120 + "--decoder-p2p-connector-host",
121 + type=str,
122 + default="127.0.0.1",
123 + help="Host of the decoder's P2PConnector ZMQ socket",
124 + )
125 + p.add_argument(
126 + "--decoder-p2p-connector-port",
127 + type=int,
128 + default=7778,
129 + help="Port of the decoder's P2PConnector ZMQ socket",
130 + )
131 + p.add_argument(
132 + "--decoder-first",
133 + action="store_true",
134 + help="Send decode request before prefill so decoder is already "
135 + "waiting when KV blocks arrive (decoder-first mode)",
136 + )
137 + args = p.parse_args()
138 + if len(args.prefiller_hosts) != len(args.prefiller_ports):
139 + raise ValueError("Prefiller host/port count mismatch")
140 + if len(args.decoder_hosts) != len(args.decoder_ports):
141 + raise ValueError("Decoder host/port count mismatch")
142 + args.prefiller_instances = list(zip(args.prefiller_hosts, args.prefiller_ports))
143 + args.decoder_instances = list(zip(args.decoder_hosts, args.decoder_ports))
144 + return args
145 +
146 +
147 +def _get_next(app, service: str):
148 + if service == "prefill":
149 + return app.state.prefill_clients[next(app.state.prefill_iterator)]
150 + return app.state.decode_clients[next(app.state.decode_iterator)]
151 +
152 +
153 +def _auth_headers(request_id: str) -> dict:
154 + headers: dict = {"X-Request-Id": request_id}
155 + api_key = os.environ.get("OPENAI_API_KEY", "")
156 + if api_key:
157 + headers["Authorization"] = f"Bearer {api_key}"
158 + return headers
159 +
160 +
161 +async def _prefill(client_info, endpoint, req_data, request_id):
162 + """Send a prefill-only request (max_tokens=1) to the prefiller."""
163 + data = req_data.copy()
164 + data["kv_transfer_params"] = {
165 + "decode": {
166 + "kv_request_id": request_id,
167 + },
168 + }
169 + data["stream"] = False
170 + data["max_tokens"] = 1
171 + data.pop("max_completion_tokens", None)
172 + data.pop("stream_options", None)
173 + data.pop("min_tokens", None)
174 + data.pop("min_completion_tokens", None)
175 +
176 + headers = _auth_headers(request_id)
177 + resp = await client_info["client"].post(endpoint, json=data, headers=headers)
178 + resp.raise_for_status()
179 + await resp.aread()
180 + return resp
181 +
182 +
183 +async def _stream_decode(client_info, endpoint, req_data, request_id):
184 + headers = _auth_headers(request_id)
185 + async with client_info["client"].stream(
186 + "POST", endpoint, json=req_data, headers=headers
187 + ) as resp:
188 + resp.raise_for_status()
189 + async for chunk in resp.aiter_bytes():
190 + yield chunk
191 +
192 +
193 +async def _handle_completions(api: str, request: Request):
194 + try:
195 + req_data = await request.json()
196 + request_id = str(uuid.uuid4())
197 +
198 + prefill_client = _get_next(request.app, "prefill")
199 + await _prefill(prefill_client, api, req_data, request_id)
200 +
201 + # Inject the prefiller's P2PConnector address so the decoder can pull
202 + # KV blocks from it via the P2PConnector transport.
203 + req_data["kv_transfer_params"] = {
204 + "prefill": {
205 + "kv_request_id": request_id,
206 + "remote_host": global_args.p2p_connector_host,
207 + "remote_port": global_args.p2p_connector_port,
208 + },
209 + }
210 +
211 + decode_client = _get_next(request.app, "decode")
212 + logger.debug("prefill=%s decode=%s", prefill_client, decode_client)
213 +
214 + async def generate():
215 + async for chunk in _stream_decode(decode_client, api, req_data, request_id):
216 + yield chunk
217 +
218 + return StreamingResponse(generate(), media_type="application/json")
219 +
220 + except Exception as e:
221 + import sys
222 + import traceback
223 +
224 + print(f"Proxy error on {api}: {e}")
225 + print("".join(traceback.format_exception(*sys.exc_info())))
226 + raise
227 +
228 +
229 +async def _handle_completions_decoder_first(api: str, request: Request):
230 + """Decoder-first mode: send decode request before prefill.
231 +
232 + The decoder establishes its request and starts polling for KV blocks
233 + immediately. The prefill is then sent so the prefiller computes and
234 + pushes blocks to the already-waiting decoder.
235 + """
236 + try:
237 + req_data = await request.json()
238 + request_id = str(uuid.uuid4())
239 +
240 + prefill_client = _get_next(request.app, "prefill")
241 + decode_client = _get_next(request.app, "decode")
242 +
243 + decode_data = req_data.copy()
244 + decode_data["kv_transfer_params"] = {
245 + "prefill": {
246 + "kv_request_id": request_id,
247 + "remote_host": global_args.p2p_connector_host,
248 + "remote_port": global_args.p2p_connector_port,
249 + },
250 + }
251 +
252 + async def generate():
253 + queue: asyncio.Queue = asyncio.Queue()
254 +
255 + async def _run_decode():
256 + try:
257 + async for chunk in _stream_decode(
258 + decode_client, api, decode_data, request_id
259 + ):
260 + await queue.put(("data", chunk))
261 + except Exception as exc:
262 + await queue.put(("error", exc))
263 + finally:
264 + await queue.put(("done", None))
265 +
266 + # 1. Start decode request — decoder is now waiting for KV blocks
267 + asyncio.create_task(_run_decode())
268 +
269 + # 2. Send prefill — blocks are computed and pushed to the decoder
270 + try:
271 + await _prefill(prefill_client, api, req_data, request_id)
272 + except Exception as exc:
273 + logger.warning("decoder-first: prefill failed: %s", exc)
274 +
275 + logger.debug(
276 + "decoder-first: prefill done, streaming decode prefill=%s decode=%s",
277 + prefill_client,
278 + decode_client,
279 + )
280 +
281 + # 3. Stream the decode response
282 + while True:
283 + kind, value = await queue.get()
284 + if kind == "done":
285 + break
286 + if kind == "error":
287 + raise value # type: ignore[misc]
288 + yield value
289 +
290 + return StreamingResponse(generate(), media_type="application/json")
291 +
292 + except Exception as e:
293 + import sys
294 + import traceback
295 +
296 + print(f"Proxy error on {api}: {e}")
297 + print("".join(traceback.format_exception(*sys.exc_info())))
298 + raise
299 +
300 +
301 +def _route_handler(api: str):
302 + if global_args.decoder_first:
303 + return lambda req: _handle_completions_decoder_first(api, req)
304 + return lambda req: _handle_completions(api, req)
305 +
306 +
307 +@app.post("/v1/completions")
308 +async def completions(request: Request):
309 + return await _route_handler("/completions")(request)
310 +
311 +
312 +@app.post("/v1/chat/completions")
313 +async def chat_completions(request: Request):
314 + return await _route_handler("/chat/completions")(request)
315 +
316 +
317 +@app.get("/healthcheck")
318 +async def healthcheck():
319 + return {
320 + "status": "ok",
321 + "prefill_instances": len(app.state.prefill_clients),
322 + "decode_instances": len(app.state.decode_clients),
323 + }
324 +
325 +
326 +if __name__ == "__main__":
327 + global global_args
328 + global_args = parse_args()
329 + import uvicorn
330 +
331 + uvicorn.run(app, host=global_args.host, port=global_args.port)
tests/v1/kv_offload/tiering/p2p/run_accuracy_test.sh new
+312
@@ -0,0 +1,312 @@
1 +#!/bin/bash
2 +# SPDX-License-Identifier: Apache-2.0
3 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4 +#
5 +# Accuracy test driver for the p2p connector
6 +# (OffloadingConnector + TieringOffloadingSpec + p2p tier).
7 +#
8 +# Mirrors tests/v1/kv_connector/nixl_integration/run_accuracy_test.sh:
9 +# brings up N prefillers + M decoders on the local host, fronts them
10 +# with p2p_connector_proxy.py, then runs the connector-agnostic
11 +# test_accuracy.py (gsm8k via lm_eval) against the proxy.
12 +#
13 +# Knobs (env vars unless flagged otherwise):
14 +# MODEL_NAMES space-separated model list (default: Llama-3.2-1B-Instruct)
15 +# NUM_PREFILL_INSTANCES default 1
16 +# NUM_DECODE_INSTANCES default 1
17 +# PREFILLER_TP_SIZE default 1
18 +# DECODER_TP_SIZE default 1
19 +# GPU_MEMORY_UTILIZATION default 0.45
20 +# MAX_MODEL_LEN default 512
21 +# PREFILL_BLOCK_SIZE default 128
22 +# DECODE_BLOCK_SIZE default 128
23 +# CPU_BYTES default 209715200 (200 MB)
24 +# VLLM_SERVE_EXTRA_ARGS comma-separated extra args for vllm serve
25 +# --decoder-first toggle decoder-first proxy mode
26 +#
27 +# Examples:
28 +# bash tests/v1/kv_offload/tiering/p2p/run_accuracy_test.sh
29 +# NUM_PREFILL_INSTANCES=2 NUM_DECODE_INSTANCES=2 \
30 +# bash tests/v1/kv_offload/tiering/p2p/run_accuracy_test.sh
31 +# bash tests/v1/kv_offload/tiering/p2p/run_accuracy_test.sh --decoder-first
32 +
33 +set -xe
34 +
35 +# ---------------------------------------------------------------------------
36 +# Args
37 +# ---------------------------------------------------------------------------
38 +DECODER_FIRST="false"
39 +
40 +while [[ $# -gt 0 ]]; do
41 + case $1 in
42 + --decoder-first)
43 + DECODER_FIRST="true"
44 + shift 1
45 + ;;
46 + *)
47 + echo "Unknown option $1"
48 + echo "Usage: $0 [--decoder-first]"
49 + exit 1
50 + ;;
51 + esac
52 +done
53 +
54 +# ---------------------------------------------------------------------------
55 +# Models
56 +# ---------------------------------------------------------------------------
57 +MODEL_NAMES=${MODEL_NAMES:-}
58 +if [[ -n "$MODEL_NAMES" ]]; then
59 + # shellcheck disable=SC2206
60 + MODELS=($MODEL_NAMES)
61 +else
62 + MODELS=(
63 + "meta-llama/Llama-3.2-1B-Instruct"
64 + )
65 +fi
66 +
67 +# ---------------------------------------------------------------------------
68 +# Defaults
69 +# ---------------------------------------------------------------------------
70 +NUM_PREFILL_INSTANCES=${NUM_PREFILL_INSTANCES:-1}
71 +NUM_DECODE_INSTANCES=${NUM_DECODE_INSTANCES:-1}
72 +PREFILLER_TP_SIZE=${PREFILLER_TP_SIZE:-1}
73 +DECODER_TP_SIZE=${DECODER_TP_SIZE:-1}
74 +GPU_MEMORY_UTILIZATION=${GPU_MEMORY_UTILIZATION:-0.45}
75 +MAX_MODEL_LEN=${MAX_MODEL_LEN:-512}
76 +PREFILL_BLOCK_SIZE=${PREFILL_BLOCK_SIZE:-128}
77 +DECODE_BLOCK_SIZE=${DECODE_BLOCK_SIZE:-128}
78 +CPU_BYTES=${CPU_BYTES:-209715200}
79 +VLLM_SERVE_EXTRA_ARGS=${VLLM_SERVE_EXTRA_ARGS:-}
80 +
81 +# Base ports — per-instance offsets layered on top.
82 +PREFILL_HTTP_BASE=8100
83 +DECODE_HTTP_BASE=8200
84 +PREFILL_PD_BASE=7777
85 +DECODE_PD_BASE=$((PREFILL_PD_BASE + NUM_PREFILL_INSTANCES))
86 +PROXY_PORT=8192
87 +P2P_HOST=127.0.0.1
88 +
89 +# ---------------------------------------------------------------------------
90 +# Resolve repo root + venv (works in .venv and /workspace/venv pods)
91 +# ---------------------------------------------------------------------------
92 +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
93 +GIT_ROOT="${GIT_ROOT:-$(cd -- "${SCRIPT_DIR}/../../../../.." && pwd -P)}"
94 +
95 +if [[ -z "${VLLM_BIN:-}" ]]; then
96 + if [[ -x "${GIT_ROOT}/.venv/bin/vllm" ]]; then
97 + VLLM_BIN="${GIT_ROOT}/.venv/bin/vllm"
98 + elif [[ -x "/workspace/venv/bin/vllm" ]]; then
99 + VLLM_BIN="/workspace/venv/bin/vllm"
100 + else
101 + VLLM_BIN="$(command -v vllm)"
102 + fi
103 +fi
104 +if [[ -z "${PYTHON_BIN:-}" ]]; then
105 + if [[ -x "${GIT_ROOT}/.venv/bin/python" ]]; then
106 + PYTHON_BIN="${GIT_ROOT}/.venv/bin/python"
107 + elif [[ -x "/workspace/venv/bin/python" ]]; then
108 + PYTHON_BIN="/workspace/venv/bin/python"
109 + else
110 + PYTHON_BIN="$(command -v python3 || command -v python)"
111 + fi
112 +fi
113 +echo "Using vllm: ${VLLM_BIN}"
114 +echo "Using python: ${PYTHON_BIN}"
115 +
116 +SMI_BIN=$(command -v nvidia-smi || command -v rocm-smi || echo "")
117 +
118 +# Trap SIGINT/SIGTERM/EXIT to kill background jobs.
119 +trap 'kill $(jobs -pr) 2>/dev/null || true' SIGINT SIGTERM EXIT
120 +
121 +# ---------------------------------------------------------------------------
122 +# Helpers
123 +# ---------------------------------------------------------------------------
124 +wait_for_server() {
125 + local port=$1
126 + timeout 1200 bash -c "
127 + until curl -s localhost:${port}/v1/completions > /dev/null; do
128 + sleep 1
129 + done" && return 0 || return 1
130 +}
131 +
132 +cleanup_instances() {
133 + echo "Cleaning up any running vLLM / proxy instances..."
134 + pkill -f "vllm serve" || true
135 + pkill -f "p2p_connector_proxy.py" || true
136 + sleep 2
137 +}
138 +
139 +get_num_gpus() {
140 + if [[ "$SMI_BIN" == *"nvidia"* ]]; then
141 + $SMI_BIN --query-gpu=name --format=csv,noheader | wc -l
142 + elif [[ "$SMI_BIN" == *"rocm"* ]]; then
143 + $SMI_BIN -l | grep -c GPU
144 + else
145 + echo "1"
146 + fi
147 +}
148 +
149 +# Build the OffloadingConnector kv-transfer-config for a given PD port.
150 +# Mirrors deploy_local.sh:131.
151 +build_kv_config() {
152 + local pd_port=$1
153 + printf '{"kv_connector":"OffloadingConnector","kv_role":"kv_both","kv_connector_extra_config":{"spec_name":"TieringOffloadingSpec","cpu_bytes_to_use":%s,"secondary_tiers":[{"type":"p2p","host":"%s","port":%s}]}}' \
154 + "${CPU_BYTES}" "${P2P_HOST}" "${pd_port}"
155 +}
156 +
157 +# ---------------------------------------------------------------------------
158 +# Per-model run
159 +# ---------------------------------------------------------------------------
160 +run_tests_for_model() {
161 + local model_name=$1
162 + echo "================================"
163 + echo "Testing model: $model_name"
164 + echo " prefillers=${NUM_PREFILL_INSTANCES} (tp=${PREFILLER_TP_SIZE})"
165 + echo " decoders=${NUM_DECODE_INSTANCES} (tp=${DECODER_TP_SIZE})"
166 + echo " decoder_first=${DECODER_FIRST}"
167 + echo "================================"
168 +
169 + PREFILL_HOSTS=()
170 + PREFILL_PORTS=()
171 + PREFILL_PD_PORTS=()
172 + DECODE_HOSTS=()
173 + DECODE_PORTS=()
174 + DECODE_PD_PORTS=()
175 +
176 + local num_gpus
177 + num_gpus=$(get_num_gpus)
178 + local next_gpu=0
179 +
180 + # ---- Prefillers ----
181 + for i in $(seq 0 $((NUM_PREFILL_INSTANCES-1))); do
182 + local gpu_id=$((i * PREFILLER_TP_SIZE % num_gpus))
183 + local cuda_devs="${gpu_id}"
184 + for (( j=1; j < PREFILLER_TP_SIZE; j++ )); do
185 + cuda_devs="${cuda_devs},$(((gpu_id + j) % num_gpus))"
186 + done
187 + next_gpu=$(((gpu_id + PREFILLER_TP_SIZE) % num_gpus))
188 +
189 + local http_port=$((PREFILL_HTTP_BASE + i))
190 + local pd_port=$((PREFILL_PD_BASE + i))
191 + local kv_cfg
192 + kv_cfg=$(build_kv_config "${pd_port}")
193 +
194 + echo "Prefiller $i: gpu=[${cuda_devs}] http=${http_port} pd=${pd_port}"
195 +
196 + BASE_CMD="CUDA_VISIBLE_DEVICES=${cuda_devs} \
197 + PYTHONHASHSEED=42 \
198 + ${VLLM_BIN} serve ${model_name} \
199 + --port ${http_port} \
200 + --enforce-eager \
201 + --block-size ${PREFILL_BLOCK_SIZE} \
202 + --gpu-memory-utilization ${GPU_MEMORY_UTILIZATION} \
203 + --max-model-len ${MAX_MODEL_LEN} \
204 + --tensor-parallel-size ${PREFILLER_TP_SIZE} \
205 + --kv-transfer-config '${kv_cfg}'"
206 +
207 + if [[ -n "$VLLM_SERVE_EXTRA_ARGS" ]]; then
208 + IFS=',' read -r -a extra_args <<< "$VLLM_SERVE_EXTRA_ARGS"
209 + for arg in "${extra_args[@]}"; do
210 + BASE_CMD="${BASE_CMD} $arg"
211 + done
212 + fi
213 +
214 + eval "${BASE_CMD} &"
215 +
216 + PREFILL_HOSTS+=("${P2P_HOST}")
217 + PREFILL_PORTS+=("${http_port}")
218 + PREFILL_PD_PORTS+=("${pd_port}")
219 + done
220 +
221 + # ---- Decoders ----
222 + for i in $(seq 0 $((NUM_DECODE_INSTANCES-1))); do
223 + local gpu_id=$(((next_gpu + i * DECODER_TP_SIZE) % num_gpus))
224 + local cuda_devs="${gpu_id}"
225 + for (( j=1; j < DECODER_TP_SIZE; j++ )); do
226 + cuda_devs="${cuda_devs},$(((gpu_id + j) % num_gpus))"
227 + done
228 +
229 + local http_port=$((DECODE_HTTP_BASE + i))
230 + local pd_port=$((DECODE_PD_BASE + i))
231 + local kv_cfg
232 + kv_cfg=$(build_kv_config "${pd_port}")
233 +
234 + echo "Decoder $i: gpu=[${cuda_devs}] http=${http_port} pd=${pd_port}"
235 +
236 + BASE_CMD="CUDA_VISIBLE_DEVICES=${cuda_devs} \
237 + PYTHONHASHSEED=42 \
238 + ${VLLM_BIN} serve ${model_name} \
239 + --port ${http_port} \
240 + --enforce-eager \
241 + --block-size ${DECODE_BLOCK_SIZE} \
242 + --gpu-memory-utilization ${GPU_MEMORY_UTILIZATION} \
243 + --max-model-len ${MAX_MODEL_LEN} \
244 + --tensor-parallel-size ${DECODER_TP_SIZE} \
245 + --kv-transfer-config '${kv_cfg}'"
246 +
247 + if [[ -n "$VLLM_SERVE_EXTRA_ARGS" ]]; then
248 + IFS=',' read -r -a extra_args <<< "$VLLM_SERVE_EXTRA_ARGS"
249 + for arg in "${extra_args[@]}"; do
250 + BASE_CMD="${BASE_CMD} $arg"
251 + done
252 + fi
253 +
254 + eval "${BASE_CMD} &"
255 +
256 + DECODE_HOSTS+=("${P2P_HOST}")
257 + DECODE_PORTS+=("${http_port}")
258 + DECODE_PD_PORTS+=("${pd_port}")
259 + done
260 +
261 + # ---- Wait for HTTP readiness ----
262 + for port in "${PREFILL_PORTS[@]}"; do
263 + echo "Waiting for prefill instance on port $port to start..."
264 + wait_for_server "$port"
265 + done
266 + for port in "${DECODE_PORTS[@]}"; do
267 + echo "Waiting for decode instance on port $port to start..."
268 + wait_for_server "$port"
269 + done
270 +
271 + # ---- Proxy ----
272 + # The proxy currently advertises a single prefiller PD address to decoders.
273 + # For the 1xM and matched NxM common cases the first prefiller's PD coords
274 + # are the right pick; multi-prefiller PD round-robin is a follow-up.
275 + PROXY_CMD="${PYTHON_BIN} ${SCRIPT_DIR}/p2p_connector_proxy.py \
276 + --port ${PROXY_PORT} \
277 + --host ${P2P_HOST} \
278 + --prefiller-hosts ${PREFILL_HOSTS[*]} \
279 + --prefiller-ports ${PREFILL_PORTS[*]} \
280 + --decoder-hosts ${DECODE_HOSTS[*]} \
281 + --decoder-ports ${DECODE_PORTS[*]} \
282 + --p2p-connector-host ${P2P_HOST} \
283 + --p2p-connector-port ${PREFILL_PD_PORTS[0]} \
284 + --decoder-p2p-connector-host ${P2P_HOST} \
285 + --decoder-p2p-connector-port ${DECODE_PD_PORTS[0]}"
286 +
287 + if [[ "${DECODER_FIRST}" == "true" ]]; then
288 + PROXY_CMD="${PROXY_CMD} --decoder-first"
289 + fi
290 +
291 + echo "Starting proxy: ${PROXY_CMD}"
292 + eval "${PROXY_CMD} &"
293 +
294 + sleep 5
295 +
296 + # ---- Run accuracy test (reused from nixl_integration) ----
297 + echo "Running tests for $model_name"
298 + TEST_MODEL=$model_name "${PYTHON_BIN}" -m pytest -s -x \
299 + "${GIT_ROOT}/tests/v1/kv_connector/nixl_integration/test_accuracy.py"
300 +
301 + cleanup_instances
302 + sleep 3
303 +}
304 +
305 +# ---------------------------------------------------------------------------
306 +# Drive
307 +# ---------------------------------------------------------------------------
308 +for model in "${MODELS[@]}"; do
309 + run_tests_for_model "$model"
310 +done
311 +
312 +echo "All tests completed!"
tests/v1/kv_offload/tiering/p2p/test_data_transport.py new
+330
@@ -0,0 +1,330 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for DataTransport base class and NixlTransport."""
4 +
5 +from __future__ import annotations
6 +
7 +import ctypes
8 +from unittest.mock import MagicMock, patch
9 +
10 +import numpy as np
11 +
12 +from vllm.v1.kv_offload.tiering.p2p.data.base import PollResult
13 +from vllm.v1.kv_offload.tiering.p2p.data.nixl import NixlTransport
14 +
15 +# ---------------------------------------------------------------------------
16 +# DataTransport base class tests
17 +# ---------------------------------------------------------------------------
18 +
19 +
20 +class TestDataTransportBase:
21 + """Tests for the DataTransport abstract base properties."""
22 +
23 + def _make_view(self, num_blocks: int = 8, block_len: int = 1024) -> memoryview:
24 + """Create a memoryview with the given shape."""
25 + buf = np.zeros((num_blocks, block_len), dtype=np.uint8)
26 + return memoryview(buf)
27 +
28 + def test_properties(self):
29 + """base_addr, num_blocks, block_len are set from memoryview shape."""
30 + view = self._make_view(num_blocks=4, block_len=2048)
31 +
32 + # Use NixlTransport (concrete) with NIXL mocked away
33 + with patch("vllm.v1.kv_offload.tiering.p2p.data.nixl._NixlAgent", None):
34 + transport = NixlTransport("test:1", view)
35 +
36 + assert transport.num_blocks == 4
37 + assert transport.block_len == 2048
38 + assert transport.base_addr == ctypes.addressof(ctypes.c_char.from_buffer(view))
39 +
40 + def test_config_fingerprint_empty_when_no_fields(self):
41 + """No config fields → empty fingerprint."""
42 + view = self._make_view()
43 + with patch("vllm.v1.kv_offload.tiering.p2p.data.nixl._NixlAgent", None):
44 + transport = NixlTransport("test:1", view, config_fields=None)
45 + assert transport.config_fingerprint == ""
46 +
47 + def test_config_fingerprint_deterministic(self):
48 + """Same config fields → same fingerprint."""
49 + view = self._make_view()
50 + fields = {"model": "llama", "dtype": "float16", "block_size_factor": 1}
51 + with patch("vllm.v1.kv_offload.tiering.p2p.data.nixl._NixlAgent", None):
52 + t1 = NixlTransport("test:1", view, config_fields=fields)
53 + t2 = NixlTransport("test:2", view, config_fields=fields)
54 + assert t1.config_fingerprint == t2.config_fingerprint
55 + assert len(t1.config_fingerprint) == 16
56 +
57 + def test_config_fingerprint_differs_for_different_fields(self):
58 + """Different config fields → different fingerprint."""
59 + view = self._make_view()
60 + with patch("vllm.v1.kv_offload.tiering.p2p.data.nixl._NixlAgent", None):
61 + t1 = NixlTransport("test:1", view, config_fields={"model": "a"})
62 + t2 = NixlTransport("test:2", view, config_fields={"model": "b"})
63 + assert t1.config_fingerprint != t2.config_fingerprint
64 +
65 +
66 +# ---------------------------------------------------------------------------
67 +# NixlTransport tests (with mocked NIXL agent)
68 +# ---------------------------------------------------------------------------
69 +
70 +
71 +class TestNixlTransportWithMockedAgent:
72 + """Tests for NixlTransport logic with a mocked NIXL agent."""
73 +
74 + def _make_transport(self) -> NixlTransport:
75 + """Create a NixlTransport with mocked NIXL internals."""
76 + view = memoryview(np.zeros((8, 1024), dtype=np.uint8))
77 +
78 + with patch("vllm.v1.kv_offload.tiering.p2p.data.nixl._NixlAgent", None):
79 + transport = NixlTransport("test:1", view)
80 +
81 + # Manually set up a mock agent after construction
82 + agent = MagicMock()
83 + agent.add_remote_agent.return_value = "nixl-peer-name"
84 + agent.get_xfer_descs.return_value = MagicMock()
85 + agent.prep_xfer_dlist.return_value = MagicMock()
86 + agent.make_prepped_xfer.return_value = MagicMock(name="handle")
87 + agent.transfer.return_value = None
88 + agent.check_xfer_state.return_value = "PROC"
89 + agent.get_agent_metadata.return_value = b"test-metadata"
90 +
91 + transport._agent = agent
92 + transport._local_dlist = MagicMock()
93 + return transport
94 +
95 + def test_available_false_without_nixl(self):
96 + """Without NIXL installed, available is False."""
97 + view = memoryview(np.zeros((4, 512), dtype=np.uint8))
98 + with patch("vllm.v1.kv_offload.tiering.p2p.data.nixl._NixlAgent", None):
99 + transport = NixlTransport("test:1", view)
100 + assert transport.available is False
101 +
102 + def test_available_true_with_agent(self):
103 + transport = self._make_transport()
104 + assert transport.available is True
105 +
106 + def test_get_agent_metadata(self):
107 + transport = self._make_transport()
108 + assert transport.get_agent_metadata() == b"test-metadata"
109 +
110 + def test_write_blocks_returns_none_for_unknown_peer(self):
111 + """write_blocks returns None if peer not registered."""
112 + transport = self._make_transport()
113 + result = transport.write_blocks("unknown:1", [0, 1], [2, 3])
114 + assert result is None
115 +
116 + def test_write_blocks_returns_transfer_id(self):
117 + """write_blocks returns an integer transfer_id on success."""
118 + transport = self._make_transport()
119 + transport.add_remote_peer("peer:1", b"meta", 0x1000, 8, 1024)
120 +
121 + tid = transport.write_blocks("peer:1", [0, 1], [2, 3])
122 + assert tid is not None
123 + assert isinstance(tid, int)
124 +
125 + def test_write_blocks_increments_transfer_id(self):
126 + """Each write_blocks call gets a unique transfer_id."""
127 + transport = self._make_transport()
128 + transport.add_remote_peer("peer:1", b"meta", 0x1000, 8, 1024)
129 +
130 + tid1 = transport.write_blocks("peer:1", [0], [1])
131 + tid2 = transport.write_blocks("peer:1", [2], [3])
132 + assert tid1 != tid2
133 +
134 + def test_poll_empty_when_no_inflight(self):
135 + """poll returns empty when nothing is inflight."""
136 + transport = self._make_transport()
137 + result = transport.poll()
138 + assert result == PollResult(done=(), failed=())
139 +
140 + def test_poll_returns_done_when_transfer_completes(self):
141 + """Completed transfer appears in poll().done."""
142 + transport = self._make_transport()
143 + transport.add_remote_peer("peer:1", b"meta", 0x1000, 8, 1024)
144 +
145 + tid = transport.write_blocks("peer:1", [0], [1])
146 +
147 + # Simulate completion
148 + transport._agent.check_xfer_state.return_value = "DONE"
149 + result = transport.poll()
150 +
151 + assert tid in result.done
152 + assert result.failed == ()
153 + # Handle released
154 + transport._agent.release_xfer_handle.assert_called()
155 +
156 + def test_poll_returns_failed_for_error_state(self):
157 + """Transfer in error state appears in poll().failed."""
158 + transport = self._make_transport()
159 + transport.add_remote_peer("peer:1", b"meta", 0x1000, 8, 1024)
160 +
161 + tid = transport.write_blocks("peer:1", [0], [1])
162 +
163 + transport._agent.check_xfer_state.return_value = "ERR"
164 + result = transport.poll()
165 +
166 + assert result.done == ()
167 + assert tid in result.failed
168 +
169 + def test_poll_ignores_in_progress(self):
170 + """Transfers in PROC/PEND state stay inflight."""
171 + transport = self._make_transport()
172 + transport.add_remote_peer("peer:1", b"meta", 0x1000, 8, 1024)
173 +
174 + transport.write_blocks("peer:1", [0], [1])
175 +
176 + transport._agent.check_xfer_state.return_value = "PROC"
177 + result = transport.poll()
178 + assert result.done == ()
179 + assert result.failed == ()
180 +
181 + transport._agent.check_xfer_state.return_value = "PEND"
182 + result = transport.poll()
183 + assert result.done == ()
184 + assert result.failed == ()
185 +
186 + def test_cancel_removes_inflight(self):
187 + """cancel removes transfers and releases handles."""
188 + transport = self._make_transport()
189 + transport.add_remote_peer("peer:1", b"meta", 0x1000, 8, 1024)
190 +
191 + tid = transport.write_blocks("peer:1", [0], [1])
192 + assert tid in transport._inflight
193 +
194 + result = transport.cancel([tid])
195 + assert result == []
196 + assert tid not in transport._inflight
197 + transport._agent.release_xfer_handle.assert_called()
198 +
199 + def test_cancel_ignores_unknown_ids(self):
200 + """cancel with unknown IDs doesn't crash."""
201 + transport = self._make_transport()
202 + assert transport.cancel([999, 1000]) == []
203 + assert transport.cancel([999, 1000], mode="wait") == []
204 +
205 + def test_cancel_wait_release_succeeds(self):
206 + """wait-mode cancel that succeeds pops the entry and returns []."""
207 + transport = self._make_transport()
208 + transport.add_remote_peer("peer:1", b"meta", 0x1000, 8, 1024)
209 +
210 + tid = transport.write_blocks("peer:1", [0], [1])
211 + assert tid in transport._inflight
212 +
213 + result = transport.cancel([tid], mode="wait")
214 + assert result == []
215 + assert tid not in transport._inflight
216 + transport._agent.release_xfer_handle.assert_called_once()
217 +
218 + def test_cancel_wait_release_raises(self):
219 + """wait-mode cancel keeps the entry and returns the tid on raise."""
220 + transport = self._make_transport()
221 + transport.add_remote_peer("peer:1", b"meta", 0x1000, 8, 1024)
222 +
223 + tid = transport.write_blocks("peer:1", [0], [1])
224 + transport._agent.release_xfer_handle.side_effect = RuntimeError(
225 + "NIXL_ERR_REPOST_ACTIVE"
226 + )
227 +
228 + result = transport.cancel([tid], mode="wait")
229 + assert result == [tid]
230 + assert tid in transport._inflight
231 +
232 + def test_cancel_wait_then_poll_completes(self):
233 + """A wait-cancel that left a tid pending later completes via poll."""
234 + transport = self._make_transport()
235 + transport.add_remote_peer("peer:1", b"meta", 0x1000, 8, 1024)
236 +
237 + tid = transport.write_blocks("peer:1", [0], [1])
238 + transport._agent.release_xfer_handle.side_effect = RuntimeError("busy")
239 + assert transport.cancel([tid], mode="wait") == [tid]
240 + assert tid in transport._inflight
241 +
242 + transport._agent.release_xfer_handle.side_effect = None
243 + transport._agent.check_xfer_state.return_value = "DONE"
244 +
245 + result = transport.poll()
246 + assert tid in result.done
247 + assert tid not in transport._inflight
248 +
249 + def test_add_and_remove_remote_peer(self):
250 + transport = self._make_transport()
251 + transport.add_remote_peer("peer:1", b"meta", 0x1000, 8, 1024)
252 + assert "peer:1" in transport._remote_dlists
253 +
254 + transport.remove_remote_peer("peer:1")
255 + assert "peer:1" not in transport._remote_dlists
256 + transport._agent.release_dlist_handle.assert_called()
257 + transport._agent.remove_remote_agent.assert_called()
258 +
259 + def test_close_releases_everything(self):
260 + """close releases all handles and clears state."""
261 + transport = self._make_transport()
262 + transport.add_remote_peer("peer:1", b"meta", 0x1000, 8, 1024)
263 + transport.write_blocks("peer:1", [0], [1])
264 +
265 + transport.close()
266 + assert transport._agent is None
267 + assert transport._inflight == {}
268 + assert transport._remote_dlists == {}
269 +
270 +
271 +# ---------------------------------------------------------------------------
272 +# NIXL agent-config selection
273 +# ---------------------------------------------------------------------------
274 +
275 +
276 +class TestNixlAgentConfigSelection:
277 + """Tests that backends/num_threads pick the right nixl_agent_config call.
278 +
279 + Mirrors the conditional in
280 + vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:325-329.
281 + """
282 +
283 + def _make_view(self) -> memoryview:
284 + return memoryview(np.zeros((4, 512), dtype=np.uint8))
285 +
286 + def test_non_ucx_backends_passes_backends_kwarg(self):
287 + """When any non-UCX backend is requested, pass backends + telemetry."""
288 + agent_cls = MagicMock()
289 + config_fn = MagicMock(return_value=MagicMock(name="cfg"))
290 + with (
291 + patch("vllm.v1.kv_offload.tiering.p2p.data.nixl._NixlAgent", agent_cls),
292 + patch(
293 + "vllm.v1.kv_offload.tiering.p2p.data.nixl._NixlAgentConfig", config_fn
294 + ),
295 + ):
296 + NixlTransport("test:1", self._make_view(), backends=["MOONCAKE"])
297 +
298 + config_fn.assert_called_once_with(backends=["MOONCAKE"], capture_telemetry=True)
299 + # num_threads must NOT be passed on the non-UCX branch.
300 + assert "num_threads" not in config_fn.call_args.kwargs
301 +
302 + def test_ucx_only_passes_num_threads(self):
303 + """UCX-only configuration passes num_threads + telemetry, no backends."""
304 + agent_cls = MagicMock()
305 + config_fn = MagicMock(return_value=MagicMock(name="cfg"))
306 + with (
307 + patch("vllm.v1.kv_offload.tiering.p2p.data.nixl._NixlAgent", agent_cls),
308 + patch(
309 + "vllm.v1.kv_offload.tiering.p2p.data.nixl._NixlAgentConfig", config_fn
310 + ),
311 + ):
312 + NixlTransport("test:1", self._make_view(), num_threads=8)
313 +
314 + config_fn.assert_called_once_with(num_threads=8, capture_telemetry=True)
315 + assert "backends" not in config_fn.call_args.kwargs
316 +
317 + def test_default_backends_is_ucx_only(self):
318 + """No backends arg → defaults to UCX-only branch."""
319 + agent_cls = MagicMock()
320 + config_fn = MagicMock(return_value=MagicMock(name="cfg"))
321 + with (
322 + patch("vllm.v1.kv_offload.tiering.p2p.data.nixl._NixlAgent", agent_cls),
323 + patch(
324 + "vllm.v1.kv_offload.tiering.p2p.data.nixl._NixlAgentConfig", config_fn
325 + ),
326 + ):
327 + NixlTransport("test:1", self._make_view())
328 +
329 + # Default num_threads=4, no backends kwarg.
330 + config_fn.assert_called_once_with(num_threads=4, capture_telemetry=True)
tests/v1/kv_offload/tiering/p2p/test_manager.py new
+1370
@@ -0,0 +1,1370 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for P2PSecondaryTierManager.
4 +
5 +Tests the manager's job routing, session lifecycle, and result collection
6 +using fake transport and session objects.
7 +"""
8 +
9 +from __future__ import annotations
10 +
11 +import time
12 +from types import SimpleNamespace
13 +
14 +import numpy as np
15 +
16 +from vllm.v1.kv_offload.base import LookupResult, ReqContext
17 +from vllm.v1.kv_offload.tiering.base import JobMetadata, JobResult
18 +from vllm.v1.kv_offload.tiering.p2p import manager as manager_module
19 +from vllm.v1.kv_offload.tiering.p2p.manager import (
20 + _UNBOUND_STORE_TIMEOUT_S,
21 + P2PSecondaryTierManager,
22 +)
23 +from vllm.v1.kv_offload.tiering.p2p.session import (
24 + LoadResult,
25 + SessionPollResult,
26 + StoreResult,
27 +)
28 +
29 +# ---------------------------------------------------------------------------
30 +# Helpers
31 +# ---------------------------------------------------------------------------
32 +
33 +
34 +def _prefill_kv_params(
35 + remote_host: str = "10.0.0.1",
36 + remote_port: int = 8000,
37 + kv_request_id: str = "req-1",
38 +) -> dict:
39 + """Decoder-side kv_transfer_params: ``prefill`` sub-dict carries
40 + kv_request_id + remote_host + remote_port."""
41 + return {
42 + "prefill": {
43 + "kv_request_id": kv_request_id,
44 + "remote_host": remote_host,
45 + "remote_port": remote_port,
46 + },
47 + }
48 +
49 +
50 +def _decode_kv_params(kv_request_id: str = "req-1") -> dict:
51 + """Prefiller-side kv_transfer_params: ``decode`` sub-dict carries
52 + kv_request_id only."""
53 + return {"decode": {"kv_request_id": kv_request_id}}
54 +
55 +
56 +def _req_context(kv_params: dict | None = None) -> ReqContext:
57 + return ReqContext(req_id="test", kv_transfer_params=kv_params)
58 +
59 +
60 +def _job_metadata(
61 + job_id: int,
62 + keys: list[bytes] | None = None,
63 + block_ids: list[int] | None = None,
64 + kv_params: dict | None = None,
65 +) -> JobMetadata:
66 + if keys is None:
67 + keys = [b"key1"]
68 + if block_ids is None:
69 + block_ids = list(range(len(keys)))
70 + return JobMetadata(
71 + job_id=job_id,
72 + keys=keys,
73 + block_ids=np.array(block_ids),
74 + is_promotion=False,
75 + req_context=_req_context(kv_params),
76 + )
77 +
78 +
79 +def _make_manager() -> P2PSecondaryTierManager:
80 + """Create a manager with stubbed __init__."""
81 + mgr = P2PSecondaryTierManager.__new__(P2PSecondaryTierManager)
82 + mgr._local_id = "127.0.0.1:7777"
83 + mgr._finished_jobs = []
84 + mgr._failed_req_ids = set()
85 + mgr._sessions = {}
86 + mgr._kv_to_session = {}
87 + mgr._unbound_stores = {}
88 + return mgr
89 +
90 +
91 +# ---------------------------------------------------------------------------
92 +# Tests for _remote_id_from_params
93 +# ---------------------------------------------------------------------------
94 +
95 +
96 +class TestRemoteIdFromParams:
97 + def test_valid_params(self):
98 + result = P2PSecondaryTierManager._remote_id_from_params(
99 + {"remote_host": "10.0.0.1", "remote_port": 8000}
100 + )
101 + assert result == "10.0.0.1:8000"
102 +
103 + def test_missing_host(self):
104 + result = P2PSecondaryTierManager._remote_id_from_params({"remote_port": 8000})
105 + assert result is None
106 +
107 + def test_missing_port(self):
108 + result = P2PSecondaryTierManager._remote_id_from_params(
109 + {"remote_host": "10.0.0.1"}
110 + )
111 + assert result is None
112 +
113 + def test_empty_dict(self):
114 + result = P2PSecondaryTierManager._remote_id_from_params({})
115 + assert result is None
116 +
117 +
118 +# ---------------------------------------------------------------------------
119 +# Tests for lookup
120 +# ---------------------------------------------------------------------------
121 +
122 +
123 +class TestLookup:
124 + def test_lookup_returns_miss_without_kv_params(self):
125 + mgr = _make_manager()
126 + ctx = _req_context(kv_params=None)
127 + assert mgr.lookup(b"key", ctx) is LookupResult.MISS
128 +
129 + def test_lookup_returns_miss_without_required_fields(self):
130 + mgr = _make_manager()
131 + ctx = _req_context(kv_params={"prefill": {"remote_host": "x"}})
132 + assert mgr.lookup(b"key", ctx) is LookupResult.MISS
133 +
134 + def test_lookup_returns_hit_for_valid_request(self):
135 + mgr = _make_manager()
136 + ctx = _req_context(kv_params=_prefill_kv_params())
137 + assert mgr.lookup(b"key", ctx) is LookupResult.HIT
138 +
139 + def test_lookup_returns_miss_for_failed_request(self):
140 + mgr = _make_manager()
141 + mgr._failed_req_ids.add("req-1")
142 + ctx = _req_context(kv_params=_prefill_kv_params(kv_request_id="req-1"))
143 + assert mgr.lookup(b"key", ctx) is LookupResult.MISS
144 +
145 + def test_lookup_returns_hit_for_different_request_id(self):
146 + mgr = _make_manager()
147 + mgr._failed_req_ids.add("req-1")
148 + ctx = _req_context(kv_params=_prefill_kv_params(kv_request_id="req-2"))
149 + assert mgr.lookup(b"key", ctx) is LookupResult.HIT
150 +
151 + def test_lookup_returns_miss_without_prefill_key(self):
152 + """No ``prefill`` sub-dict means the request was not routed for
153 + remote prefill — local prefill should run instead, so lookup()
154 + returns MISS even when a stale ``decode`` block is present."""
155 + mgr = _make_manager()
156 + ctx = _req_context(kv_params=_decode_kv_params())
157 + assert mgr.lookup(b"key", ctx) is LookupResult.MISS
158 +
159 +
160 +# ---------------------------------------------------------------------------
161 +# Tests for submit_store
162 +# ---------------------------------------------------------------------------
163 +
164 +
165 +class TestSubmitStore:
166 + def test_no_decode_succeeds_immediately(self):
167 + """Without a ``decode`` block, job succeeds immediately."""
168 + mgr = _make_manager()
169 + job = _job_metadata(job_id=1, kv_params={})
170 + mgr.submit_store(job)
171 + assert mgr._finished_jobs == [JobResult(job_id=1, success=True)]
172 +
173 + def test_missing_kv_request_id_fails(self):
174 + """Missing kv_request_id inside ``decode`` fails the job."""
175 + mgr = _make_manager()
176 + params: dict = {"decode": {}}
177 + job = _job_metadata(job_id=1, kv_params=params)
178 + mgr.submit_store(job)
179 + assert mgr._finished_jobs == [JobResult(job_id=1, success=False)]
180 +
181 + def test_no_binding_yet_parks_in_unbound_stores(self):
182 + """submit_store without a bound session buffers the batch keyed
183 + by kv_request_id; no session is pre-created (peer_id is unknown
184 + to the producer at store time)."""
185 + mgr = _make_manager()
186 + job = _job_metadata(
187 + job_id=1,
188 + keys=[b"k1", b"k2"],
189 + block_ids=[3, 4],
190 + kv_params=_decode_kv_params(kv_request_id="req-1"),
191 + )
192 + mgr.submit_store(job)
193 +
194 + assert mgr._sessions == {}
195 + assert mgr._finished_jobs == []
196 + batches = mgr._unbound_stores["req-1"]
197 + assert len(batches) == 1
198 + assert (
199 + batches[0].job_id,
200 + list(batches[0].keys),
201 + list(batches[0].block_ids),
202 + ) == (
203 + 1,
204 + [b"k1", b"k2"],
205 + [3, 4],
206 + )
207 +
208 + def test_routes_to_bound_session(self):
209 + """If a session has already received FetchMsg for this
210 + kv_request_id (so _kv_to_session is populated), submit_store
211 + forwards directly to that session rather than re-buffering."""
212 + mgr = _make_manager()
213 + bound = _FakeSession(peer_id="10.0.0.1:8000", connected=True)
214 + mgr._kv_to_session["req-1"] = bound # type: ignore[assignment]
215 + # Note: _sessions is intentionally untouched — this test isolates
216 + # the kv_request_id → session fast path.
217 + job = _job_metadata(
218 + job_id=7,
219 + keys=[b"k1", b"k2"],
220 + block_ids=[3, 4],
221 + kv_params=_decode_kv_params(kv_request_id="req-1"),
222 + )
223 + mgr.submit_store(job)
224 +
225 + assert len(bound.stores_added) == 1
226 + kv_req_id, keys, _, job_id = bound.stores_added[0]
227 + assert (kv_req_id, keys, job_id) == ("req-1", [b"k1", b"k2"], 7)
228 + assert mgr._unbound_stores == {}
229 + assert mgr._finished_jobs == []
230 +
231 + def test_extra_top_level_keys_are_ignored(self):
232 + """Producer-side kv_transfer_params should not pre-create a
233 + session even when a stale caller still passes a top-level
234 + ``remote_host``/``remote_port`` next to ``decode``."""
235 + mgr = _make_manager()
236 + params = _decode_kv_params()
237 + params["remote_host"] = "stale"
238 + params["remote_port"] = 12345
239 + job = _job_metadata(job_id=1, kv_params=params)
240 + mgr.submit_store(job)
241 + # No session pre-created, no peer-keyed state.
242 + assert mgr._sessions == {}
243 + assert "req-1" in mgr._unbound_stores
244 +
245 +
246 +# ---------------------------------------------------------------------------
247 +# Tests for submit_load
248 +# ---------------------------------------------------------------------------
249 +
250 +
251 +class TestSubmitLoad:
252 + def test_missing_params_fails(self):
253 + """Missing required kv_params fields fails the job."""
254 + mgr = _make_manager()
255 + job = _job_metadata(job_id=1, kv_params={})
256 + mgr.submit_load(job)
257 + assert mgr._finished_jobs == [JobResult(job_id=1, success=False)]
258 +
259 + def test_empty_keys_succeeds_immediately(self):
260 + """Empty key list succeeds immediately."""
261 + mgr = _make_manager()
262 + job = _job_metadata(
263 + job_id=1, keys=[], block_ids=[], kv_params=_prefill_kv_params()
264 + )
265 + mgr.submit_load(job)
266 + assert mgr._finished_jobs == [JobResult(job_id=1, success=True)]
267 +
268 + def test_no_session_fails(self):
269 + """No session for peer fails and marks request failed."""
270 + mgr = _make_manager()
271 + job = _job_metadata(job_id=1, kv_params=_prefill_kv_params())
272 + mgr.submit_load(job)
273 + assert mgr._finished_jobs == [JobResult(job_id=1, success=False)]
274 + assert "req-1" in mgr._failed_req_ids
275 +
276 + def test_happy_path_with_active_session(self):
277 + """When the peer's session exists, submit_load forwards to
278 + session.request_blocks and does NOT add a finished result yet."""
279 + mgr = _make_manager()
280 + peer_id = "10.0.0.1:8000"
281 + existing = _FakeSession(peer_id=peer_id, connected=True)
282 + mgr._sessions[peer_id] = existing # type: ignore[assignment]
283 + job = _job_metadata(
284 + job_id=42,
285 + keys=[b"k1", b"k2"],
286 + block_ids=[5, 6],
287 + kv_params=_prefill_kv_params(kv_request_id="req-42"),
288 + )
289 + mgr.submit_load(job)
290 +
291 + assert existing.requests == [(42, "req-42")]
292 + assert mgr._finished_jobs == []
293 + assert "req-42" not in mgr._failed_req_ids
294 +
295 +
296 +# ---------------------------------------------------------------------------
297 +# Tests for on_request_finished
298 +# ---------------------------------------------------------------------------
299 +
300 +
301 +class TestOnRequestFinished:
302 + def _make_with_failed(self) -> P2PSecondaryTierManager:
303 + mgr = _make_manager()
304 + mgr._failed_req_ids = {"req-1"}
305 + return mgr
306 +
307 + def test_prunes_failed_req_ids(self):
308 + mgr = self._make_with_failed()
309 + ctx = _req_context(kv_params=_prefill_kv_params(kv_request_id="req-1"))
310 + mgr.on_request_finished(ctx)
311 + assert "req-1" not in mgr._failed_req_ids
312 +
313 + def test_no_kv_params_does_nothing(self):
314 + mgr = self._make_with_failed()
315 + ctx = _req_context(kv_params=None)
316 + mgr.on_request_finished(ctx)
317 + assert "req-1" in mgr._failed_req_ids
318 +
319 + def test_no_kv_request_id_does_nothing(self):
320 + mgr = self._make_with_failed()
321 + ctx = _req_context(kv_params={"remote_host": "x", "remote_port": 1})
322 + mgr.on_request_finished(ctx)
323 + assert "req-1" in mgr._failed_req_ids
324 +
325 + def test_decoder_side_calls_session_finish_request(self):
326 + """Decoder-side finish (``prefill`` set) still routes via peer_id
327 + because the consumer addresses the producer it loaded from. The
328 + session's finish_request cancels the client-role load."""
329 + mgr = _make_manager()
330 + peer_id = "10.0.0.1:8000"
331 + session = _FakeSession(peer_id=peer_id)
332 + mgr._sessions[peer_id] = session
333 + ctx = _req_context(kv_params=_prefill_kv_params(kv_request_id="req-1"))
334 + mgr.on_request_finished(ctx)
335 + assert session.finishes == ["req-1"]
336 +
337 + def test_prefiller_bound_id_routes_via_kv_to_session(self):
338 + """Prefiller-side finish for an id whose session is already bound
339 + (FetchMsg received) routes via _kv_to_session and pops the entry."""
340 + mgr = _make_manager()
341 + bound = _FakeSession(peer_id="some-peer:1", connected=True)
342 + mgr._kv_to_session["req-1"] = bound # type: ignore[assignment]
343 + ctx = _req_context(kv_params=_decode_kv_params(kv_request_id="req-1"))
344 + mgr.on_request_finished(ctx)
345 + assert bound.finishes == ["req-1"]
346 + assert "req-1" not in mgr._kv_to_session
347 +
348 + def test_prefiller_unbound_id_leaves_batches_parked(self):
349 + """Prefiller-side finish for an id with parked unbound batches
350 + and no session binding is a no-op on `_unbound_stores`. The
351 + parked batches survive until a peer fetches them or the
352 + `_reap_unbound_stores` timeout fires — `on_request_finished`
353 + must not evict them."""
354 + from vllm.v1.kv_offload.tiering.p2p.manager import _UnboundStoreBatch
355 +
356 + mgr = _make_manager()
357 + mgr._unbound_stores["req-1"] = [
358 + _UnboundStoreBatch(job_id=10, keys=[b"k"], block_ids=[0]),
359 + _UnboundStoreBatch(job_id=11, keys=[b"k2"], block_ids=[1]),
360 + ]
361 + ctx = _req_context(kv_params=_decode_kv_params(kv_request_id="req-1"))
362 + mgr.on_request_finished(ctx)
363 + assert "req-1" in mgr._unbound_stores
364 + assert [b.job_id for b in mgr._unbound_stores["req-1"]] == [10, 11]
365 + outcomes = {(r.job_id, r.success) for r in mgr._finished_jobs}
366 + assert (10, False) not in outcomes
367 + assert (11, False) not in outcomes
368 +
369 +
370 +# ---------------------------------------------------------------------------
371 +# Tests for get_finished_jobs
372 +# ---------------------------------------------------------------------------
373 +
374 +
375 +class _FakeServerHalf:
376 + def __init__(self) -> None:
377 + self._inflight: dict[int, object] = {}
378 +
379 +
380 +class _FakeClientHalf:
381 + def __init__(self) -> None:
382 + self._inbound: dict[int, object] = {}
383 +
384 +
385 +class _FakeSession:
386 + """Fake bidirectional session that returns canned poll() results."""
387 +
388 + def __init__(
389 + self,
390 + peer_id: str = "fake:1",
391 + alive: bool = True,
392 + connected: bool = True,
393 + loads: list[LoadResult] | None = None,
394 + stores: list[StoreResult] | None = None,
395 + new_fetch_ids: list[str] | None = None,
396 + close_loads: list[tuple[int, str]] | None = None,
397 + close_stores: list[int] | None = None,
398 + ) -> None:
399 + self.peer_id = peer_id
400 + self.alive = alive
401 + self.connected = connected
402 + self.ready = True
403 + self._loads = loads or []
404 + self._stores = stores or []
405 + self._new_fetch_ids = new_fetch_ids or []
406 + self._close_loads = close_loads or []
407 + self._close_stores = close_stores or []
408 + self.requests: list[tuple[int, str]] = []
409 + self.stores_added: list[tuple[str, list, object, int]] = []
410 + self.attached: list[object] = []
411 + self.finishes: list[str] = []
412 + # Mirror P2PSession._server._inflight (transfer_id → handle) and
413 + # P2PSession._client._inbound for the shutdown-drain and drain_jobs
414 + # paths. Tests populate _server._inflight when needed.
415 + self._server = _FakeServerHalf()
416 + self._client = _FakeClientHalf()
417 +
418 + def poll(self):
419 + result = SessionPollResult(
420 + loads=self._loads,
421 + stores=self._stores,
422 + new_fetch_ids=self._new_fetch_ids,
423 + )
424 + self._loads = []
425 + self._stores = []
426 + self._new_fetch_ids = []
427 + return result
428 +
429 + def request_blocks(self, job_id, kv_request_id, keys, block_ids):
430 + self.requests.append((job_id, kv_request_id))
431 +
432 + def add_stored_blocks(self, kv_request_id, keys, block_ids, job_id):
433 + self.stores_added.append((kv_request_id, list(keys), block_ids, job_id))
434 +
435 + def attach_connection(self, conn):
436 + self.attached.append(conn)
437 + self.connected = True
438 +
439 + def finish_request(self, kv_request_id):
440 + self.finishes.append(kv_request_id)
441 +
442 + def close(self):
443 + return self._close_loads, self._close_stores
444 +
445 +
446 +class TestGetFinished:
447 + def _make(self) -> P2PSecondaryTierManager:
448 + mgr = _make_manager()
449 + mgr._finished_jobs = [
450 + JobResult(job_id=1, success=True),
451 + JobResult(job_id=2, success=False),
452 + ]
453 +
454 + class FakeControl:
455 + def poll(self):
456 + return []
457 +
458 + mgr._control = FakeControl() # type: ignore[assignment]
459 + mgr._data = None # type: ignore[assignment]
460 + return mgr
461 +
462 + def test_drains_finished_jobs(self):
463 + """get_finished_jobs returns and clears accumulated results."""
464 + mgr = self._make()
465 + results = list(mgr.get_finished_jobs())
466 + assert len(results) == 2
467 + assert JobResult(job_id=1, success=True) in results
468 + assert JobResult(job_id=2, success=False) in results
469 + # Second call returns empty.
470 + assert list(mgr.get_finished_jobs()) == []
471 +
472 + def test_reaps_dead_sessions(self):
473 + """Dead connected sessions are removed and their pending jobs failed."""
474 +
475 + class FakeData:
476 + def remove_remote_peer(self, pid):
477 + pass
478 +
479 + mgr = self._make()
480 + mgr._data = FakeData() # type: ignore[assignment]
481 + dead = _FakeSession(
482 + peer_id="dead:1234",
483 + alive=False,
484 + connected=True,
485 + close_loads=[(20, "req-load")],
486 + close_stores=[10, 11],
487 + )
488 + mgr._sessions["dead:1234"] = dead # type: ignore[assignment]
489 +
490 + results = list(mgr.get_finished_jobs())
491 + # 2 baseline + 1 failed load + 2 failed stores
492 + assert len(results) == 5
493 + assert JobResult(job_id=10, success=False) in results
494 + assert JobResult(job_id=11, success=False) in results
495 + assert JobResult(job_id=20, success=False) in results
496 + assert "dead:1234" not in mgr._sessions
497 + assert "req-load" in mgr._failed_req_ids
498 +
499 + def test_unbound_store_kept_within_timeout(self):
500 + """Recently-parked unbound stores stay across a poll."""
501 + mgr = self._make()
502 + from vllm.v1.kv_offload.tiering.p2p.manager import _UnboundStoreBatch
503 +
504 + mgr._unbound_stores["req-fresh"] = [
505 + _UnboundStoreBatch(job_id=99, keys=[b"k"], block_ids=[0])
506 + ]
507 + list(mgr.get_finished_jobs())
508 + assert "req-fresh" in mgr._unbound_stores
509 +
510 + def test_unbound_store_reaped_after_timeout(self):
511 + """Unbound stores past _UNBOUND_STORE_TIMEOUT_S surface as failed
512 + and their kv_request_id lands in _failed_req_ids so a late
513 + FetchMsg/lookup doesn't try to satisfy them."""
514 + from vllm.v1.kv_offload.tiering.p2p.manager import _UnboundStoreBatch
515 +
516 + mgr = self._make()
517 + stale = _UnboundStoreBatch(job_id=10, keys=[b"k"], block_ids=[0])
518 + # Backdate the submission so the head batch is past the deadline.
519 + stale.submitted_at = time.monotonic() - _UNBOUND_STORE_TIMEOUT_S - 1.0
520 + mgr._unbound_stores["req-stale"] = [
521 + stale,
522 + _UnboundStoreBatch(job_id=11, keys=[b"k2"], block_ids=[1]),
523 + ]
524 +
525 + results = list(mgr.get_finished_jobs())
526 +
527 + assert "req-stale" not in mgr._unbound_stores
528 + # 2 baseline + 2 buffered stores
529 + assert JobResult(job_id=10, success=False) in results
530 + assert JobResult(job_id=11, success=False) in results
531 + assert "req-stale" in mgr._failed_req_ids
532 +
533 + def test_submit_store_parks_unbound_batch(self):
534 + """submit_store on an unbound id appends a batch with a fresh
535 + submitted_at stamp so the unbound-store sweep can age it out."""
536 + mgr = _make_manager()
537 + job = _job_metadata(
538 + job_id=1, kv_params=_decode_kv_params(kv_request_id="req-1")
539 + )
540 + before = time.monotonic()
541 + mgr.submit_store(job)
542 + after = time.monotonic()
543 + batches = mgr._unbound_stores["req-1"]
544 + assert len(batches) == 1
545 + assert before <= batches[0].submitted_at <= after
546 +
547 +
548 +# ---------------------------------------------------------------------------
549 +# has_pending_work
550 +# ---------------------------------------------------------------------------
551 +
552 +
553 +class TestHasPendingWork:
554 + """has_pending_work() must always return True so the engine keeps
555 + ticking the offload pipeline — that's the only thread driving
556 + _control.poll() (incoming peer connects) and session.poll()
557 + (incoming fetch messages on existing sessions)."""
558 +
559 + def test_returns_true_unconditionally_to_keep_engine_ticking(self):
560 + """Even with no sessions and no jobs, has_pending_work() returns
561 + True so the engine keeps calling get_finished_jobs(), which is
562 + what drives _control.poll() for inbound peer connects."""
563 + mgr = _make_manager()
564 + assert mgr.has_pending_work() is True
565 +
566 + def test_returns_true_even_when_sessions_present(self):
567 + """The result is the same regardless of session state — there is
568 + no 'idle' branch."""
569 + mgr = _make_manager()
570 + mgr._sessions["peer:1"] = _FakeSession(peer_id="peer:1") # type: ignore[assignment]
571 + assert mgr.has_pending_work() is True
572 +
573 +
574 +# ---------------------------------------------------------------------------
575 +# Shutdown drain
576 +# ---------------------------------------------------------------------------
577 +
578 +
579 +class _ShutdownFakeData:
580 + """Fake DataTransport that records cancel/poll/close calls and
581 + drives the wait-cancel loop with a scriptable `still` queue."""
582 +
583 + def __init__(self, still_queue: list[list[int]] | None = None) -> None:
584 + # Each list in still_queue is the set of ids the next
585 + # cancel(mode="wait") should report as still inflight. The last
586 + # entry repeats once exhausted.
587 + self._still_queue = list(still_queue) if still_queue else [[]]
588 + self.cancel_calls: list[tuple[list[int], str]] = []
589 + self.poll_calls: int = 0
590 + self.close_calls: int = 0
591 +
592 + def cancel(self, transfer_ids, mode: str = "immediate") -> list[int]:
593 + ids = list(transfer_ids)
594 + self.cancel_calls.append((ids, mode))
595 + if mode == "wait":
596 + if len(self._still_queue) > 1:
597 + return list(self._still_queue.pop(0))
598 + return list(self._still_queue[0])
599 + return []
600 +
601 + def poll(self):
602 + self.poll_calls += 1
603 +
604 + class _Empty:
605 + done: list[int] = []
606 + failed: list[int] = []
607 +
608 + return _Empty()
609 +
610 + def close(self) -> None:
611 + self.close_calls += 1
612 +
613 +
614 +class _ShutdownFakeControl:
615 + def __init__(self) -> None:
616 + self.close_calls: int = 0
617 +
618 + def close(self) -> None:
619 + self.close_calls += 1
620 +
621 +
622 +class TestShutdownDrain:
623 + """shutdown() drains inflight transfers via cancel(mode='wait')
624 + before calling _data.close(), with a 3s deadline fallback to
625 + cancel(mode='immediate')."""
626 +
627 + def _prep(
628 + self,
629 + still_queue: list[list[int]] | None = None,
630 + inflight_ids: list[int] | None = None,
631 + ) -> tuple[P2PSecondaryTierManager, _ShutdownFakeData, _ShutdownFakeControl]:
632 + mgr = _make_manager()
633 + data = _ShutdownFakeData(still_queue=still_queue)
634 + control = _ShutdownFakeControl()
635 + mgr._data = data # type: ignore[assignment]
636 + mgr._control = control # type: ignore[assignment]
637 + if inflight_ids:
638 + session = _FakeSession(peer_id="peer:1", connected=True)
639 + session._server._inflight = {tid: object() for tid in inflight_ids}
640 + mgr._sessions["peer:1"] = session # type: ignore[assignment]
641 + return mgr, data, control
642 +
643 + def test_shutdown_drains_inflight_via_wait_cancel(self):
644 + # First cancel(wait) returns the input still inflight; second returns [].
645 + mgr, data, control = self._prep(
646 + still_queue=[[42, 43], []],
647 + inflight_ids=[42, 43],
648 + )
649 + mgr.shutdown()
650 +
651 + wait_calls = [c for c in data.cancel_calls if c[1] == "wait"]
652 + immediate_calls = [c for c in data.cancel_calls if c[1] == "immediate"]
653 + assert len(wait_calls) >= 1
654 + assert wait_calls[0][0] == [42, 43]
655 + assert immediate_calls == []
656 + # poll() was driven between cancel attempts.
657 + assert data.poll_calls >= 1
658 + # _data and _control were closed exactly once each, after the drain.
659 + assert data.close_calls == 1
660 + assert control.close_calls == 1
661 +
662 + def test_shutdown_force_cancels_after_timeout(self, monkeypatch):
663 + # Drain never completes — wait-cancel keeps returning the inflight set.
664 + # Use a synthetic clock so the test does not depend on real wallclock
665 + # being able to advance in <50ms on a loaded CI node:
666 + # call 1 (deadline calc): 100.0 -> deadline = 100.05
667 + # call 2 (loop predicate): 100.0 -> enters loop, one wait-cancel
668 + # call 3 (loop predicate): 100.06 -> exits, force-cancel runs
669 + monkeypatch.setattr(manager_module, "_SHUTDOWN_DRAIN_TIMEOUT_S", 0.05)
670 + monkeypatch.setattr(manager_module, "_DRAIN_SLEEP_S", 0.0)
671 + times = iter([100.0, 100.0, 100.06])
672 + # Patch via a fake module on `manager_module.time` so we do not mutate
673 + # the global `time` module — other code in the process (e.g. the
674 + # buildkite test collector's pytest_runtest_logreport hook) calls
675 + # time.monotonic() before monkeypatch teardown.
676 + fake_time = SimpleNamespace(monotonic=lambda: next(times), sleep=time.sleep)
677 + monkeypatch.setattr(manager_module, "time", fake_time)
678 +
679 + mgr, data, control = self._prep(
680 + still_queue=[[42]],
681 + inflight_ids=[42],
682 + )
683 + mgr.shutdown()
684 +
685 + wait_calls = [c for c in data.cancel_calls if c[1] == "wait"]
686 + immediate_calls = [c for c in data.cancel_calls if c[1] == "immediate"]
687 + assert len(wait_calls) == 1
688 + assert wait_calls[0][0] == [42]
689 + assert immediate_calls == [([42], "immediate")]
690 + assert data.close_calls == 1
691 + assert control.close_calls == 1
692 +
693 + def test_shutdown_no_inflight_skips_drain(self):
694 + mgr, data, control = self._prep()
695 + mgr.shutdown()
696 +
697 + assert data.cancel_calls == []
698 + assert data.poll_calls == 0
699 + assert data.close_calls == 1
700 + assert control.close_calls == 1
701 +
702 +
703 +# ---------------------------------------------------------------------------
704 +# Bidirectional regression test — both managers act as client AND server
705 +# toward each other on the same peer_id. This is the case the unification
706 +# is meant to fix.
707 +# ---------------------------------------------------------------------------
708 +
709 +
710 +class _LoopbackControl:
711 + """In-memory control transport that pairs two managers head-to-head.
712 +
713 + Each manager hands its outbound message buffer to the other's inbound
714 + queue on poll(). connect() returns a connection whose send() writes
715 + into the peer's inbound side; recv() reads what the peer's connect-or-
716 + poll path delivered for us.
717 + """
718 +
719 + def __init__(self, local_id: str) -> None:
720 + self._local_id = local_id
721 + self._peer: _LoopbackControl | None = None
722 + self._inbound_outbound: dict[str, _LoopbackConnection] = {}
723 + # Pending inbound for a peer that has not yet been registered.
724 + self._pending: list[tuple[str, dict]] = []
725 +
726 + def pair(self, peer: _LoopbackControl) -> None:
727 + self._peer = peer
728 + peer._peer = self
729 +
730 + def connect(self, peer_id: str):
731 + if peer_id in self._inbound_outbound:
732 + raise AssertionError(f"already connected to {peer_id}")
733 + conn = _LoopbackConnection(self, peer_id)
734 + self._inbound_outbound[peer_id] = conn
735 + return conn
736 +
737 + def poll(self):
738 + # Drain whatever the peer has sent toward us.
739 + new = []
740 + if self._peer is not None:
741 + for pid, msg in self._peer._drain_outbound_to(self._local_id):
742 + conn = self._inbound_outbound.get(pid)
743 + if conn is None:
744 + conn = _LoopbackConnection(self, pid)
745 + self._inbound_outbound[pid] = conn
746 + new.append(conn)
747 + conn._inbox.append(msg)
748 + return new
749 +
750 + def _drain_outbound_to(self, peer_local_id: str):
751 + # Peer is calling our poll → return all messages we've sent toward
752 + # peer_local_id (which is the peer's own local_id).
753 + out: list[tuple[str, dict]] = []
754 + for pid, conn in list(self._inbound_outbound.items()):
755 + # Each outgoing message goes to peer_local_id and is tagged
756 + # with the sender's local_id (i.e., self._local_id).
757 + for msg in conn._outbox:
758 + out.append((self._local_id, msg))
759 + conn._outbox.clear()
760 + return out
761 +
762 + def close(self):
763 + for conn in self._inbound_outbound.values():
764 + conn.close()
765 + self._inbound_outbound.clear()
766 +
767 +
768 +class _LoopbackConnection:
769 + def __init__(self, transport: _LoopbackControl, peer_id: str) -> None:
770 + self._transport = transport
771 + self.peer_id = peer_id
772 + self._inbox: list[dict] = []
773 + self._outbox: list[dict] = []
774 + self._closed = False
775 +
776 + @property
777 + def alive(self) -> bool:
778 + return not self._closed
779 +
780 + def send(self, msg: dict) -> None:
781 + if self._closed:
782 + raise RuntimeError("send on closed conn")
783 + self._outbox.append(msg)
784 +
785 + def recv(self) -> list[dict]:
786 + msgs = self._inbox
787 + self._inbox = []
788 + return msgs
789 +
790 + def mark_dead(self) -> None:
791 + self._closed = True
792 +
793 + def close(self) -> None:
794 + self._closed = True
795 +
796 +
797 +class _FakeData:
798 + """Minimal NIXL fake that lets matched transfers complete on the next poll."""
799 +
800 + def __init__(self, local_id: str) -> None:
801 + self._local_id = local_id
802 + self.block_len = 4096
803 + self.base_addr = 0x1000
804 + self.num_blocks = 16
805 + self.config_fingerprint = ""
806 + self._remote_peers: dict[str, dict] = {}
807 + self._inflight_done: list[int] = []
808 + self._next_id = 0
809 +
810 + def get_agent_metadata(self) -> bytes:
811 + return f"meta-{self._local_id}".encode()
812 +
813 + def add_remote_peer(
814 + self, peer_id, agent_metadata, base_addr, num_blocks, block_len
815 + ) -> None:
816 + self._remote_peers[peer_id] = {
817 + "agent_metadata": agent_metadata,
818 + "base_addr": base_addr,
819 + "num_blocks": num_blocks,
820 + "block_len": block_len,
821 + }
822 +
823 + def remove_remote_peer(self, peer_id: str) -> None:
824 + self._remote_peers.pop(peer_id, None)
825 +
826 + def write_blocks(self, peer_id, local_idxs, remote_idxs):
827 + if peer_id not in self._remote_peers:
828 + return None
829 + tid = self._next_id
830 + self._next_id += 1
831 + self._inflight_done.append(tid)
832 + return tid
833 +
834 + def poll(self):
835 + from vllm.v1.kv_offload.tiering.p2p.data.base import PollResult
836 +
837 + done = self._inflight_done[:]
838 + self._inflight_done.clear()
839 + return PollResult(done=done, failed=[])
840 +
841 + def cancel(self, transfer_ids) -> None:
842 + pass
843 +
844 + def close(self) -> None:
845 + pass
846 +
847 +
848 +def _build_paired_managers() -> tuple[P2PSecondaryTierManager, P2PSecondaryTierManager]:
849 + """Two managers each acting as both client and server toward the other.
850 +
851 + Wires _LoopbackControl pair + per-side _FakeData so transfers complete
852 + on the next poll. The test drives polling by calling get_finished_jobs(),
853 + which invokes _poll_once synchronously on the calling thread.
854 + """
855 + mgr_a = _make_manager()
856 + mgr_b = _make_manager()
857 + mgr_a._local_id = "A:1"
858 + mgr_b._local_id = "B:2"
859 +
860 + ctrl_a = _LoopbackControl(mgr_a._local_id)
861 + ctrl_b = _LoopbackControl(mgr_b._local_id)
862 + ctrl_a.pair(ctrl_b)
863 +
864 + mgr_a._control = ctrl_a # type: ignore[assignment]
865 + mgr_b._control = ctrl_b # type: ignore[assignment]
866 + mgr_a._data = _FakeData(mgr_a._local_id) # type: ignore[assignment]
867 + mgr_b._data = _FakeData(mgr_b._local_id) # type: ignore[assignment]
868 +
869 + return mgr_a, mgr_b
870 +
871 +
872 +class TestBidirectionalManager:
873 + """Two managers each load FROM and serve TO the other over a single peer."""
874 +
875 + def test_both_loads_succeed(self):
876 + mgr_a, mgr_b = _build_paired_managers()
877 +
878 + a_loads_kv = "req-AtoB-load" # A loads, B serves
879 + b_loads_kv = "req-BtoA-load" # B loads, A serves
880 +
881 + a_decoder_params = {
882 + "prefill": {
883 + "kv_request_id": a_loads_kv,
884 + "remote_host": "B",
885 + "remote_port": 2,
886 + },
887 + }
888 + b_decoder_params = {
889 + "prefill": {
890 + "kv_request_id": b_loads_kv,
891 + "remote_host": "A",
892 + "remote_port": 1,
893 + },
894 + }
895 + a_prefiller_params = {"decode": {"kv_request_id": b_loads_kv}}
896 + b_prefiller_params = {"decode": {"kv_request_id": a_loads_kv}}
897 +
898 + # 1. Both sides open client-role sessions toward the peer.
899 + mgr_a.on_new_request(_req_context(a_decoder_params))
900 + mgr_b.on_new_request(_req_context(b_decoder_params))
901 +
902 + # 2. Both sides store the blocks the peer will fetch.
903 + mgr_a.submit_store(
904 + _job_metadata(
905 + job_id=100,
906 + keys=[b"a-block"],
907 + block_ids=[0],
908 + kv_params=a_prefiller_params,
909 + )
910 + )
911 + mgr_b.submit_store(
912 + _job_metadata(
913 + job_id=200,
914 + keys=[b"b-block"],
915 + block_ids=[0],
916 + kv_params=b_prefiller_params,
917 + )
918 + )
919 +
920 + # 3. Both sides submit loads.
921 + mgr_a.submit_load(
922 + _job_metadata(
923 + job_id=101,
924 + keys=[b"b-block"],
925 + block_ids=[0],
926 + kv_params=a_decoder_params,
927 + )
928 + )
929 + mgr_b.submit_load(
930 + _job_metadata(
931 + job_id=201,
932 + keys=[b"a-block"],
933 + block_ids=[0],
934 + kv_params=b_decoder_params,
935 + )
936 + )
937 +
938 + # 4. Drive several poll iterations on each side. Each
939 + # get_finished_jobs() call invokes _poll_once synchronously.
940 + all_a: list[JobResult] = []
941 + all_b: list[JobResult] = []
942 + for _ in range(8):
943 + all_a.extend(list(mgr_a.get_finished_jobs()))
944 + all_b.extend(list(mgr_b.get_finished_jobs()))
945 +
946 + # Both load jobs and both store jobs must complete successfully.
947 + a_ok = {r.job_id for r in all_a if r.success}
948 + b_ok = {r.job_id for r in all_b if r.success}
949 + # A: load job 101 + store job 100
950 + assert 101 in a_ok, f"A loads succeeded: {a_ok}"
951 + assert 100 in a_ok, f"A stores succeeded: {a_ok}"
952 + # B: load job 201 + store job 200
953 + assert 201 in b_ok, f"B loads succeeded: {b_ok}"
954 + assert 200 in b_ok, f"B stores succeeded: {b_ok}"
955 +
956 +
957 +# ---------------------------------------------------------------------------
958 +# _accept_new_peers — duplicate connection rejection
959 +# ---------------------------------------------------------------------------
960 +
961 +
962 +class _RecordingConn:
963 + """Inbound connection stub that records close()/peer_id only."""
964 +
965 + def __init__(self, peer_id: str) -> None:
966 + self.peer_id = peer_id
967 + self.close_calls: int = 0
968 +
969 + def close(self) -> None:
970 + self.close_calls += 1
971 +
972 +
973 +class TestAcceptNewPeers:
974 + """A second inbound from an already-connected peer is rejected and the
975 + new conn is closed; the existing session is left untouched."""
976 +
977 + def test_duplicate_connection_is_closed_and_existing_session_untouched(self):
978 + mgr = _make_manager()
979 + peer_id = "10.0.0.1:8000"
980 + existing = _FakeSession(peer_id=peer_id, connected=True)
981 + mgr._sessions[peer_id] = existing # type: ignore[assignment]
982 +
983 + new_conn = _RecordingConn(peer_id)
984 + mgr._accept_new_peers([new_conn])
985 +
986 + # Manager swallowed the ValueError and closed the duplicate conn.
987 + assert new_conn.close_calls == 1
988 + # Existing session was NOT re-attached.
989 + assert existing.attached == []
990 + # Session map unchanged.
991 + assert mgr._sessions[peer_id] is existing
992 +
993 + def test_creates_session_for_new_peer(self):
994 + """An inbound conn from a peer with no existing session creates
995 + a fresh connected session and registers it under conn.peer_id.
996 + The prefiller has no pre-created pending session anymore — the
997 + first signal of a peer's existence is its inbound connection."""
998 +
999 + class FakeData:
1000 + block_len = 4096
1001 + base_addr = 0x1000
1002 + num_blocks = 16
1003 + config_fingerprint = ""
1004 +
1005 + def get_agent_metadata(self):
1006 + return b"meta"
1007 +
1008 + def add_remote_peer(self, *args, **kwargs):
1009 + pass
1010 +
1011 + mgr = _make_manager()
1012 + mgr._data = FakeData() # type: ignore[assignment]
1013 + peer_id = "10.0.0.1:8000"
1014 +
1015 + # Real ControlConnection-shaped fake: send/close/peer_id only.
1016 + sent: list[dict] = []
1017 +
1018 + class _Conn:
1019 + def __init__(self, pid: str) -> None:
1020 + self.peer_id = pid
1021 + self.alive = True
1022 +
1023 + def send(self, msg: dict) -> None:
1024 + sent.append(msg)
1025 +
1026 + def close(self) -> None:
1027 + self.alive = False
1028 +
1029 + mgr._accept_new_peers([_Conn(peer_id)]) # type: ignore[arg-type]
1030 +
1031 + assert peer_id in mgr._sessions
1032 + assert mgr._sessions[peer_id].connected is True
1033 + # Session sent its ConnectMsg on the new connection.
1034 + assert any(m for m in sent)
1035 +
1036 +
1037 +# ---------------------------------------------------------------------------
1038 +# _poll_once orchestration
1039 +# ---------------------------------------------------------------------------
1040 +
1041 +
1042 +class TestPollOnce:
1043 + """_poll_once must drain control, accept new peers, poll every session,
1044 + surface results, and reap dead sessions — in that order."""
1045 +
1046 + def test_orchestrates_accept_poll_and_reap(self):
1047 + mgr = _make_manager()
1048 +
1049 + # Alive session whose poll() returns one load + one store.
1050 + peer_alive = "10.0.0.2:9000"
1051 + alive = _FakeSession(
1052 + peer_id=peer_alive,
1053 + alive=True,
1054 + connected=True,
1055 + loads=[LoadResult(job_id=11, kv_request_id="req-11", success=True)],
1056 + stores=[StoreResult(job_id=22, success=True)],
1057 + )
1058 + mgr._sessions[peer_alive] = alive # type: ignore[assignment]
1059 +
1060 + # Dead session whose pending close() jobs surface as failures.
1061 + peer_dead = "10.0.0.3:9999"
1062 + dead = _FakeSession(
1063 + peer_id=peer_dead,
1064 + alive=False,
1065 + connected=True,
1066 + close_loads=[(33, "req-33")],
1067 + close_stores=[44],
1068 + )
1069 + mgr._sessions[peer_dead] = dead # type: ignore[assignment]
1070 +
1071 + class _Ctrl:
1072 + def poll(self_inner):
1073 + return []
1074 +
1075 + class _Data:
1076 + def remove_remote_peer(self_inner, pid):
1077 + pass
1078 +
1079 + mgr._control = _Ctrl() # type: ignore[assignment]
1080 + mgr._data = _Data() # type: ignore[assignment]
1081 +
1082 + mgr._poll_once()
1083 +
1084 + # Every session was polled — alive's results landed.
1085 + # Dead session was reaped — its close() failures landed.
1086 + finished = mgr._finished_jobs
1087 + ok = {(r.job_id, r.success) for r in finished}
1088 + assert (11, True) in ok # alive load result
1089 + assert (22, True) in ok # alive store result
1090 + assert (33, False) in ok # dead session's pending load
1091 + assert (44, False) in ok # dead session's pending store
1092 + assert "req-33" in mgr._failed_req_ids
1093 + assert peer_dead not in mgr._sessions
1094 + assert peer_alive in mgr._sessions
1095 +
1096 + def test_new_fetch_id_binds_and_replays_unbound_batches(self):
1097 + """When session.poll() reports a kv_request_id whose FetchMsg
1098 + arrived this tick, the manager binds it to that session and
1099 + replays every parked submit_store batch via add_stored_blocks."""
1100 + from vllm.v1.kv_offload.tiering.p2p.manager import _UnboundStoreBatch
1101 +
1102 + mgr = _make_manager()
1103 + peer = "10.0.0.1:8000"
1104 + sess = _FakeSession(
1105 + peer_id=peer,
1106 + alive=True,
1107 + connected=True,
1108 + new_fetch_ids=["req-1"],
1109 + )
1110 + mgr._sessions[peer] = sess # type: ignore[assignment]
1111 + mgr._unbound_stores["req-1"] = [
1112 + _UnboundStoreBatch(job_id=5, keys=[b"k1"], block_ids=[0]),
1113 + _UnboundStoreBatch(job_id=6, keys=[b"k2"], block_ids=[1]),
1114 + ]
1115 +
1116 + class _Ctrl:
1117 + def poll(self_inner):
1118 + return []
1119 +
1120 + mgr._control = _Ctrl() # type: ignore[assignment]
1121 + mgr._poll_once()
1122 +
1123 + assert mgr._kv_to_session["req-1"] is sess
1124 + assert "req-1" not in mgr._unbound_stores
1125 + replayed = [
1126 + (kv_req_id, list(keys), job_id)
1127 + for kv_req_id, keys, _, job_id in sess.stores_added
1128 + ]
1129 + assert replayed == [("req-1", [b"k1"], 5), ("req-1", [b"k2"], 6)]
1130 +
1131 + def test_new_fetch_id_with_no_unbound_still_binds(self):
1132 + """A FetchMsg for a kv_request_id with no parked batches still
1133 + records the binding so subsequent submit_stores route fast."""
1134 + mgr = _make_manager()
1135 + peer = "10.0.0.1:8000"
1136 + sess = _FakeSession(
1137 + peer_id=peer,
1138 + alive=True,
1139 + connected=True,
1140 + new_fetch_ids=["req-fast"],
1141 + )
1142 + mgr._sessions[peer] = sess # type: ignore[assignment]
1143 +
1144 + class _Ctrl:
1145 + def poll(self_inner):
1146 + return []
1147 +
1148 + mgr._control = _Ctrl() # type: ignore[assignment]
1149 + mgr._poll_once()
1150 +
1151 + assert mgr._kv_to_session["req-fast"] is sess
1152 + assert sess.stores_added == []
1153 +
1154 + def test_failed_load_records_kv_request_id(self):
1155 + """A LoadResult(success=False) from session.poll() must add its
1156 + kv_request_id to _failed_req_ids so future lookups return MISS."""
1157 + mgr = _make_manager()
1158 + peer = "10.0.0.1:8000"
1159 + sess = _FakeSession(
1160 + peer_id=peer,
1161 + alive=True,
1162 + connected=True,
1163 + loads=[LoadResult(job_id=5, kv_request_id="req-5", success=False)],
1164 + )
1165 + mgr._sessions[peer] = sess # type: ignore[assignment]
1166 +
1167 + class _Ctrl:
1168 + def poll(self_inner):
1169 + return []
1170 +
1171 + mgr._control = _Ctrl() # type: ignore[assignment]
1172 +
1173 + mgr._poll_once()
1174 +
1175 + assert mgr._finished_jobs == [JobResult(job_id=5, success=False)]
1176 + assert "req-5" in mgr._failed_req_ids
1177 +
1178 +
1179 +# ---------------------------------------------------------------------------
1180 +# drain_jobs
1181 +# ---------------------------------------------------------------------------
1182 +
1183 +
1184 +class _DrainCtrl:
1185 + """Trivial control fake whose poll() returns an empty list."""
1186 +
1187 + def poll(self):
1188 + return []
1189 +
1190 +
1191 +class TestDrainJobs:
1192 + def test_returns_immediately_when_quiescent(self):
1193 + """No sessions and no inflight: drain_jobs returns without sleeping."""
1194 + mgr = _make_manager()
1195 + mgr._control = _DrainCtrl() # type: ignore[assignment]
1196 +
1197 + sleeps: list[float] = []
1198 + # If drain_jobs sleeps when nothing is pending, that's a regression.
1199 + import vllm.v1.kv_offload.tiering.p2p.manager as m
1200 +
1201 + original_sleep = m.time.sleep
1202 + m.time.sleep = lambda s: sleeps.append(s) # type: ignore[assignment]
1203 + try:
1204 + mgr.drain_jobs()
1205 + finally:
1206 + m.time.sleep = original_sleep # type: ignore[assignment]
1207 +
1208 + assert sleeps == []
1209 +
1210 + def test_returns_when_session_has_no_inflight_or_inbound(self):
1211 + """A session with empty _inbound and _inflight does not block drain."""
1212 + mgr = _make_manager()
1213 + mgr._control = _DrainCtrl() # type: ignore[assignment]
1214 + mgr._sessions["peer:1"] = _FakeSession(peer_id="peer:1") # type: ignore[assignment]
1215 + # Should return on the first iteration.
1216 + mgr.drain_jobs()
1217 +
1218 + def test_logs_warning_after_5s_then_completes(self, monkeypatch):
1219 + """A session that stays inflight past 5s triggers the warning, and
1220 + once it clears the loop returns."""
1221 + mgr = _make_manager()
1222 + mgr._control = _DrainCtrl() # type: ignore[assignment]
1223 + sess = _FakeSession(peer_id="peer:1")
1224 + sess._server._inflight = {1: object()} # non-empty
1225 + mgr._sessions["peer:1"] = sess # type: ignore[assignment]
1226 +
1227 + # Synthetic monotonic clock: 100.0 for the start stamp, then 106.0
1228 + # for the first elapsed-check (past the 5s warning threshold), then
1229 + # steady at 106.0 for any later checks.
1230 + clock = iter([100.0, 106.0])
1231 +
1232 + def fake_monotonic() -> float:
1233 + try:
1234 + return next(clock)
1235 + except StopIteration:
1236 + return 106.0
1237 +
1238 + monkeypatch.setattr(manager_module, "_DRAIN_SLEEP_S", 0.0)
1239 +
1240 + # Spy on the warning logger directly — vllm's logger does not
1241 + # propagate to root, so caplog can't see it.
1242 + warnings: list[str] = []
1243 +
1244 + def record_warning(msg, *args, **_kwargs):
1245 + warnings.append(msg % args if args else msg)
1246 +
1247 + monkeypatch.setattr(manager_module.logger, "warning", record_warning)
1248 +
1249 + # Clear inflight after the first sleep so drain can exit on the
1250 + # next iteration's `pending` check.
1251 + n_sleeps = 0
1252 +
1253 + def clearing_sleep(_s):
1254 + nonlocal n_sleeps
1255 + n_sleeps += 1
1256 + sess._server._inflight = {}
1257 +
1258 + # Patch via a fake module on `manager_module.time` so we do not mutate
1259 + # the global `time` module — other code in the process (e.g. the
1260 + # buildkite test collector's pytest_runtest_logreport hook) calls
1261 + # time.monotonic() before monkeypatch teardown.
1262 + fake_time = SimpleNamespace(monotonic=fake_monotonic, sleep=clearing_sleep)
1263 + monkeypatch.setattr(manager_module, "time", fake_time)
1264 +
1265 + mgr.drain_jobs()
1266 +
1267 + assert any("still draining after 5s" in w for w in warnings), warnings
1268 +
1269 +
1270 +# ---------------------------------------------------------------------------
1271 +# on_schedule_end
1272 +# ---------------------------------------------------------------------------
1273 +
1274 +
1275 +class TestOnScheduleEnd:
1276 + def test_is_noop(self):
1277 + """on_schedule_end is a documented no-op; just confirm it doesn't
1278 + raise and doesn't mutate state."""
1279 + mgr = _make_manager()
1280 + before_sessions = dict(mgr._sessions)
1281 + before_jobs = list(mgr._finished_jobs)
1282 + assert mgr.on_schedule_end() is None
1283 + assert mgr._sessions == before_sessions
1284 + assert mgr._finished_jobs == before_jobs
1285 +
1286 +
1287 +# ---------------------------------------------------------------------------
1288 +# Connection death mid-transfer (real P2PSession via paired managers)
1289 +# ---------------------------------------------------------------------------
1290 +
1291 +
1292 +class TestConnectionDeathMidTransfer:
1293 + """When a peer's control connection dies while a load is in flight,
1294 + the load surfaces as failed and its kv_request_id lands in
1295 + _failed_req_ids so future lookups route to local prefill. The
1296 + prefiller-side store no longer travels through the session at store
1297 + time (it's parked in _unbound_stores keyed by kv_request_id), so its
1298 + cleanup on connection death is via on_request_finished or the
1299 + unbound-store timeout — covered separately below."""
1300 +
1301 + def test_dead_connection_with_pending_work_surfaces_failures(self):
1302 + mgr_a, mgr_b = _build_paired_managers()
1303 +
1304 + a_decoder_params = {
1305 + "prefill": {
1306 + "kv_request_id": "req-load",
1307 + "remote_host": "B",
1308 + "remote_port": 2,
1309 + },
1310 + }
1311 + a_prefiller_params = {"decode": {"kv_request_id": "req-store"}}
1312 +
1313 + # Open the outbound session A->B and submit one load + one store.
1314 + mgr_a.on_new_request(_req_context(a_decoder_params))
1315 + mgr_a.submit_store(
1316 + _job_metadata(
1317 + job_id=900,
1318 + keys=[b"a-block"],
1319 + block_ids=[0],
1320 + kv_params=a_prefiller_params,
1321 + )
1322 + )
1323 + mgr_a.submit_load(
1324 + _job_metadata(
1325 + job_id=901,
1326 + keys=[b"b-block"],
1327 + block_ids=[0],
1328 + kv_params=a_decoder_params,
1329 + )
1330 + )
1331 +
1332 + # Drain anything the loopback can deliver synchronously, but stop
1333 + # before the remote side has had time to complete the transfers.
1334 + list(mgr_a.get_finished_jobs())
1335 +
1336 + # Sanity: store 900 is parked in unbound_stores, not in any
1337 + # session — the producer no longer learns the peer at store time.
1338 + assert "req-store" in mgr_a._unbound_stores
1339 +
1340 + # Kill the control connection out from under the session.
1341 + peer_id = "B:2"
1342 + sess = mgr_a._sessions[peer_id]
1343 + assert sess._conn is not None
1344 + sess._conn.mark_dead()
1345 +
1346 + # Reap surfaces the load (which lived inside the session) as
1347 + # failed. The store 900 is not session-scoped — it survives the
1348 + # session reap and waits for the unbound-store timeout.
1349 + results: list[JobResult] = []
1350 + for _ in range(3):
1351 + results.extend(list(mgr_a.get_finished_jobs()))
1352 +
1353 + outcomes = {(r.job_id, r.success) for r in results}
1354 + assert (901, False) in outcomes, f"load should fail: {outcomes}"
1355 + assert (900, False) not in outcomes, f"store should still be parked: {outcomes}"
1356 + assert "req-load" in mgr_a._failed_req_ids
1357 + # Session removed.
1358 + assert peer_id not in mgr_a._sessions
1359 + # Store batch is still parked.
1360 + assert "req-store" in mgr_a._unbound_stores
1361 +
1362 + # The engine signals the producer's request is done. That is a
1363 + # no-op for the parked batch — `on_request_finished` does not
1364 + # evict unbound stores; only `_reap_unbound_stores` does, after
1365 + # the unbound-store timeout. Job 900 stays unfinished here.
1366 + mgr_a.on_request_finished(_req_context(a_prefiller_params))
1367 + finishes = {(r.job_id, r.success) for r in mgr_a._finished_jobs}
1368 + assert (900, False) not in finishes
1369 + assert (900, True) not in finishes
1370 + assert "req-store" in mgr_a._unbound_stores
tests/v1/kv_offload/tiering/p2p/test_sessions.py new
+1645
@@ -0,0 +1,1645 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for the unified bidirectional P2PSession.
4 +
5 +A P2PSession owns a single ControlConnection and dispatches every message
6 +type — both client-role (FetchMsg / TransferDoneMsg / AbortAck) and
7 +server-role (FetchMsg / TransferDoneMsg / AbortAck from the peer's
8 +perspective). These tests exercise both flows independently and the
9 +bidirectional case where one session simultaneously serves a fetch and
10 +completes its own load.
11 +"""
12 +
13 +from __future__ import annotations
14 +
15 +import time
16 +
17 +import pytest
18 +
19 +from vllm.v1.kv_offload.tiering.p2p.session import (
20 + LoadResult,
21 + P2PSession,
22 + StoreResult,
23 +)
24 +from vllm.v1.kv_offload.tiering.p2p.session.client import (
25 + _ABORT_ACK_TIMEOUT_S,
26 + _LOAD_TIMEOUT_S,
27 +)
28 +from vllm.v1.kv_offload.tiering.p2p.session.protocol import (
29 + TYPE_KEY,
30 + AbortAckMsg,
31 + AbortFetchMsg,
32 + ConnectAckMsg,
33 + ConnectMsg,
34 + DisconnectMsg,
35 + FetchMsg,
36 + TransferDoneMsg,
37 +)
38 +from vllm.v1.kv_offload.tiering.p2p.session.server import (
39 + _CANCEL_DRAIN_TIMEOUT_S,
40 + _InflightXfer,
41 +)
42 +from vllm.v1.kv_offload.tiering.p2p.session.session import (
43 + _MAX_CONSECUTIVE_DISPATCH_ERRORS,
44 +)
45 +
46 +# ---------------------------------------------------------------------------
47 +# Helpers
48 +# ---------------------------------------------------------------------------
49 +
50 +
51 +class FakeDataTransport:
52 + """Minimal fake DataTransport for testing sessions."""
53 +
54 + def __init__(
55 + self,
56 + base_addr: int = 0x1000,
57 + num_blocks: int = 16,
58 + block_len: int = 4096,
59 + config_fingerprint: str = "",
60 + ) -> None:
61 + self._base_addr = base_addr
62 + self._num_blocks = num_blocks
63 + self._block_len = block_len
64 + self._config_fingerprint = config_fingerprint
65 + self._remote_peers: dict[str, dict] = {}
66 + self._transfers: dict[int, tuple] = {}
67 + self._next_id = 0
68 + self._poll_done: list[int] = []
69 + self._poll_failed: list[int] = []
70 + self._cancel_still_inflight: set[int] = set()
71 + self._cancel_calls: list[tuple[list[int], str]] = []
72 +
73 + @property
74 + def base_addr(self) -> int:
75 + return self._base_addr
76 +
77 + @property
78 + def num_blocks(self) -> int:
79 + return self._num_blocks
80 +
81 + @property
82 + def block_len(self) -> int:
83 + return self._block_len
84 +
85 + @property
86 + def config_fingerprint(self) -> str:
87 + return self._config_fingerprint
88 +
89 + def get_agent_metadata(self) -> bytes:
90 + return b"fake-metadata"
91 +
92 + def add_remote_peer(
93 + self, peer_id, agent_metadata, base_addr, num_blocks, block_len
94 + ) -> None:
95 + self._remote_peers[peer_id] = {
96 + ConnectMsg.AGENT_METADATA: agent_metadata,
97 + ConnectMsg.BASE_ADDR: base_addr,
98 + "num_blocks": num_blocks,
99 + "block_len": block_len,
100 + }
101 +
102 + def remove_remote_peer(self, peer_id: str) -> None:
103 + self._remote_peers.pop(peer_id, None)
104 +
105 + def write_blocks(self, peer_id, local_idxs, remote_idxs) -> int | None:
106 + if peer_id not in self._remote_peers:
107 + return None
108 + tid = self._next_id
109 + self._next_id += 1
110 + self._transfers[tid] = (peer_id, local_idxs, remote_idxs)
111 + return tid
112 +
113 + def poll(self):
114 + from vllm.v1.kv_offload.tiering.p2p.data.base import PollResult
115 +
116 + result = PollResult(done=list(self._poll_done), failed=list(self._poll_failed))
117 + self._poll_done.clear()
118 + self._poll_failed.clear()
119 + return result
120 +
121 + def cancel(self, transfer_ids, mode: str = "immediate") -> list[int]:
122 + ids = list(transfer_ids)
123 + self._cancel_calls.append((ids, mode))
124 + if mode == "wait":
125 + still: list[int] = []
126 + for tid in ids:
127 + if tid in self._cancel_still_inflight:
128 + still.append(tid)
129 + else:
130 + self._transfers.pop(tid, None)
131 + return still
132 + for tid in ids:
133 + self._transfers.pop(tid, None)
134 + self._cancel_still_inflight.discard(tid)
135 + return []
136 +
137 + def close(self) -> None:
138 + pass
139 +
140 +
141 +class FakeConnection:
142 + """Fake ControlConnection that captures sent messages."""
143 +
144 + def __init__(self, peer_id: str = "peer:8000") -> None:
145 + self.peer_id = peer_id
146 + self._inbox: list[dict] = []
147 + self._sent: list[dict] = []
148 + self._closed = False
149 +
150 + @property
151 + def alive(self) -> bool:
152 + return not self._closed
153 +
154 + def send(self, msg: dict) -> None:
155 + self._sent.append(msg)
156 +
157 + def recv(self) -> list[dict]:
158 + msgs = self._inbox
159 + self._inbox = []
160 + return msgs
161 +
162 + def enqueue(self, msg: dict) -> None:
163 + self._inbox.append(msg)
164 +
165 + def mark_dead(self) -> None:
166 + self._closed = True
167 +
168 + def close(self) -> None:
169 + self._closed = True
170 +
171 +
172 +def _peer_connect_msg(
173 + peer_id: str = "peer:8000",
174 + block_len: int = 4096,
175 + fingerprint: str | None = None,
176 +) -> dict:
177 + """Build a ConnectMsg as if the peer sent it."""
178 + msg = {
179 + TYPE_KEY: ConnectMsg.TYPE,
180 + ConnectMsg.PEER_ID: peer_id,
181 + ConnectMsg.AGENT_METADATA: b"peer-metadata",
182 + ConnectMsg.BASE_ADDR: 0x2000,
183 + ConnectMsg.NUM_BLOCKS: 16,
184 + ConnectMsg.BLOCK_LEN: block_len,
185 + }
186 + if fingerprint is not None:
187 + msg[ConnectMsg.CONFIG_FINGERPRINT] = fingerprint
188 + return msg
189 +
190 +
191 +def _make_session(
192 + conn: FakeConnection | None = None,
193 + transport: FakeDataTransport | None = None,
194 + peer_id: str = "peer:8000",
195 + local_id: str = "local:9000",
196 +) -> tuple[P2PSession, FakeConnection, FakeDataTransport]:
197 + if conn is None:
198 + conn = FakeConnection(peer_id=peer_id)
199 + if transport is None:
200 + transport = FakeDataTransport()
201 + session = P2PSession(
202 + peer_id=peer_id,
203 + local_id=local_id,
204 + transport=transport, # type: ignore[arg-type]
205 + local_block_len=transport.block_len,
206 + conn=conn, # type: ignore[arg-type]
207 + )
208 + return session, conn, transport
209 +
210 +
211 +def _activate(
212 + session: P2PSession, conn: FakeConnection, peer_id: str = "peer:8000"
213 +) -> None:
214 + """Drive the handshake: peer sends ConnectMsg + ConnectAckMsg."""
215 + conn.enqueue(_peer_connect_msg(peer_id=peer_id))
216 + conn.enqueue({TYPE_KEY: ConnectAckMsg.TYPE, ConnectAckMsg.PEER_ID: peer_id})
217 + session.poll()
218 +
219 +
220 +# ---------------------------------------------------------------------------
221 +# Connect / handshake
222 +# ---------------------------------------------------------------------------
223 +
224 +
225 +class TestConnectHandshake:
226 + def test_connect_msg_sent_on_creation(self):
227 + """Session sends its own ConnectMsg on connection."""
228 + session, conn, _ = _make_session()
229 + assert len(conn._sent) == 1
230 + msg = conn._sent[0]
231 + assert msg[TYPE_KEY] == ConnectMsg.TYPE
232 + assert msg[ConnectMsg.PEER_ID] == "local:9000"
233 + assert msg[ConnectMsg.NUM_BLOCKS] == 16
234 + assert msg[ConnectMsg.BLOCK_LEN] == 4096
235 + assert ConnectMsg.AGENT_METADATA in msg
236 +
237 + def test_peer_connect_triggers_add_remote_and_ack(self):
238 + """Receiving ConnectMsg registers peer and replies with ConnectAck."""
239 + session, conn, transport = _make_session()
240 + conn.enqueue(_peer_connect_msg())
241 + session.poll()
242 + assert "peer:8000" in transport._remote_peers
243 + ack = next(m for m in conn._sent if m[TYPE_KEY] == ConnectAckMsg.TYPE)
244 + assert ack[ConnectAckMsg.PEER_ID] == "local:9000"
245 +
246 + def test_connect_ack_makes_session_ready(self):
247 + """Session.ready becomes True after ConnectAckMsg."""
248 + session, conn, _ = _make_session()
249 + assert not session.ready
250 + conn.enqueue({TYPE_KEY: ConnectAckMsg.TYPE, ConnectAckMsg.PEER_ID: "peer:8000"})
251 + session.poll()
252 + assert session.ready
253 +
254 + def test_messages_queued_before_ack_flush_on_ack(self):
255 + """Outgoing messages sent before ConnectAck are flushed after."""
256 + session, conn, _ = _make_session()
257 + session.request_blocks(
258 + job_id=1, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
259 + )
260 + # Before ack: only our ConnectMsg was sent.
261 + assert len(conn._sent) == 1
262 + assert conn._sent[0][TYPE_KEY] == ConnectMsg.TYPE
263 + # Ack arrives.
264 + conn.enqueue({TYPE_KEY: ConnectAckMsg.TYPE, ConnectAckMsg.PEER_ID: "peer:8000"})
265 + session.poll()
266 + # Queued fetch is now sent.
267 + assert any(m[TYPE_KEY] == FetchMsg.TYPE for m in conn._sent)
268 +
269 + def test_block_len_mismatch_marks_dead(self):
270 + """Mismatched block_len rejects peer and marks connection dead."""
271 + session, conn, transport = _make_session()
272 + conn.enqueue(_peer_connect_msg(block_len=8192)) # mismatch
273 + session.poll()
274 + assert "peer:8000" not in transport._remote_peers
275 + assert not session.alive
276 +
277 + def test_config_fingerprint_mismatch_marks_dead(self):
278 + """Mismatched config fingerprint rejects peer."""
279 + transport = FakeDataTransport(config_fingerprint="abc123")
280 + session, conn, _ = _make_session(transport=transport)
281 + conn.enqueue(_peer_connect_msg(fingerprint="different"))
282 + session.poll()
283 + assert "peer:8000" not in transport._remote_peers
284 + assert not session.alive
285 +
286 + def test_config_fingerprint_match_succeeds(self):
287 + """Matching fingerprints register the peer."""
288 + transport = FakeDataTransport(config_fingerprint="same_fp")
289 + session, conn, _ = _make_session(transport=transport)
290 + conn.enqueue(_peer_connect_msg(fingerprint="same_fp"))
291 + session.poll()
292 + assert "peer:8000" in transport._remote_peers
293 + assert session.alive
294 +
295 + def test_missing_fingerprint_allowed(self):
296 + """Missing fingerprint on either side is allowed."""
297 + transport = FakeDataTransport(config_fingerprint="abc123")
298 + session, conn, _ = _make_session(transport=transport)
299 + conn.enqueue(_peer_connect_msg()) # no fingerprint
300 + session.poll()
301 + assert "peer:8000" in transport._remote_peers
302 +
303 +
304 +# ---------------------------------------------------------------------------
305 +# Client-role flows
306 +# ---------------------------------------------------------------------------
307 +
308 +
309 +class TestClientFlows:
310 + def test_request_blocks_sends_fetch(self):
311 + session, conn, _ = _make_session()
312 + _activate(session, conn)
313 + session.request_blocks(
314 + job_id=1, kv_request_id="req-1", keys=[b"k1", b"k2"], block_ids=[0, 1]
315 + )
316 + lookup = conn._sent[-1]
317 + assert lookup[TYPE_KEY] == FetchMsg.TYPE
318 + assert lookup[FetchMsg.KV_REQUEST_ID] == "req-1"
319 + assert lookup[FetchMsg.BLOCK_HASHES] == [b"k1", b"k2"]
320 + assert lookup[FetchMsg.BLOCK_INDEXES] == [0, 1]
321 +
322 + def test_transfer_done_success(self):
323 + session, conn, _ = _make_session()
324 + _activate(session, conn)
325 + session.request_blocks(
326 + job_id=1, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
327 + )
328 + conn.enqueue(
329 + {
330 + TYPE_KEY: TransferDoneMsg.TYPE,
331 + TransferDoneMsg.KV_REQUEST_ID: "req-1",
332 + TransferDoneMsg.SUCCESS: True,
333 + }
334 + )
335 + loads = session.poll().loads
336 + assert loads == [LoadResult(job_id=1, kv_request_id="req-1", success=True)]
337 +
338 + def test_transfer_done_failure(self):
339 + session, conn, _ = _make_session()
340 + _activate(session, conn)
341 + session.request_blocks(
342 + job_id=1, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
343 + )
344 + conn.enqueue(
345 + {
346 + TYPE_KEY: TransferDoneMsg.TYPE,
347 + TransferDoneMsg.KV_REQUEST_ID: "req-1",
348 + TransferDoneMsg.SUCCESS: False,
349 + }
350 + )
351 + loads = session.poll().loads
352 + assert loads == [LoadResult(job_id=1, kv_request_id="req-1", success=False)]
353 +
354 + def test_finish_request_sends_abort(self):
355 + session, conn, _ = _make_session()
356 + _activate(session, conn)
357 + session.request_blocks(
358 + job_id=1, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
359 + )
360 + session.finish_request("req-1")
361 + abort = conn._sent[-1]
362 + assert abort[TYPE_KEY] == AbortFetchMsg.TYPE
363 + assert abort[AbortFetchMsg.KV_REQUEST_ID] == "req-1"
364 +
365 + def test_load_timeout_sends_abort(self):
366 + session, conn, _ = _make_session()
367 + _activate(session, conn)
368 + session.request_blocks(
369 + job_id=1, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
370 + )
371 + session._client._inbound["req-1"].submitted_at = time.monotonic() - 60.0
372 + session.poll()
373 + abort = conn._sent[-1]
374 + assert abort[TYPE_KEY] == AbortFetchMsg.TYPE
375 +
376 + def test_load_abort_ack_timeout_surfaces_failure(self):
377 + """After load timeout sends AbortFetch, if no AbortAck arrives within
378 + _ABORT_ACK_TIMEOUT_S the request is surfaced as failed and removed
379 + from _inbound — the engine cannot wait forever on a peer that won't
380 + ack.
381 + """
382 + session, conn, _ = _make_session()
383 + _activate(session, conn)
384 + session.request_blocks(
385 + job_id=7, kv_request_id="req-7", keys=[b"k"], block_ids=[0]
386 + )
387 + # 1) Trip the load timeout to send AbortFetch and stamp aborted_at.
388 + session._client._inbound["req-7"].submitted_at = (
389 + time.monotonic() - _LOAD_TIMEOUT_S - 1.0
390 + )
391 + loads = session.poll().loads
392 + assert loads == []
393 + assert any(
394 + m.get(TYPE_KEY) == AbortFetchMsg.TYPE
395 + and m[AbortFetchMsg.KV_REQUEST_ID] == "req-7"
396 + for m in conn._sent
397 + )
398 + assert session._client._inbound["req-7"].aborted_at is not None
399 +
400 + # 2) Now backdate aborted_at past the abort-ack timeout. No ack ever
401 + # arrived from the peer.
402 + session._client._inbound["req-7"].aborted_at = (
403 + time.monotonic() - _ABORT_ACK_TIMEOUT_S - 1.0
404 + )
405 + loads = session.poll().loads
406 + assert loads == [LoadResult(job_id=7, kv_request_id="req-7", success=False)]
407 + assert "req-7" not in session._client._inbound
408 +
409 + def test_load_abort_ack_clears_request(self):
410 + """After load timeout sends AbortFetch, an arriving AbortAckMsg from
411 + the peer surfaces the failure cleanly and removes the request from
412 + _inbound — covers the on_abort_ack arrival path."""
413 + session, conn, _ = _make_session()
414 + _activate(session, conn)
415 + session.request_blocks(
416 + job_id=8, kv_request_id="req-8", keys=[b"k"], block_ids=[0]
417 + )
418 + session._client._inbound["req-8"].submitted_at = (
419 + time.monotonic() - _LOAD_TIMEOUT_S - 1.0
420 + )
421 + # First poll: AbortFetch goes out.
422 + session.poll()
423 + assert session._client._inbound["req-8"].aborted_at is not None
424 +
425 + # Peer acks the abort.
426 + conn.enqueue(
427 + {
428 + TYPE_KEY: AbortAckMsg.TYPE,
429 + AbortAckMsg.KV_REQUEST_ID: "req-8",
430 + }
431 + )
432 + loads = session.poll().loads
433 + assert loads == [LoadResult(job_id=8, kv_request_id="req-8", success=False)]
434 + assert "req-8" not in session._client._inbound
435 +
436 +
437 +# ---------------------------------------------------------------------------
438 +# Server-role flows
439 +# ---------------------------------------------------------------------------
440 +
441 +
442 +class TestServerFlows:
443 + def test_store_then_fetch_matches(self):
444 + """Blocks stored before fetch demand are matched on demand arrival."""
445 + session, conn, transport = _make_session()
446 + _activate(session, conn)
447 + session.add_stored_blocks("req-1", [b"k1", b"k2"], [0, 1], job_id=1)
448 + conn.enqueue(
449 + {
450 + TYPE_KEY: FetchMsg.TYPE,
451 + FetchMsg.KV_REQUEST_ID: "req-1",
452 + FetchMsg.BLOCK_HASHES: [b"k1", b"k2"],
453 + FetchMsg.BLOCK_INDEXES: [10, 11],
454 + }
455 + )
456 + session.poll()
457 + assert len(transport._transfers) == 1
458 + _, (peer, local, remote) = next(iter(transport._transfers.items()))
459 + assert local == [0, 1]
460 + assert remote == [10, 11]
461 +
462 + def test_fetch_then_store_matches(self):
463 + """Fetch demand registered before store; store fulfills it."""
464 + session, conn, transport = _make_session()
465 + _activate(session, conn)
466 + conn.enqueue(
467 + {
468 + TYPE_KEY: FetchMsg.TYPE,
469 + FetchMsg.KV_REQUEST_ID: "req-1",
470 + FetchMsg.BLOCK_HASHES: [b"k1"],
471 + FetchMsg.BLOCK_INDEXES: [5],
472 + }
473 + )
474 + session.poll()
475 + assert len(transport._transfers) == 0
476 + session.add_stored_blocks("req-1", [b"k1"], [3], job_id=1)
477 + assert len(transport._transfers) == 1
478 +
479 + def test_transfer_completion_emits_store_result_and_done(self):
480 + """Completed transfer reports StoreResult and sends TransferDoneMsg."""
481 + session, conn, transport = _make_session()
482 + _activate(session, conn)
483 + session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
484 + conn.enqueue(
485 + {
486 + TYPE_KEY: FetchMsg.TYPE,
487 + FetchMsg.KV_REQUEST_ID: "req-1",
488 + FetchMsg.BLOCK_HASHES: [b"k1"],
489 + FetchMsg.BLOCK_INDEXES: [5],
490 + }
491 + )
492 + session.poll()
493 + tid = next(iter(transport._transfers))
494 + transport._poll_done.append(tid)
495 + stores = session.poll().stores
496 + assert StoreResult(job_id=1, success=True) in stores
497 + assert any(m[TYPE_KEY] == TransferDoneMsg.TYPE for m in conn._sent)
498 +
499 + def test_abort_fetch_replies_with_ack(self):
500 + session, conn, _ = _make_session()
501 + _activate(session, conn)
502 + conn.enqueue(
503 + {
504 + TYPE_KEY: AbortFetchMsg.TYPE,
505 + AbortFetchMsg.KV_REQUEST_ID: "req-1",
506 + }
507 + )
508 + session.poll()
509 + ack = next(m for m in conn._sent if m[TYPE_KEY] == AbortAckMsg.TYPE)
510 + assert ack[AbortAckMsg.KV_REQUEST_ID] == "req-1"
511 + assert "req-1" not in session._server._pending_aborts
512 +
513 + def test_abort_fetch_defers_ack_when_cancel_pending(self):
514 + """If cancel(mode='wait') reports still-inflight tids, the ack is
515 + deferred and the abort is parked in _pending_aborts."""
516 + session, conn, transport = _make_session()
517 + _activate(session, conn)
518 + # Seed an inflight transfer for req-1 that the transport pretends
519 + # cannot be canceled yet.
520 + tid = 42
521 + session._server._inflight_add(
522 + tid,
523 + _InflightXfer(kv_request_id="req-1", block_count=1, job_ids={1}),
524 + )
525 + transport._cancel_still_inflight.add(tid)
526 +
527 + conn.enqueue(
528 + {
529 + TYPE_KEY: AbortFetchMsg.TYPE,
530 + AbortFetchMsg.KV_REQUEST_ID: "req-1",
531 + }
532 + )
533 + session.poll()
534 +
535 + assert not any(m[TYPE_KEY] == AbortAckMsg.TYPE for m in conn._sent)
536 + assert "req-1" in session._server._pending_aborts
537 + # First attempt happens inside _on_abort_fetch; the per-tick
538 + # drain runs again at the end of poll() — both are wait-mode.
539 + assert all(mode == "wait" for _, mode in transport._cancel_calls)
540 + assert tid in session._server._inflight # still tracked
541 +
542 + def test_abort_fetch_acks_after_drain(self):
543 + """Once the transport reports the tid as DONE the parked abort
544 + completes and AbortAckMsg is sent."""
545 + session, conn, transport = _make_session()
546 + _activate(session, conn)
547 + tid = 42
548 + session._server._inflight_add(
549 + tid,
550 + _InflightXfer(kv_request_id="req-1", block_count=1, job_ids={1}),
551 + )
552 + transport._cancel_still_inflight.add(tid)
553 +
554 + conn.enqueue(
555 + {
556 + TYPE_KEY: AbortFetchMsg.TYPE,
557 + AbortFetchMsg.KV_REQUEST_ID: "req-1",
558 + }
559 + )
560 + session.poll()
561 + assert "req-1" in session._server._pending_aborts
562 +
563 + # Backend finishes draining: transport.poll() will return tid as
564 + # DONE, and the next cancel(mode='wait') call sees it's gone.
565 + transport._cancel_still_inflight.discard(tid)
566 + transport._poll_done.append(tid)
567 +
568 + session.poll()
569 +
570 + ack = next(m for m in conn._sent if m[TYPE_KEY] == AbortAckMsg.TYPE)
571 + assert ack[AbortAckMsg.KV_REQUEST_ID] == "req-1"
572 + assert "req-1" not in session._server._pending_aborts
573 + assert tid not in session._server._inflight
574 +
575 + def test_abort_fetch_force_cancels_after_timeout(self):
576 + """If wait-mode never drains, the deadline forces immediate
577 + cancel and an ack is still sent."""
578 + session, conn, transport = _make_session()
579 + _activate(session, conn)
580 + tid = 42
581 + session._server._inflight_add(
582 + tid,
583 + _InflightXfer(kv_request_id="req-1", block_count=1, job_ids={1}),
584 + )
585 + transport._cancel_still_inflight.add(tid)
586 +
587 + conn.enqueue(
588 + {
589 + TYPE_KEY: AbortFetchMsg.TYPE,
590 + AbortFetchMsg.KV_REQUEST_ID: "req-1",
591 + }
592 + )
593 + session.poll()
594 + assert "req-1" in session._server._pending_aborts
595 + # Backdate past the drain deadline.
596 + session._server._pending_aborts["req-1"] = (
597 + time.monotonic() - _CANCEL_DRAIN_TIMEOUT_S - 1.0
598 + )
599 + # Even if the transport still claims it can't cancel, the
600 + # session must force-pop and ack.
601 + transport._cancel_calls.clear()
602 +
603 + session.poll()
604 +
605 + ack = next(m for m in conn._sent if m[TYPE_KEY] == AbortAckMsg.TYPE)
606 + assert ack[AbortAckMsg.KV_REQUEST_ID] == "req-1"
607 + assert "req-1" not in session._server._pending_aborts
608 + assert tid not in session._server._inflight
609 + assert ([tid], "immediate") in transport._cancel_calls
610 +
611 + def test_abort_fetch_idempotent_while_draining(self):
612 + """Receiving AbortFetchMsg twice for the same kv_request_id
613 + keeps a single pending entry and produces a single ack."""
614 + session, conn, transport = _make_session()
615 + _activate(session, conn)
616 + tid = 42
617 + session._server._inflight_add(
618 + tid,
619 + _InflightXfer(kv_request_id="req-1", block_count=1, job_ids={1}),
620 + )
621 + transport._cancel_still_inflight.add(tid)
622 +
623 + conn.enqueue(
624 + {
625 + TYPE_KEY: AbortFetchMsg.TYPE,
626 + AbortFetchMsg.KV_REQUEST_ID: "req-1",
627 + }
628 + )
629 + session.poll()
630 + first_started_at = session._server._pending_aborts["req-1"]
631 +
632 + # Second AbortFetchMsg for the same kv_request_id while still
633 + # draining must not reset the deadline.
634 + conn.enqueue(
635 + {
636 + TYPE_KEY: AbortFetchMsg.TYPE,
637 + AbortFetchMsg.KV_REQUEST_ID: "req-1",
638 + }
639 + )
640 + session.poll()
641 + assert session._server._pending_aborts["req-1"] == first_started_at
642 +
643 + # Now let the drain succeed and confirm exactly one ack ever.
644 + transport._cancel_still_inflight.discard(tid)
645 + transport._poll_done.append(tid)
646 + session.poll()
647 +
648 + acks = [m for m in conn._sent if m[TYPE_KEY] == AbortAckMsg.TYPE]
649 + assert len(acks) == 1
650 + assert acks[0][AbortAckMsg.KV_REQUEST_ID] == "req-1"
651 +
652 + def test_store_timeout(self):
653 + session, conn, _ = _make_session()
654 + _activate(session, conn)
655 + session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
656 + # Backdate.
657 + session._server._store_jobs[1] = time.monotonic() - 60.0
658 + stores = session.poll().stores
659 + assert StoreResult(job_id=1, success=False) in stores
660 +
661 + def test_store_timeout_then_late_completion_no_duplicate(self):
662 + """A job timed out by _timeout_pending_store_jobs must not also
663 + emit a contradictory StoreResult(success=True) when the transport
664 + later reports the same transfer as done."""
665 + session, conn, transport = _make_session()
666 + _activate(session, conn)
667 + session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
668 + conn.enqueue(
669 + {
670 + TYPE_KEY: FetchMsg.TYPE,
671 + FetchMsg.KV_REQUEST_ID: "req-1",
672 + FetchMsg.BLOCK_HASHES: [b"k1"],
673 + FetchMsg.BLOCK_INDEXES: [5],
674 + }
675 + )
676 + session.poll()
677 + tid = next(iter(transport._transfers))
678 +
679 + # Backdate the store job so the next poll times it out.
680 + session._server._store_jobs[1] = time.monotonic() - 60.0
681 + stores = session.poll().stores
682 + assert StoreResult(job_id=1, success=False) in stores
683 + assert StoreResult(job_id=1, success=True) not in stores
684 +
685 + # Transport later reports the same transfer as done — must not
686 + # emit a second (contradictory) StoreResult for job_id=1.
687 + transport._poll_done.append(tid)
688 + stores = session.poll().stores
689 + assert all(s.job_id != 1 for s in stores), (
690 + f"unexpected duplicate StoreResult after timeout: {stores}"
691 + )
692 +
693 + def test_store_timeout_then_late_failure_no_duplicate(self):
694 + """Symmetric guard: a timed-out job must not also emit a second
695 + StoreResult(success=False) when the transport later reports the
696 + same transfer as failed."""
697 + session, conn, transport = _make_session()
698 + _activate(session, conn)
699 + session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
700 + conn.enqueue(
701 + {
702 + TYPE_KEY: FetchMsg.TYPE,
703 + FetchMsg.KV_REQUEST_ID: "req-1",
704 + FetchMsg.BLOCK_HASHES: [b"k1"],
705 + FetchMsg.BLOCK_INDEXES: [5],
706 + }
707 + )
708 + session.poll()
709 + tid = next(iter(transport._transfers))
710 +
711 + session._server._store_jobs[1] = time.monotonic() - 60.0
712 + stores = session.poll().stores
713 + assert [s for s in stores if s.job_id == 1] == [
714 + StoreResult(job_id=1, success=False)
715 + ]
716 +
717 + transport._poll_failed.append(tid)
718 + stores = session.poll().stores
719 + assert all(s.job_id != 1 for s in stores), (
720 + f"unexpected duplicate StoreResult after timeout: {stores}"
721 + )
722 +
723 +
724 +# ---------------------------------------------------------------------------
725 +# finish_request server-role early-fail flow
726 +# ---------------------------------------------------------------------------
727 +
728 +
729 +class TestFinishRequestServerSide:
730 + def _last_transfer_done(self, conn: FakeConnection) -> dict | None:
731 + for msg in reversed(conn._sent):
732 + if msg[TYPE_KEY] == TransferDoneMsg.TYPE:
733 + return msg
734 + return None
735 +
736 + def test_no_inflight_unmatched_demand_sends_failure(self):
737 + """finish_request with unmatched demand and no inflight ->
738 + immediate TransferDoneMsg(success=False); _outbound cleared."""
739 + session, conn, _ = _make_session()
740 + _activate(session, conn)
741 + # Decoder demanded a block we never stored.
742 + conn.enqueue(
743 + {
744 + TYPE_KEY: FetchMsg.TYPE,
745 + FetchMsg.KV_REQUEST_ID: "req-1",
746 + FetchMsg.BLOCK_HASHES: [b"k1"],
747 + FetchMsg.BLOCK_INDEXES: [5],
748 + }
749 + )
750 + session.poll()
751 + assert "req-1" in session._server._outbound
752 +
753 + session.finish_request("req-1")
754 +
755 + msg = self._last_transfer_done(conn)
756 + assert msg is not None
757 + assert msg[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
758 + assert msg[TransferDoneMsg.SUCCESS] is False
759 + assert "req-1" not in session._server._outbound
760 +
761 + def test_with_inflight_defers_then_fires_on_last_transfer(self):
762 + """finish_request with inflight defers; last transfer fires the
763 + early-fail message and clears _outbound."""
764 + session, conn, transport = _make_session()
765 + _activate(session, conn)
766 + # Demand 2 blocks; we store 1 (kicks one inflight transfer).
767 + conn.enqueue(
768 + {
769 + TYPE_KEY: FetchMsg.TYPE,
770 + FetchMsg.KV_REQUEST_ID: "req-1",
771 + FetchMsg.BLOCK_HASHES: [b"k1", b"k2"],
772 + FetchMsg.BLOCK_INDEXES: [10, 11],
773 + }
774 + )
775 + session.poll()
776 + session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
777 + assert len(transport._transfers) == 1
778 +
779 + # finish_request while inflight: no early-fail yet.
780 + before = len(conn._sent)
781 + session.finish_request("req-1")
782 + assert len(conn._sent) == before
783 + assert "req-1" in session._server._outbound
784 + assert session._server._outbound["req-1"].finishing
785 +
786 + # Last inflight settles -> early-fail fires.
787 + tid = next(iter(transport._transfers))
788 + transport._poll_done.append(tid)
789 + session.poll()
790 +
791 + msg = self._last_transfer_done(conn)
792 + assert msg is not None
793 + assert msg[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
794 + assert msg[TransferDoneMsg.SUCCESS] is False
795 + assert "req-1" not in session._server._outbound
796 +
797 + def test_full_demand_satisfied_still_sends_success(self):
798 + """finish_request must not override a fully-satisfied transfer:
799 + when remaining hits 0, success=True still fires."""
800 + session, conn, transport = _make_session()
801 + _activate(session, conn)
802 + conn.enqueue(
803 + {
804 + TYPE_KEY: FetchMsg.TYPE,
805 + FetchMsg.KV_REQUEST_ID: "req-1",
806 + FetchMsg.BLOCK_HASHES: [b"k1"],
807 + FetchMsg.BLOCK_INDEXES: [10],
808 + }
809 + )
810 + session.poll()
811 + session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
812 + # Mark finishing (e.g., on_request_finished racing with the last
813 + # store) — but all demand is satisfied.
814 + session.finish_request("req-1")
815 +
816 + tid = next(iter(transport._transfers))
817 + transport._poll_done.append(tid)
818 + session.poll()
819 +
820 + msg = next(m for m in conn._sent if m[TYPE_KEY] == TransferDoneMsg.TYPE)
821 + assert msg[TransferDoneMsg.SUCCESS] is True
822 +
823 + def test_prefiller_first_finish_before_fetch(self):
824 + """Prefiller-first: finish_request runs before the decoder's
825 + fetch arrives. State is held until fetch, then
826 + finalized — success=True if all demand was matched against
827 + available blocks, else success=False."""
828 + # Case A: all demand satisfied by available blocks.
829 + session, conn, transport = _make_session()
830 + _activate(session, conn)
831 + session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
832 + # finish_request first — no demand received yet -> defer.
833 + session.finish_request("req-1")
834 + assert "req-1" in session._server._outbound
835 + # Fetch arrives now: demand fully satisfied by available.
836 + conn.enqueue(
837 + {
838 + TYPE_KEY: FetchMsg.TYPE,
839 + FetchMsg.KV_REQUEST_ID: "req-1",
840 + FetchMsg.BLOCK_HASHES: [b"k1"],
841 + FetchMsg.BLOCK_INDEXES: [10],
842 + }
843 + )
844 + session.poll()
845 + # The transfer was inflight; finalize it.
846 + tid = next(iter(transport._transfers))
847 + transport._poll_done.append(tid)
848 + session.poll()
849 + msg = next(m for m in conn._sent if m[TYPE_KEY] == TransferDoneMsg.TYPE)
850 + assert msg[TransferDoneMsg.SUCCESS] is True
851 +
852 + # Case B: demand exceeds available -> early-fail fires from fetch.
853 + session, conn, transport = _make_session()
854 + _activate(session, conn)
855 + session.add_stored_blocks("req-2", [b"k1"], [0], job_id=2)
856 + session.finish_request("req-2")
857 + conn.enqueue(
858 + {
859 + TYPE_KEY: FetchMsg.TYPE,
860 + FetchMsg.KV_REQUEST_ID: "req-2",
861 + FetchMsg.BLOCK_HASHES: [b"k1", b"k2"],
862 + FetchMsg.BLOCK_INDEXES: [10, 11],
863 + }
864 + )
865 + session.poll()
866 + # Inflight for k1 still in flight; nothing yet for the early-fail
867 + # — the same code path will fire from _collect_store_results.
868 + tid = next(iter(transport._transfers))
869 + transport._poll_done.append(tid)
870 + session.poll()
871 + msg = next(m for m in conn._sent if m[TYPE_KEY] == TransferDoneMsg.TYPE)
872 + assert msg[TransferDoneMsg.SUCCESS] is False
873 + assert "req-2" not in session._server._outbound
874 +
875 + def test_unknown_request_is_noop(self):
876 + session, conn, _ = _make_session()
877 + _activate(session, conn)
878 + before = len(conn._sent)
879 + session.finish_request("never-existed")
880 + assert len(conn._sent) == before
881 +
882 + def test_finish_request_no_inflight_emits_store_failure(self):
883 + """finish_request with a stored-but-unmatched job and no inflight ->
884 + TransferDoneMsg(success=False) AND deferred
885 + StoreResult(success=False) for the submit_store'd job, instead of
886 + the 30s _STORE_TIMEOUT_S path."""
887 + session, conn, _ = _make_session()
888 + _activate(session, conn)
889 + # Decoder demanded b"demand"; we never stored it.
890 + conn.enqueue(
891 + {
892 + TYPE_KEY: FetchMsg.TYPE,
893 + FetchMsg.KV_REQUEST_ID: "req-1",
894 + FetchMsg.BLOCK_HASHES: [b"demand"],
895 + FetchMsg.BLOCK_INDEXES: [5],
896 + }
897 + )
898 + session.poll()
899 + # We did submit_store a different block — goes to available, never
900 + # matches demand. Without the shortcut, job 42 sits in _store_jobs
901 + # for _STORE_TIMEOUT_S.
902 + session.add_stored_blocks("req-1", [b"unrelated"], [0], job_id=42)
903 + assert session._server._outbound["req-1"].pending_job_ids == {42}
904 +
905 + session.finish_request("req-1")
906 +
907 + # Peer notified immediately with success=False (remaining > 0).
908 + msg = self._last_transfer_done(conn)
909 + assert msg is not None
910 + assert msg[TransferDoneMsg.SUCCESS] is False
911 + assert "req-1" not in session._server._outbound
912 +
913 + # Local store job surfaces on the next poll, success=False.
914 + stores = session.poll().stores
915 + assert StoreResult(job_id=42, success=False) in stores
916 + assert 42 not in session._server._store_jobs
917 +
918 + def test_finish_request_remaining_zero_emits_success_via_inflight(self):
919 + """Deferred-via-inflight path: finish_request with inflight, last
920 + transfer drains remaining to 0 -> TransferDoneMsg(success=True)
921 + AND StoreResult(success=True)."""
922 + session, conn, transport = _make_session()
923 + _activate(session, conn)
924 + conn.enqueue(
925 + {
926 + TYPE_KEY: FetchMsg.TYPE,
927 + FetchMsg.KV_REQUEST_ID: "req-1",
928 + FetchMsg.BLOCK_HASHES: [b"k1"],
929 + FetchMsg.BLOCK_INDEXES: [10],
930 + }
931 + )
932 + session.poll()
933 + session.add_stored_blocks("req-1", [b"k1"], [0], job_id=7)
934 + # finish_request races with the inflight transfer.
935 + session.finish_request("req-1")
936 + assert "req-1" in session._server._outbound # deferred
937 +
938 + # Last inflight completes -> _finalize_outbound(success=True) fires.
939 + tid = next(iter(transport._transfers))
940 + transport._poll_done.append(tid)
941 + stores = session.poll().stores
942 +
943 + msg = self._last_transfer_done(conn)
944 + assert msg is not None
945 + assert msg[TransferDoneMsg.SUCCESS] is True
946 + assert StoreResult(job_id=7, success=True) in stores
947 + assert "req-1" not in session._server._outbound
948 +
949 + def test_write_blocks_failure_finalizes_with_failure(self):
950 + """write_blocks returning None must not leave the request hanging.
951 +
952 + The matched blocks are gone from req.demanded but no inflight
953 + will satisfy them, so remaining > 0 forever. Setting finishing
954 + and calling _finalize_outbound(success=False) immediately (no
955 + other inflight) tells the peer + emits StoreResult(success=False)
956 + without waiting on _STORE_TIMEOUT_S or _LOAD_TIMEOUT_S.
957 + """
958 + session, conn, transport = _make_session()
959 + _activate(session, conn)
960 + # Decoder demands b"k1".
961 + conn.enqueue(
962 + {
963 + TYPE_KEY: FetchMsg.TYPE,
964 + FetchMsg.KV_REQUEST_ID: "req-1",
965 + FetchMsg.BLOCK_HASHES: [b"k1"],
966 + FetchMsg.BLOCK_INDEXES: [10],
967 + }
968 + )
969 + session.poll()
970 + # Force write_blocks to fail on the next call.
971 + transport.write_blocks = lambda *a, **kw: None # type: ignore[assignment]
972 +
973 + session.add_stored_blocks("req-1", [b"k1"], [0], job_id=42)
974 +
975 + # Outbound was finalized immediately (no other inflight).
976 + assert "req-1" not in session._server._outbound
977 + # Peer notified with success=False.
978 + msg = next(m for m in conn._sent if m[TYPE_KEY] == TransferDoneMsg.TYPE)
979 + assert msg[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
980 + assert msg[TransferDoneMsg.SUCCESS] is False
981 + # Local store job surfaces on the next poll.
982 + stores = session.poll().stores
983 + assert StoreResult(job_id=42, success=False) in stores
984 + assert 42 not in session._server._store_jobs
985 +
986 + def test_partial_match_completes_in_two_rounds(self):
987 + """Peer demand for [k1, k2, k3]; first round only k1 is available,
988 + second round adds k2 and k3. Each round transfers what's matched
989 + and the request finalizes with success once remaining hits zero.
990 + """
991 + session, conn, transport = _make_session()
992 + _activate(session, conn)
993 +
994 + # Peer fetches three blocks.
995 + conn.enqueue(
996 + {
997 + TYPE_KEY: FetchMsg.TYPE,
998 + FetchMsg.KV_REQUEST_ID: "req-1",
999 + FetchMsg.BLOCK_HASHES: [b"k1", b"k2", b"k3"],
1000 + FetchMsg.BLOCK_INDEXES: [10, 11, 12],
1001 + }
1002 + )
1003 + session.poll()
1004 + # Demand registered, no matches yet.
1005 + assert session._server._inflight == {}
1006 + outbound = session._server._outbound["req-1"]
1007 + assert outbound.remaining == 3
1008 + assert set(outbound.demanded.keys()) == {b"k1", b"k2", b"k3"}
1009 +
1010 + # Round 1: only k1 is stored locally.
1011 + session.add_stored_blocks("req-1", [b"k1"], [0], job_id=100)
1012 + # One inflight transfer for k1.
1013 + assert len(session._server._inflight) == 1
1014 + tid_1 = next(iter(session._server._inflight))
1015 + assert transport._transfers[tid_1][1] == [0]
1016 + assert transport._transfers[tid_1][2] == [10]
1017 + # k2 and k3 still demanded.
1018 + assert set(outbound.demanded.keys()) == {b"k2", b"k3"}
1019 +
1020 + # Transfer 1 completes.
1021 + transport._poll_done.append(tid_1)
1022 + stores = session.poll().stores
1023 + assert StoreResult(job_id=100, success=True) in stores
1024 + assert session._server._inflight == {}
1025 + assert outbound.remaining == 2
1026 + # Not yet finalized — still 2 blocks demanded.
1027 + assert "req-1" in session._server._outbound
1028 +
1029 + # Round 2: k2 and k3 arrive together.
1030 + session.add_stored_blocks("req-1", [b"k2", b"k3"], [1, 2], job_id=200)
1031 + assert len(session._server._inflight) == 1
1032 + tid_2 = next(iter(session._server._inflight))
1033 + assert tid_2 != tid_1
1034 + assert sorted(transport._transfers[tid_2][1]) == [1, 2]
1035 +
1036 + # Transfer 2 completes — request now fully satisfied.
1037 + transport._poll_done.append(tid_2)
1038 + stores = session.poll().stores
1039 + assert StoreResult(job_id=200, success=True) in stores
1040 + # _finalize_outbound fired — request gone, peer notified with success.
1041 + assert "req-1" not in session._server._outbound
1042 + done = next(m for m in conn._sent if m.get(TYPE_KEY) == TransferDoneMsg.TYPE)
1043 + assert done[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
1044 + assert done[TransferDoneMsg.SUCCESS] is True
1045 +
1046 + def test_write_blocks_failure_finalizes_after_last_inflight_completes(self):
1047 + """write_blocks returns None on a SECOND match while a first transfer
1048 + is still inflight. The request should NOT finalize until the inflight
1049 + completes, then the elif branch in collect_results
1050 + (``finishing and not _has_inflight_for(...)``) finalizes it as failure.
1051 + """
1052 + session, conn, transport = _make_session()
1053 + _activate(session, conn)
1054 +
1055 + # Peer demands two blocks.
1056 + conn.enqueue(
1057 + {
1058 + TYPE_KEY: FetchMsg.TYPE,
1059 + FetchMsg.KV_REQUEST_ID: "req-1",
1060 + FetchMsg.BLOCK_HASHES: [b"k1", b"k2"],
1061 + FetchMsg.BLOCK_INDEXES: [10, 11],
1062 + }
1063 + )
1064 + session.poll()
1065 +
1066 + # Round 1: k1 transfers cleanly.
1067 + session.add_stored_blocks("req-1", [b"k1"], [0], job_id=100)
1068 + assert len(session._server._inflight) == 1
1069 + tid_1 = next(iter(session._server._inflight))
1070 + outbound = session._server._outbound["req-1"]
1071 + assert outbound.remaining == 2 # decrement happens on completion
1072 + assert outbound.finishing is False
1073 +
1074 + # Round 2: write_blocks fails for k2 while transfer_1 is still inflight.
1075 + transport.write_blocks = lambda *a, **kw: None # type: ignore[assignment]
1076 + session.add_stored_blocks("req-1", [b"k2"], [1], job_id=200)
1077 + # No new transfer was registered.
1078 + assert list(session._server._inflight.keys()) == [tid_1]
1079 + # Marked finishing, but NOT finalized yet (transfer_1 still inflight).
1080 + assert outbound.finishing is True
1081 + assert "req-1" in session._server._outbound
1082 + done_msgs = [m for m in conn._sent if m.get(TYPE_KEY) == TransferDoneMsg.TYPE]
1083 + assert done_msgs == []
1084 +
1085 + # Transfer 1 completes — now ``_has_inflight_for("req-1")`` is False
1086 + # and the elif branch in collect_results fires _finalize(success=False).
1087 + # The k1 success result is direct; the k2 failure result is queued
1088 + # in _pending_store_results and surfaces on the NEXT poll.
1089 + transport._poll_done.append(tid_1)
1090 + stores_first = session.poll().stores
1091 + assert StoreResult(job_id=100, success=True) in stores_first
1092 + # Outbound state cleaned up; peer notified with success=False.
1093 + assert "req-1" not in session._server._outbound
1094 + done = next(m for m in conn._sent if m.get(TYPE_KEY) == TransferDoneMsg.TYPE)
1095 + assert done[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
1096 + assert done[TransferDoneMsg.SUCCESS] is False
1097 +
1098 + # Next poll drains the queued failure.
1099 + stores_second = session.poll().stores
1100 + assert StoreResult(job_id=200, success=False) in stores_second
1101 +
1102 +
1103 +# ---------------------------------------------------------------------------
1104 +# Bidirectional — the case the unification is meant to fix
1105 +# ---------------------------------------------------------------------------
1106 +
1107 +
1108 +class TestBidirectional:
1109 + def test_session_handles_both_roles_concurrently(self):
1110 + """Single session simultaneously serves a fetch and completes a load.
1111 +
1112 + This is the regression test for the unification: with the old
1113 + split design the inbound FetchMsg would be dispatched to a
1114 + client-only session and dropped (or a server-only session would
1115 + miss the TransferDoneMsg). One unified session handles both.
1116 + """
1117 + session, conn, transport = _make_session()
1118 + _activate(session, conn)
1119 +
1120 + # Server role: the peer fetches a block from us.
1121 + session.add_stored_blocks("req-srv", [b"served"], [0], job_id=100)
1122 + conn.enqueue(
1123 + {
1124 + TYPE_KEY: FetchMsg.TYPE,
1125 + FetchMsg.KV_REQUEST_ID: "req-srv",
1126 + FetchMsg.BLOCK_HASHES: [b"served"],
1127 + FetchMsg.BLOCK_INDEXES: [7],
1128 + }
1129 + )
1130 +
1131 + # Client role: we ask the peer for a different block.
1132 + session.request_blocks(
1133 + job_id=200, kv_request_id="req-cli", keys=[b"loaded"], block_ids=[3]
1134 + )
1135 +
1136 + # Both flows progress in the same poll.
1137 + session.poll()
1138 +
1139 + # Server side: write_blocks was submitted.
1140 + assert len(transport._transfers) == 1
1141 + # Client side: the lookup was sent.
1142 + assert any(
1143 + m[TYPE_KEY] == FetchMsg.TYPE and m[FetchMsg.KV_REQUEST_ID] == "req-cli"
1144 + for m in conn._sent
1145 + )
1146 +
1147 + # Peer now signals: server-side transfer completes AND a
1148 + # TransferDoneMsg arrives for our client-side request, all in
1149 + # one batch on the same connection.
1150 + tid = next(iter(transport._transfers))
1151 + transport._poll_done.append(tid)
1152 + conn.enqueue(
1153 + {
1154 + TYPE_KEY: TransferDoneMsg.TYPE,
1155 + TransferDoneMsg.KV_REQUEST_ID: "req-cli",
1156 + TransferDoneMsg.SUCCESS: True,
1157 + }
1158 + )
1159 + result_ = session.poll()
1160 + loads = result_.loads
1161 + stores = result_.stores
1162 +
1163 + assert LoadResult(job_id=200, kv_request_id="req-cli", success=True) in loads
1164 + assert StoreResult(job_id=100, success=True) in stores
1165 +
1166 +
1167 +# ---------------------------------------------------------------------------
1168 +# Pending sessions (no connection yet)
1169 +# ---------------------------------------------------------------------------
1170 +
1171 +
1172 +class TestPendingSession:
1173 + def test_pending_session_buffers_stored_blocks(self):
1174 + """Pending session accepts add_stored_blocks but cannot send."""
1175 + transport = FakeDataTransport()
1176 + session = P2PSession(
1177 + peer_id="peer:8000",
1178 + local_id="local:9000",
1179 + transport=transport, # type: ignore[arg-type]
1180 + local_block_len=4096,
1181 + conn=None,
1182 + )
1183 + session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
1184 + result_ = session.poll()
1185 + loads = result_.loads
1186 + stores = result_.stores
1187 + assert loads == []
1188 + assert stores == []
1189 + assert not session.connected
1190 + assert session.alive
1191 +
1192 + def test_attach_connection_sends_connect(self):
1193 + """attach_connection triggers our ConnectMsg send."""
1194 + transport = FakeDataTransport()
1195 + session = P2PSession(
1196 + peer_id="peer:8000",
1197 + local_id="local:9000",
1198 + transport=transport, # type: ignore[arg-type]
1199 + local_block_len=4096,
1200 + conn=None,
1201 + )
1202 + conn = FakeConnection(peer_id="peer:8000")
1203 + session.attach_connection(conn) # type: ignore[arg-type]
1204 + assert conn._sent
1205 + assert conn._sent[0][TYPE_KEY] == ConnectMsg.TYPE
1206 +
1207 + def test_attach_connection_twice_raises(self):
1208 + """attach_connection on an already-connected session raises."""
1209 + session, conn, _ = _make_session()
1210 + with pytest.raises(ValueError, match="already connected"):
1211 + session.attach_connection(FakeConnection()) # type: ignore[arg-type]
1212 +
1213 + def test_pending_close_returns_pending_stores(self):
1214 + """Closing a pending session reports buffered stores as failed."""
1215 + transport = FakeDataTransport()
1216 + session = P2PSession(
1217 + peer_id="peer:8000",
1218 + local_id="local:9000",
1219 + transport=transport, # type: ignore[arg-type]
1220 + local_block_len=4096,
1221 + conn=None,
1222 + )
1223 + session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
1224 + session.add_stored_blocks("req-2", [b"k2"], [1], job_id=2)
1225 + failed_loads, failed_stores = session.close()
1226 + assert failed_loads == []
1227 + assert set(failed_stores) == {1, 2}
1228 +
1229 +
1230 +# ---------------------------------------------------------------------------
1231 +# Disconnect
1232 +# ---------------------------------------------------------------------------
1233 +
1234 +
1235 +class TestDisconnect:
1236 + def test_disconnect_marks_session_dead(self):
1237 + session, conn, _ = _make_session()
1238 + _activate(session, conn)
1239 + conn.enqueue({TYPE_KEY: DisconnectMsg.TYPE})
1240 + session.poll()
1241 + assert not session.alive
1242 +
1243 + def test_close_returns_pending_loads_and_stores(self):
1244 + session, conn, _ = _make_session()
1245 + _activate(session, conn)
1246 + session.request_blocks(1, "req-1", [b"k"], [0])
1247 + session.request_blocks(2, "req-2", [b"k"], [0])
1248 + session.add_stored_blocks("req-srv", [b"k"], [0], job_id=10)
1249 + failed_loads, failed_stores = session.close()
1250 + assert set(failed_loads) == {(1, "req-1"), (2, "req-2")}
1251 + assert set(failed_stores) == {10}
1252 +
1253 +
1254 +# ---------------------------------------------------------------------------
1255 +# Adversarial / malformed messages
1256 +# ---------------------------------------------------------------------------
1257 +
1258 +
1259 +class TestAdversarial:
1260 + def test_unknown_message_type_logged(self):
1261 + session, conn, _ = _make_session()
1262 + _activate(session, conn)
1263 + conn.enqueue({TYPE_KEY: "evil_command"})
1264 + result_ = session.poll()
1265 + loads = result_.loads
1266 + stores = result_.stores
1267 + assert loads == []
1268 + assert stores == []
1269 +
1270 + def test_empty_message(self):
1271 + session, conn, _ = _make_session()
1272 + _activate(session, conn)
1273 + conn.enqueue({})
1274 + result_ = session.poll()
1275 + loads = result_.loads
1276 + stores = result_.stores
1277 + assert loads == []
1278 + assert stores == []
1279 +
1280 + def test_non_dict_message(self):
1281 + session, conn, _ = _make_session()
1282 + _activate(session, conn)
1283 + conn._inbox.append(42) # type: ignore[arg-type]
1284 + result_ = session.poll()
1285 + loads = result_.loads
1286 + stores = result_.stores
1287 + assert loads == []
1288 + assert stores == []
1289 +
1290 + def test_fetch_mismatched_lengths(self):
1291 + session, conn, transport = _make_session()
1292 + _activate(session, conn)
1293 + conn.enqueue(
1294 + {
1295 + TYPE_KEY: FetchMsg.TYPE,
1296 + FetchMsg.KV_REQUEST_ID: "req-bad",
1297 + FetchMsg.BLOCK_HASHES: [b"k1", b"k2"],
1298 + FetchMsg.BLOCK_INDEXES: [1],
1299 + }
1300 + )
1301 + session.poll()
1302 + assert len(transport._transfers) == 0
1303 + # Protocol violation: session disconnects immediately so the peer
1304 + # can't keep wedging us with malformed traffic.
1305 + assert not session.alive
1306 + assert any(m[TYPE_KEY] == DisconnectMsg.TYPE for m in conn._sent)
1307 +
1308 + def test_transfer_done_missing_kv_request_id(self):
1309 + session, conn, _ = _make_session()
1310 + _activate(session, conn)
1311 + conn.enqueue({TYPE_KEY: TransferDoneMsg.TYPE, TransferDoneMsg.SUCCESS: True})
1312 + loads = session.poll().loads
1313 + assert loads == []
1314 + assert not session.alive
1315 + assert any(m[TYPE_KEY] == DisconnectMsg.TYPE for m in conn._sent)
1316 +
1317 + def test_duplicate_connect_ack(self):
1318 + session, conn, _ = _make_session()
1319 + _activate(session, conn)
1320 + conn.enqueue({TYPE_KEY: ConnectAckMsg.TYPE, ConnectAckMsg.PEER_ID: "peer:8000"})
1321 + session.poll()
1322 + assert session.ready
1323 +
1324 +
1325 +class TestDispatchErrorHandling:
1326 + """Errors raised by message handlers split into two classes:
1327 +
1328 + - Protocol-contract violations from the peer (ValueError) → disconnect
1329 + on the first occurrence; retrying won't help and may corrupt state.
1330 + - Anything else is treated as an internal bug: log loudly, count, and
1331 + only disconnect once errors arrive in a tight burst. A successful
1332 + dispatch in between resets the counter.
1333 + """
1334 +
1335 + def test_value_error_disconnects_on_first_occurrence(self):
1336 + """A FetchMsg that fails validate() raises ValueError and must
1337 + terminate the session immediately, with a DisconnectMsg sent."""
1338 + session, conn, _ = _make_session()
1339 + _activate(session, conn)
1340 + # length mismatch → FetchMsg.validate raises ValueError
1341 + conn.enqueue(
1342 + {
1343 + TYPE_KEY: FetchMsg.TYPE,
1344 + FetchMsg.KV_REQUEST_ID: "req-bad",
1345 + FetchMsg.BLOCK_HASHES: [b"k1", b"k2"],
1346 + FetchMsg.BLOCK_INDEXES: [1],
1347 + }
1348 + )
1349 + session.poll()
1350 + assert not session.alive
1351 + assert any(m[TYPE_KEY] == DisconnectMsg.TYPE for m in conn._sent)
1352 +
1353 + def test_transfer_done_missing_field_disconnects(self):
1354 + """Same contract for a malformed TransferDoneMsg from the peer."""
1355 + session, conn, _ = _make_session()
1356 + _activate(session, conn)
1357 + conn.enqueue({TYPE_KEY: TransferDoneMsg.TYPE, TransferDoneMsg.SUCCESS: True})
1358 + session.poll()
1359 + assert not session.alive
1360 +
1361 + def test_internal_error_does_not_disconnect_once(self):
1362 + """A non-ValueError raised by a handler is treated as an internal
1363 + bug: counter increments, session stays alive on a single hit."""
1364 + session, conn, _ = _make_session()
1365 + _activate(session, conn)
1366 +
1367 + def _boom(*args, **kwargs):
1368 + raise RuntimeError("simulated internal bug")
1369 +
1370 + session._server.on_fetch = _boom # type: ignore[assignment]
1371 + conn.enqueue(
1372 + {
1373 + TYPE_KEY: FetchMsg.TYPE,
1374 + FetchMsg.KV_REQUEST_ID: "req-1",
1375 + FetchMsg.BLOCK_HASHES: [b"k1"],
1376 + FetchMsg.BLOCK_INDEXES: [0],
1377 + }
1378 + )
1379 + session.poll()
1380 + assert session.alive
1381 + assert session._dispatch_error_count == 1
1382 +
1383 + def test_internal_error_threshold_disconnects(self):
1384 + """Once consecutive non-protocol errors hit the threshold, the
1385 + session tears down via _protocol_error."""
1386 + session, conn, _ = _make_session()
1387 + _activate(session, conn)
1388 +
1389 + def _boom(*args, **kwargs):
1390 + raise RuntimeError("simulated internal bug")
1391 +
1392 + session._server.on_fetch = _boom # type: ignore[assignment]
1393 + for _ in range(_MAX_CONSECUTIVE_DISPATCH_ERRORS):
1394 + conn.enqueue(
1395 + {
1396 + TYPE_KEY: FetchMsg.TYPE,
1397 + FetchMsg.KV_REQUEST_ID: "req-1",
1398 + FetchMsg.BLOCK_HASHES: [b"k1"],
1399 + FetchMsg.BLOCK_INDEXES: [0],
1400 + }
1401 + )
1402 + session.poll()
1403 + assert not session.alive
1404 + assert any(m[TYPE_KEY] == DisconnectMsg.TYPE for m in conn._sent)
1405 +
1406 + def test_internal_error_counter_resets_on_success(self):
1407 + """A successful dispatch between errors prevents the threshold
1408 + from being reached."""
1409 + session, conn, _ = _make_session()
1410 + _activate(session, conn)
1411 +
1412 + original_on_fetch = session._server.on_fetch
1413 +
1414 + def _boom(*args, **kwargs):
1415 + raise RuntimeError("simulated internal bug")
1416 +
1417 + # Alternate (boom, success) (_MAX-1) times: counter rises to 1
1418 + # then resets to 0 each cycle, never reaching the threshold.
1419 + for _ in range(_MAX_CONSECUTIVE_DISPATCH_ERRORS - 1):
1420 + session._server.on_fetch = _boom # type: ignore[assignment]
1421 + conn.enqueue(
1422 + {
1423 + TYPE_KEY: FetchMsg.TYPE,
1424 + FetchMsg.KV_REQUEST_ID: "req-1",
1425 + FetchMsg.BLOCK_HASHES: [b"k1"],
1426 + FetchMsg.BLOCK_INDEXES: [0],
1427 + }
1428 + )
1429 + session.poll()
1430 + session._server.on_fetch = original_on_fetch # type: ignore[assignment]
1431 + # A benign no-op message (unknown type) dispatches cleanly
1432 + # and resets the consecutive-error counter.
1433 + conn.enqueue({TYPE_KEY: "unknown_for_test"})
1434 + session.poll()
1435 +
1436 + assert session.alive
1437 + assert session._dispatch_error_count == 0
1438 +
1439 +
1440 +class TestInflightPerReqInvariant:
1441 + """`_inflight_per_req` is the O(1) replacement for the previous
1442 + O(N) scan in `_has_inflight_for`. These tests check that every
1443 + mutation site keeps the counter in sync with `_inflight` and that
1444 + the lookup is correct under high fan-out.
1445 + """
1446 +
1447 + def test_invariant_holds_through_lifecycle(self):
1448 + """Run a full submit→complete sequence for two concurrent
1449 + kv_request_ids and assert the counter matches `_inflight` at
1450 + every observable step, including the empty-after-finish case."""
1451 + session, conn, transport = _make_session()
1452 + _activate(session, conn)
1453 +
1454 + def _invariant_holds() -> bool:
1455 + counted = sum(session._server._inflight_per_req.values())
1456 + return counted == len(session._server._inflight) and all(
1457 + v > 0 for v in session._server._inflight_per_req.values()
1458 + )
1459 +
1460 + assert _invariant_holds()
1461 +
1462 + # Two requests, two blocks each, all dispatched in one batch.
1463 + session.add_stored_blocks("req-A", [b"a1", b"a2"], [0, 1], job_id=10)
1464 + session.add_stored_blocks("req-B", [b"b1", b"b2"], [2, 3], job_id=11)
1465 + for kv_id, hashes, indexes in (
1466 + ("req-A", [b"a1", b"a2"], [100, 101]),
1467 + ("req-B", [b"b1", b"b2"], [102, 103]),
1468 + ):
1469 + conn.enqueue(
1470 + {
1471 + TYPE_KEY: FetchMsg.TYPE,
1472 + FetchMsg.KV_REQUEST_ID: kv_id,
1473 + FetchMsg.BLOCK_HASHES: hashes,
1474 + FetchMsg.BLOCK_INDEXES: indexes,
1475 + }
1476 + )
1477 + session.poll()
1478 +
1479 + assert _invariant_holds()
1480 + assert session._server._has_inflight_for("req-A")
1481 + assert session._server._has_inflight_for("req-B")
1482 + assert not session._server._has_inflight_for("req-C")
1483 +
1484 + # Complete req-A's transfer first; req-B should still be inflight.
1485 + a_tids = [
1486 + tid
1487 + for tid, x in session._server._inflight.items()
1488 + if x.kv_request_id == "req-A"
1489 + ]
1490 + for tid in a_tids:
1491 + transport._poll_done.append(tid)
1492 + session.poll()
1493 +
1494 + assert _invariant_holds()
1495 + assert not session._server._has_inflight_for("req-A")
1496 + assert "req-A" not in session._server._inflight_per_req # entry was removed
1497 + assert session._server._has_inflight_for("req-B")
1498 +
1499 + # Complete req-B; counter must drain to empty.
1500 + b_tids = [
1501 + tid
1502 + for tid, x in session._server._inflight.items()
1503 + if x.kv_request_id == "req-B"
1504 + ]
1505 + for tid in b_tids:
1506 + transport._poll_done.append(tid)
1507 + session.poll()
1508 +
1509 + assert _invariant_holds()
1510 + assert session._server._inflight == {}
1511 + assert session._server._inflight_per_req == {}
1512 +
1513 + def test_has_inflight_for_correct_with_many_requests(self):
1514 + """Populate many inflight xfers across many ids; lookup must
1515 + match the actual presence in `_inflight` for both hits and
1516 + misses. The whole point of the counter is that this lookup is
1517 + constant-time, but we assert correctness, not timing."""
1518 + session, _, _ = _make_session()
1519 + for kv_id_idx in range(100):
1520 + kv_id = f"req-{kv_id_idx}"
1521 + for j in range(10):
1522 + tid = kv_id_idx * 10 + j
1523 + session._server._inflight_add(
1524 + tid,
1525 + _InflightXfer(kv_request_id=kv_id, block_count=1, job_ids={tid}),
1526 + )
1527 + assert sum(session._server._inflight_per_req.values()) == len(
1528 + session._server._inflight
1529 + )
1530 + assert session._server._has_inflight_for("req-0")
1531 + assert session._server._has_inflight_for("req-99")
1532 + assert not session._server._has_inflight_for("req-missing")
1533 +
1534 + # Drain all entries for req-50 and confirm the entry disappears.
1535 + tids_50 = [
1536 + tid
1537 + for tid, x in session._server._inflight.items()
1538 + if x.kv_request_id == "req-50"
1539 + ]
1540 + for tid in tids_50:
1541 + session._server._inflight_pop(tid)
1542 + assert "req-50" not in session._server._inflight_per_req
1543 + assert not session._server._has_inflight_for("req-50")
1544 + # Other ids unaffected.
1545 + assert session._server._has_inflight_for("req-49")
1546 +
1547 +
1548 +# ---------------------------------------------------------------------------
1549 +# Protocol validation tests (unchanged from the prior file)
1550 +# ---------------------------------------------------------------------------
1551 +
1552 +
1553 +class TestConnectMsgValidation:
1554 + def _valid_msg(self) -> dict:
1555 + return {
1556 + TYPE_KEY: ConnectMsg.TYPE,
1557 + ConnectMsg.PEER_ID: "peer:1",
1558 + ConnectMsg.AGENT_METADATA: b"meta",
1559 + ConnectMsg.BASE_ADDR: 0x1000,
1560 + ConnectMsg.NUM_BLOCKS: 8,
1561 + ConnectMsg.BLOCK_LEN: 4096,
1562 + }
1563 +
1564 + def test_valid_message_passes(self):
1565 + ConnectMsg.validate(self._valid_msg())
1566 +
1567 + def test_missing_peer_id(self):
1568 + msg = self._valid_msg()
1569 + del msg[ConnectMsg.PEER_ID]
1570 + with pytest.raises(ValueError, match="peer_id"):
1571 + ConnectMsg.validate(msg)
1572 +
1573 + def test_missing_agent_metadata(self):
1574 + msg = self._valid_msg()
1575 + del msg[ConnectMsg.AGENT_METADATA]
1576 + with pytest.raises(ValueError, match="agent_metadata"):
1577 + ConnectMsg.validate(msg)
1578 +
1579 + def test_agent_metadata_wrong_type(self):
1580 + msg = self._valid_msg()
1581 + msg[ConnectMsg.AGENT_METADATA] = "not bytes"
1582 + with pytest.raises(ValueError, match="agent_metadata"):
1583 + ConnectMsg.validate(msg)
1584 +
1585 + def test_base_addr_negative(self):
1586 + msg = self._valid_msg()
1587 + msg[ConnectMsg.BASE_ADDR] = -1
1588 + with pytest.raises(ValueError, match="base_addr"):
1589 + ConnectMsg.validate(msg)
1590 +
1591 + def test_num_blocks_zero(self):
1592 + msg = self._valid_msg()
1593 + msg[ConnectMsg.NUM_BLOCKS] = 0
1594 + with pytest.raises(ValueError, match="num_blocks"):
1595 + ConnectMsg.validate(msg)
1596 +
1597 + def test_block_len_zero(self):
1598 + msg = self._valid_msg()
1599 + msg[ConnectMsg.BLOCK_LEN] = 0
1600 + with pytest.raises(ValueError, match="block_len"):
1601 + ConnectMsg.validate(msg)
1602 +
1603 +
1604 +class TestFetchMsgValidation:
1605 + def _valid_msg(self) -> dict:
1606 + return {
1607 + TYPE_KEY: FetchMsg.TYPE,
1608 + FetchMsg.KV_REQUEST_ID: "req-1",
1609 + FetchMsg.BLOCK_HASHES: [b"k1", b"k2"],
1610 + FetchMsg.BLOCK_INDEXES: [0, 1],
1611 + }
1612 +
1613 + def test_valid_message_passes(self):
1614 + FetchMsg.validate(self._valid_msg())
1615 +
1616 + def test_length_mismatch(self):
1617 + msg = self._valid_msg()
1618 + msg[FetchMsg.BLOCK_INDEXES] = [0]
1619 + with pytest.raises(ValueError, match="length mismatch"):
1620 + FetchMsg.validate(msg)
1621 +
1622 + def test_negative_index(self):
1623 + msg = self._valid_msg()
1624 + msg[FetchMsg.BLOCK_INDEXES] = [0, -1]
1625 + with pytest.raises(ValueError, match="invalid index"):
1626 + FetchMsg.validate(msg)
1627 +
1628 +
1629 +class TestTransferDoneMsgValidation:
1630 + def test_valid_message_passes(self):
1631 + msg = {
1632 + TYPE_KEY: TransferDoneMsg.TYPE,
1633 + TransferDoneMsg.KV_REQUEST_ID: "req-1",
1634 + TransferDoneMsg.SUCCESS: True,
1635 + }
1636 + TransferDoneMsg.validate(msg)
1637 +
1638 + def test_success_wrong_type(self):
1639 + msg = {
1640 + TYPE_KEY: TransferDoneMsg.TYPE,
1641 + TransferDoneMsg.KV_REQUEST_ID: "req-1",
1642 + TransferDoneMsg.SUCCESS: 1,
1643 + }
1644 + with pytest.raises(ValueError, match="success"):
1645 + TransferDoneMsg.validate(msg)
tests/v1/kv_offload/tiering/p2p/test_zmq_transport.py new
+230
@@ -0,0 +1,230 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for vllm.v1.kv_offload.tiering.p2p.control.zmq."""
4 +
5 +from __future__ import annotations
6 +
7 +import socket
8 +import time
9 +
10 +import pytest
11 +import zmq
12 +
13 +from vllm.v1.kv_offload.tiering.p2p.control.zmq import (
14 + ZmqConnection,
15 + ZmqTransport,
16 + _Sockets,
17 +)
18 +
19 +
20 +def _free_port() -> int:
21 + """Find a free TCP port."""
22 + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
23 + s.bind(("127.0.0.1", 0))
24 + return s.getsockname()[1]
25 +
26 +
27 +def _make_transport(host: str = "127.0.0.1", attempts: int = 8):
28 + """Construct a ZmqTransport on a fresh port, retrying on bind collisions.
29 +
30 + Why: _free_port() releases the probe socket before ZmqTransport binds the
31 + same port — a parallel test run can steal it in between. Retrying on
32 + ZMQError/OSError closes that race without a production change.
33 + """
34 + last_err: Exception | None = None
35 + for _ in range(attempts):
36 + port = _free_port()
37 + try:
38 + return ZmqTransport(f"{host}:{port}", host, port), port
39 + except (zmq.ZMQError, OSError) as e:
40 + last_err = e
41 + assert last_err is not None
42 + raise last_err
43 +
44 +
45 +def _wait_for_inbound(transport: ZmqTransport, deadline: float = 2.0):
46 + """Poll until at least one new inbound connection is accepted, or fail."""
47 + end = time.monotonic() + deadline
48 + while time.monotonic() < end:
49 + new = transport.poll()
50 + if new:
51 + return new
52 + time.sleep(0.005)
53 + raise AssertionError(f"no inbound connection within {deadline}s")
54 +
55 +
56 +def _wait_for_messages(
57 + transport: ZmqTransport,
58 + conn: ZmqConnection,
59 + n: int,
60 + deadline: float = 2.0,
61 +) -> list[dict]:
62 + """Poll until `conn` has received at least `n` messages, then return them."""
63 + end = time.monotonic() + deadline
64 + msgs: list[dict] = []
65 + while time.monotonic() < end:
66 + transport.poll()
67 + msgs.extend(conn.recv())
68 + if len(msgs) >= n:
69 + return msgs
70 + time.sleep(0.005)
71 + raise AssertionError(f"got {len(msgs)}/{n} messages within {deadline}s")
72 +
73 +
74 +def _make_mock_connection(peer_id: str = "test:1234") -> ZmqConnection:
75 + """Create a ZmqConnection with mock sockets for unit testing."""
76 + from unittest.mock import MagicMock
77 +
78 + sockets = _Sockets(dealer=MagicMock(), monitor=MagicMock())
79 + return ZmqConnection(peer_id, sockets)
80 +
81 +
82 +class TestZmqConnection:
83 + """Tests for ZmqConnection in isolation (no real sockets)."""
84 +
85 + def test_enqueue_and_recv(self):
86 + """Messages enqueued are returned by recv() in order."""
87 + conn = _make_mock_connection()
88 +
89 + conn.enqueue({"type": "a"})
90 + conn.enqueue({"type": "b"})
91 +
92 + msgs = conn.recv()
93 + assert list(msgs) == [{"type": "a"}, {"type": "b"}]
94 + # Second recv is empty
95 + assert not conn.recv()
96 +
97 + def test_recv_returns_empty_initially(self):
98 + conn = _make_mock_connection()
99 + assert not conn.recv()
100 +
101 + def test_alive_initially_true(self):
102 + conn = _make_mock_connection()
103 + assert conn.alive is True
104 +
105 + def test_mark_dead(self):
106 + conn = _make_mock_connection()
107 + conn.mark_dead()
108 + assert conn.alive is False
109 +
110 + def test_send_raises_when_closed(self):
111 + conn = _make_mock_connection()
112 + conn.mark_dead()
113 +
114 + with pytest.raises(RuntimeError, match="closed connection"):
115 + conn.send({"type": "test"})
116 +
117 +
118 +class TestZmqTransportConnectivity:
119 + """Integration tests for ZmqTransport with real ZMQ sockets."""
120 +
121 + def test_connect_and_send_message(self):
122 + """Two transports can connect and exchange messages."""
123 + transport_a, port_a = _make_transport()
124 + transport_b, port_b = _make_transport()
125 +
126 + try:
127 + peer_a_id = f"127.0.0.1:{port_a}"
128 + conn_b_to_a = transport_b.connect(peer_a_id)
129 + conn_b_to_a.send({"type": "hello", "data": 42})
130 +
131 + new_conns = _wait_for_inbound(transport_a)
132 + assert len(new_conns) == 1
133 +
134 + conn_a_from_b = new_conns[0]
135 + assert conn_a_from_b.peer_id == f"127.0.0.1:{port_b}"
136 +
137 + msgs = _wait_for_messages(transport_a, conn_a_from_b, 1)
138 + assert msgs == [{"type": "hello", "data": 42}]
139 + finally:
140 + transport_a.close()
141 + transport_b.close()
142 +
143 + def test_bidirectional_messaging(self):
144 + """Both sides can send and receive after connection."""
145 + transport_a, port_a = _make_transport()
146 + transport_b, _ = _make_transport()
147 +
148 + try:
149 + conn_b = transport_b.connect(f"127.0.0.1:{port_a}")
150 + conn_b.send({"type": "connect", "from": "b"})
151 +
152 + new_conns = _wait_for_inbound(transport_a)
153 + assert len(new_conns) == 1
154 + conn_a = new_conns[0]
155 +
156 + conn_a.send({"type": "reply", "from": "a"})
157 +
158 + msgs = _wait_for_messages(transport_b, conn_b, 1)
159 + assert msgs == [{"type": "reply", "from": "a"}]
160 + finally:
161 + transport_a.close()
162 + transport_b.close()
163 +
164 + def test_poll_returns_empty_when_no_connections(self):
165 + transport, _ = _make_transport()
166 + try:
167 + assert not transport.poll()
168 + finally:
169 + transport.close()
170 +
171 + def test_multiple_messages(self):
172 + """Multiple messages are buffered and returned together."""
173 + transport_a, port_a = _make_transport()
174 + transport_b, _ = _make_transport()
175 +
176 + try:
177 + conn_b = transport_b.connect(f"127.0.0.1:{port_a}")
178 + conn_b.send({"seq": 1})
179 + conn_b.send({"seq": 2})
180 + conn_b.send({"seq": 3})
181 +
182 + new_conns = _wait_for_inbound(transport_a)
183 + assert len(new_conns) == 1
184 + conn_a = new_conns[0]
185 +
186 + msgs = _wait_for_messages(transport_a, conn_a, 3)
187 + assert [m["seq"] for m in msgs] == [1, 2, 3]
188 + finally:
189 + transport_a.close()
190 + transport_b.close()
191 +
192 + def test_duplicate_connect_asserts(self):
193 + """Connecting to the same peer twice raises AssertionError."""
194 + # port_a is never bound — we just need a syntactically-valid peer id.
195 + port_a = _free_port()
196 + transport_b, _ = _make_transport()
197 + try:
198 + transport_b.connect(f"127.0.0.1:{port_a}")
199 + with pytest.raises(AssertionError, match="already exists"):
200 + transport_b.connect(f"127.0.0.1:{port_a}")
201 + finally:
202 + transport_b.close()
203 +
204 + def test_dead_connection_removed_on_poll(self):
205 + """Dead connections are cleaned up during poll."""
206 + transport_a, port_a = _make_transport()
207 + transport_b, _ = _make_transport()
208 +
209 + try:
210 + conn_b = transport_b.connect(f"127.0.0.1:{port_a}")
211 + conn_b.send({"type": "hello"})
212 +
213 + new_conns = _wait_for_inbound(transport_a)
214 + assert len(new_conns) == 1
215 +
216 + # Mark the inbound connection dead manually.
217 + new_conns[0].mark_dead()
218 +
219 + # Pruning is synchronous within poll().
220 + transport_a.poll()
221 + assert len(transport_a._connections) == 0
222 + finally:
223 + transport_a.close()
224 + transport_b.close()
225 +
226 + def test_close_is_idempotent(self):
227 + """Calling close() twice doesn't raise."""
228 + transport, _ = _make_transport()
229 + transport.close()
230 + transport.close() # should not raise
vllm/v1/kv_offload/tiering/factory.py
+6
@@ -66,6 +66,12 @@ SecondaryTierFactory.register_tier(
66 "FileSystemTierManager",
67 )
68
69 +SecondaryTierFactory.register_tier(
70 + "p2p",
71 + "vllm.v1.kv_offload.tiering.p2p.manager",
72 + "P2PSecondaryTierManager",
73 +)
74 +
75 SecondaryTierFactory.register_tier(
76 "obj",
77 "vllm.v1.kv_offload.tiering.obj.manager",
vllm/v1/kv_offload/tiering/p2p/__init__.py
vllm/v1/kv_offload/tiering/p2p/control/__init__.py new
+17
@@ -0,0 +1,17 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from vllm.v1.kv_offload.tiering.p2p.control.base import (
4 + ControlConnection,
5 + ControlTransport,
6 +)
7 +from vllm.v1.kv_offload.tiering.p2p.control.zmq import (
8 + ZmqConnection,
9 + ZmqTransport,
10 +)
11 +
12 +__all__ = [
13 + "ControlConnection",
14 + "ControlTransport",
15 + "ZmqConnection",
16 + "ZmqTransport",
17 +]
vllm/v1/kv_offload/tiering/p2p/control/base.py new
+166
@@ -0,0 +1,166 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Abstract base classes for the P2P control-plane transport.
4 +
5 +The control plane handles peer discovery, connection lifecycle, and
6 +message routing. It is message-content agnostic — it moves opaque
7 +dicts between peers without interpreting them.
8 +
9 +Architecture
10 +------------
11 +
12 + ControlTransport (one per node)
13 + ├── listen for inbound connections
14 + ├── connect() to outbound peers
15 + └── poll() → new ControlConnections
16 +
17 + ControlConnection (one per peer)
18 + ├── send(msg) — enqueue a message to the peer
19 + ├── recv() — drain buffered inbound messages
20 + ├── mark_dead() — signal that the peer is gone
21 + └── close() — tear down the connection
22 +
23 +Threading model: all I/O is driven by the caller invoking poll().
24 +No background threads. poll() must be called periodically to:
25 + - receive messages (buffered per-connection)
26 + - accept new inbound peers
27 + - detect disconnections
28 +
29 +Implementor contracts
30 +---------------------
31 +
32 +- ControlConnection.send() must not block. Messages are serialized
33 + and queued for the next I/O pass.
34 +- ControlConnection.recv() returns all messages received since the
35 + last call (may be empty). Messages are dicts (already deserialized).
36 +- ControlConnection.alive returns False after mark_dead() or close().
37 +- ControlTransport.poll() returns newly accepted connections only —
38 + not previously returned ones. Each connection appears exactly once.
39 +- ControlTransport.connect() creates an outbound connection to a peer
40 + identified by peer_id (format: "host:port"). Raises on failure.
41 +- Messages may arrive from unknown peers (new inbound connections).
42 + The transport creates a ControlConnection and returns it from poll()
43 + with the first message(s) already in its recv() buffer.
44 +"""
45 +
46 +from __future__ import annotations
47 +
48 +from abc import ABC, abstractmethod
49 +from collections.abc import Sequence
50 +
51 +
52 +class ControlConnection(ABC):
53 + """Bidirectional message channel to a single remote peer.
54 +
55 + Lifecycle:
56 + 1. Created by ControlTransport (connect() or poll())
57 + 2. Used for send/recv by sessions
58 + 3. Marked dead on disconnect (mark_dead())
59 + 4. Cleaned up with close()
60 +
61 + Once mark_dead() is called, alive becomes False and the owning
62 + session should stop using this connection. close() releases
63 + underlying resources (sockets, monitors).
64 + """
65 +
66 + def __init__(self, peer_id: str) -> None:
67 + self.peer_id = peer_id
68 +
69 + @property
70 + @abstractmethod
71 + def alive(self) -> bool:
72 + """True if the connection is usable. False after mark_dead/close."""
73 + ...
74 +
75 + @abstractmethod
76 + def send(self, msg: dict) -> None:
77 + """Enqueue a message for delivery to the peer.
78 +
79 + Must not block. Serialization happens internally.
80 + Raises on closed connection.
81 + """
82 + ...
83 +
84 + @abstractmethod
85 + def recv(self) -> Sequence[dict]:
86 + """Drain and return all messages received since the last call.
87 +
88 + Returns an empty sequence if no messages are pending.
89 + The returned sequence is read-only — callers must not mutate it.
90 + Messages are dicts deserialized from the wire format.
91 + """
92 + ...
93 +
94 + @abstractmethod
95 + def mark_dead(self) -> None:
96 + """Mark this connection as dead (peer disconnected).
97 +
98 + After this call, alive returns False. The session should
99 + stop using this connection and the transport will clean it up.
100 + """
101 + ...
102 +
103 + @abstractmethod
104 + def close(self) -> None:
105 + """Release all resources (sockets, monitors).
106 +
107 + Idempotent. After close(), alive returns False.
108 + """
109 + ...
110 +
111 +
112 +class ControlTransport(ABC):
113 + """Manages peer connections and drives all control-plane I/O.
114 +
115 + Owns the listening socket and all active connections.
116 + The caller must invoke poll() periodically to process I/O.
117 +
118 + Lifecycle:
119 + 1. Constructed with a local identity and listen address
120 + 2. connect() to reach remote peers
121 + 3. poll() to accept inbound peers and process messages
122 + 4. close() to shut down
123 + """
124 +
125 + @abstractmethod
126 + def connect(self, peer_id: str) -> ControlConnection:
127 + """Create an outbound connection to a remote peer.
128 +
129 + Args:
130 + peer_id: Remote peer identity (format: "host:port").
131 +
132 + Returns:
133 + A new ControlConnection ready for send/recv.
134 +
135 + The connection's send queue is live immediately — messages
136 + sent before the remote peer's poll() will be buffered.
137 + """
138 + ...
139 +
140 + @abstractmethod
141 + def poll(self) -> Sequence[ControlConnection]:
142 + """Process all pending I/O and return newly accepted connections.
143 +
144 + This is the main I/O driver. Each call:
145 + - Receives messages from all connected peers (buffered in
146 + each connection's recv() queue)
147 + - Accepts new inbound peers and creates connections for them
148 + (first message already in recv() buffer)
149 + - Detects disconnections and marks connections dead
150 +
151 + Returns:
152 + Newly accepted inbound connections (not previously returned).
153 + The returned sequence is read-only — callers must not mutate
154 + it. The caller is responsible for creating sessions for
155 + these connections.
156 + """
157 + ...
158 +
159 + @abstractmethod
160 + def close(self) -> None:
161 + """Shut down the transport and all connections.
162 +
163 + Closes the listening socket and all active connections.
164 + Idempotent.
165 + """
166 + ...
vllm/v1/kv_offload/tiering/p2p/control/zmq.py new
+307
@@ -0,0 +1,307 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""
4 +ZMQ-based transport layer for P2P KV cache sharing.
5 +
6 +Provides ZmqConnection (per-peer messaging) and ZmqTransport (connection
7 +management). Message-content agnostic.
8 +"""
9 +
10 +from __future__ import annotations
11 +
12 +from collections.abc import Sequence
13 +from dataclasses import dataclass
14 +
15 +import msgspec
16 +import zmq
17 +import zmq.utils.monitor
18 +
19 +from vllm.logger import init_logger
20 +from vllm.v1.kv_offload.tiering.p2p.control.base import (
21 + ControlConnection,
22 + ControlTransport,
23 +)
24 +
25 +logger = init_logger(__name__)
26 +
27 +_HEARTBEAT_IVL_MS = 2000
28 +_HEARTBEAT_TIMEOUT_MS = 10000
29 +_HEARTBEAT_TTL_MS = 10000
30 +
31 +# Shared sentinels returned when there is nothing to report.
32 +_EMPTY_INBOX: tuple[dict, ...] = ()
33 +_EMPTY_NEW_CONNECTIONS: tuple[ControlConnection, ...] = ()
34 +
35 +
36 +def _tcp_addr(host: str, port: int | str) -> str:
37 + return f"tcp://{host}:{port}"
38 +
39 +
40 +def _apply_heartbeat(sock: zmq.Socket) -> None:
41 + sock.setsockopt(zmq.HEARTBEAT_IVL, _HEARTBEAT_IVL_MS)
42 + sock.setsockopt(zmq.HEARTBEAT_TIMEOUT, _HEARTBEAT_TIMEOUT_MS)
43 + sock.setsockopt(zmq.HEARTBEAT_TTL, _HEARTBEAT_TTL_MS)
44 +
45 +
46 +@dataclass
47 +class _Sockets:
48 + dealer: zmq.Socket
49 + monitor: zmq.Socket
50 +
51 +
52 +class ZmqConnection(ControlConnection):
53 + """Bidirectional message channel to a single remote peer."""
54 +
55 + def __init__(self, peer_id: str, sockets: _Sockets) -> None:
56 + super().__init__(peer_id)
57 + self._sockets = sockets
58 + self._closed = False
59 + self._inbox: list[dict] = []
60 +
61 + def send(self, msg: dict) -> None:
62 + """Send a msgpack-encoded message to this peer."""
63 + if self._closed:
64 + raise RuntimeError(
65 + f"ZmqConnection: send on closed connection to {self.peer_id}"
66 + )
67 + data = msgspec.msgpack.encode(msg)
68 + self._sockets.dealer.send(data)
69 +
70 + def recv(self) -> Sequence[dict]:
71 + """Drain and return all buffered incoming messages."""
72 + if not self._inbox:
73 + return _EMPTY_INBOX
74 + msgs = self._inbox
75 + self._inbox = []
76 + return msgs
77 +
78 + @property
79 + def alive(self) -> bool:
80 + return not self._closed
81 +
82 + def close(self) -> None:
83 + if self._closed:
84 + return
85 + self._closed = True
86 + logger.info("ZmqConnection: closing connection to %s", self.peer_id)
87 + self._sockets.monitor.close()
88 + self._sockets.dealer.close()
89 +
90 + def enqueue(self, msg: dict) -> None:
91 + """Buffer an incoming message."""
92 + self._inbox.append(msg)
93 +
94 + def mark_dead(self) -> None:
95 + """Mark connection as disconnected."""
96 + self._closed = True
97 +
98 + @property
99 + def monitor_socket(self) -> zmq.Socket:
100 + """Monitor socket for disconnect detection (used by ZmqTransport)."""
101 + return self._sockets.monitor
102 +
103 +
104 +class ZmqTransport(ControlTransport):
105 + """ZMQ implementation of ControlTransport.
106 +
107 + Manages a ROUTER socket for accepting connections and DEALER sockets
108 + for outbound connections. Message-content agnostic.
109 + """
110 +
111 + def __init__(self, local_id: str, host: str, port: int) -> None:
112 + self._local_id = local_id
113 + self._closed = False
114 +
115 + self._connections: dict[str, ZmqConnection] = {}
116 + self._pending_inbound: list[tuple[str, dict]] = []
117 +
118 + self._zmq_ctx = zmq.Context()
119 + self._router: zmq.Socket = self._zmq_ctx.socket(zmq.ROUTER)
120 + _apply_heartbeat(self._router)
121 + bind_addr = _tcp_addr(host, port)
122 + self._router.bind(bind_addr)
123 + logger.info("ZmqTransport %s: ROUTER bound on %s", self._local_id, bind_addr)
124 +
125 + # ------------------------------------------------------------------
126 + # ZmqConnection lifecycle
127 + # ------------------------------------------------------------------
128 +
129 + def connect(self, peer_id: str) -> ZmqConnection:
130 + """Open an outbound connection to a remote peer."""
131 + assert peer_id not in self._connections, (
132 + f"ZmqConnection to {peer_id} already exists"
133 + )
134 + logger.info(
135 + "ZmqTransport %s: opening OUTBOUND connection to %s",
136 + self._local_id,
137 + peer_id,
138 + )
139 + return self._open_connection(peer_id, direction="outbound")
140 +
141 + def poll(self) -> Sequence[ControlConnection]:
142 + """Process all pending I/O. Returns newly accepted connections.
143 +
144 + - Receives messages (buffered in each connection's inbox)
145 + - Creates connections for new inbound peers (connect msg in inbox)
146 + - Checks monitors for disconnections
147 + - Removes and closes dead connections
148 + """
149 + self._recv_router()
150 + self._check_monitors()
151 +
152 + # Create connections for new inbound peers
153 + new_connections: list[ControlConnection] | None = None
154 + for sender_id, msg in self._pending_inbound:
155 + conn = self._connections.get(sender_id)
156 + if conn is None:
157 + logger.info(
158 + "ZmqTransport %s: accepting INBOUND connection from %s",
159 + self._local_id,
160 + sender_id,
161 + )
162 + conn = self._open_connection(sender_id, direction="inbound")
163 + if new_connections is None:
164 + new_connections = []
165 + new_connections.append(conn)
166 + conn.enqueue(msg)
167 + self._pending_inbound.clear()
168 +
169 + # Remove dead connections
170 + for pid in [p for p, c in self._connections.items() if not c.alive]:
171 + self._connections.pop(pid).close()
172 +
173 + return (
174 + new_connections if new_connections is not None else _EMPTY_NEW_CONNECTIONS
175 + )
176 +
177 + def close(self) -> None:
178 + if self._closed:
179 + return
180 + self._closed = True
181 +
182 + for conn in self._connections.values():
183 + conn.close()
184 + self._connections.clear()
185 +
186 + self._router.setsockopt(zmq.LINGER, 0)
187 + self._router.close()
188 + self._zmq_ctx.destroy(linger=0)
189 +
190 + # ------------------------------------------------------------------
191 + # Internal
192 + # ------------------------------------------------------------------
193 +
194 + def _open_connection(
195 + self, peer_id: str, direction: str = "outbound"
196 + ) -> ZmqConnection:
197 + """Create a DEALER socket + monitor and register the connection."""
198 + host, port_str = peer_id.rsplit(":", 1)
199 + dealer_addr = _tcp_addr(host, port_str)
200 +
201 + logger.debug(
202 + "ZmqTransport %s: creating DEALER for %s peer %s -> %s",
203 + self._local_id,
204 + direction,
205 + peer_id,
206 + dealer_addr,
207 + )
208 +
209 + dealer = self._zmq_ctx.socket(zmq.DEALER)
210 + _apply_heartbeat(dealer)
211 + dealer.identity = self._local_id.encode()
212 +
213 + safe_id = peer_id.replace(":", "-").replace("/", "-")
214 + monitor_addr = f"inproc://p2p-monitor-{safe_id}"
215 + dealer.monitor(monitor_addr, zmq.EVENT_DISCONNECTED)
216 +
217 + monitor_sock = self._zmq_ctx.socket(zmq.PAIR)
218 + monitor_sock.connect(monitor_addr)
219 +
220 + dealer.connect(dealer_addr)
221 +
222 + sockets = _Sockets(dealer=dealer, monitor=monitor_sock)
223 + conn = ZmqConnection(peer_id, sockets)
224 + self._connections[peer_id] = conn
225 + logger.info(
226 + "ZmqTransport %s: %s connection established to %s (active connections: %d)",
227 + self._local_id,
228 + direction,
229 + peer_id,
230 + len(self._connections),
231 + )
232 + return conn
233 +
234 + def _recv_router(self) -> None:
235 + """Non-blocking: receive all pending messages from ROUTER."""
236 + while True:
237 + try:
238 + frames = self._router.recv_multipart(zmq.NOBLOCK)
239 + except zmq.Again:
240 + break
241 + except zmq.ZMQError as exc:
242 + logger.warning("ZmqTransport %s: recv error: %s", self._local_id, exc)
243 + break
244 +
245 + if len(frames) != 2:
246 + logger.warning(
247 + "ZmqTransport %s: dropping message with %d frames (expected 2)",
248 + self._local_id,
249 + len(frames),
250 + )
251 + continue
252 +
253 + identity, data = frames
254 + sender_id = identity.decode()
255 +
256 + logger.debug(
257 + "ZmqTransport %s: ROUTER recv from %s (%d bytes)",
258 + self._local_id,
259 + sender_id,
260 + len(data),
261 + )
262 +
263 + try:
264 + msg = msgspec.msgpack.decode(data)
265 + except Exception as exc:
266 + logger.warning(
267 + "ZmqTransport %s: failed to decode message from %s: %s",
268 + self._local_id,
269 + sender_id,
270 + exc,
271 + )
272 + continue
273 +
274 + conn = self._connections.get(sender_id)
275 +
276 + if conn is not None:
277 + conn.enqueue(msg)
278 + else:
279 + self._pending_inbound.append((sender_id, msg))
280 +
281 + def _check_monitors(self) -> None:
282 + """Non-blocking: check all monitor sockets for disconnection."""
283 + for conn in self._connections.values():
284 + if not conn.alive:
285 + continue
286 + try:
287 + event = zmq.utils.monitor.recv_monitor_message(
288 + conn.monitor_socket, zmq.NOBLOCK
289 + )
290 + except zmq.Again:
291 + continue
292 + except zmq.ZMQError as exc:
293 + logger.warning(
294 + "ZmqTransport %s: monitor error for peer %s: %s",
295 + self._local_id,
296 + conn.peer_id,
297 + exc,
298 + )
299 + continue
300 +
301 + if event["event"] == zmq.EVENT_DISCONNECTED:
302 + logger.debug(
303 + "ZmqTransport %s: peer %s disconnected",
304 + self._local_id,
305 + conn.peer_id,
306 + )
307 + conn.mark_dead()
vllm/v1/kv_offload/tiering/p2p/data/__init__.py new
+10
@@ -0,0 +1,10 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from vllm.v1.kv_offload.tiering.p2p.data.base import DataTransport, PollResult
4 +from vllm.v1.kv_offload.tiering.p2p.data.nixl import NixlTransport
5 +
6 +__all__ = [
7 + "DataTransport",
8 + "NixlTransport",
9 + "PollResult",
10 +]
vllm/v1/kv_offload/tiering/p2p/data/base.py new
+259
@@ -0,0 +1,259 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Abstract base class for the P2P data-plane transport.
4 +
5 +The data plane handles RDMA (or similar) block transfers between peers.
6 +It is independent of the control plane — the control plane establishes
7 +who can talk to whom, then the data plane moves blocks at wire speed.
8 +
9 +Architecture
10 +------------
11 +
12 + DataTransport (one per node)
13 + ├── owns the local KV block memory region
14 + ├── registers remote peers (add_remote_peer / remove_remote_peer)
15 + ├── submits block writes (write_blocks → transfer_id)
16 + ├── polls for completion (poll → done/failed IDs)
17 + └── cancels inflight transfers (cancel)
18 +
19 +Memory model
20 +------------
21 +
22 +The local node exposes a contiguous block region:
23 +
24 + base_addr ──► ┌─────────────┐ block 0
25 + ├─────────────┤ block 1
26 + ├─────────────┤ ...
27 + └─────────────┘ block (num_blocks - 1)
28 +
29 + Each block is block_len bytes.
30 +
31 +Remote peers expose the same layout. write_blocks() copies local
32 +blocks to a remote peer's block region by index:
33 +
34 + write_blocks("peer:1", local_idxs=[0, 3], remote_idxs=[5, 7])
35 + → writes local block 0 → remote block 5
36 + local block 3 → remote block 7
37 +
38 +Transfer lifecycle
39 +------------------
40 +
41 +1. Register remote peer: add_remote_peer(peer_id, metadata, ...)
42 + - Provides the remote memory layout so transfers can target it
43 +2. Submit: write_blocks(peer_id, local_idxs, remote_idxs) → int
44 + - Returns a transfer_id (opaque int) for tracking
45 + - Returns None if peer not registered or submission fails
46 +3. Poll: poll() → PollResult(done=[...], failed=[...])
47 + - Returns transfer_ids that completed or failed since last poll
48 + - Completed transfers are automatically cleaned up
49 +4. Cancel: cancel(transfer_ids, mode="immediate" | "wait")
50 + - Best-effort cancellation of inflight transfers
51 + - mode="wait" returns ids still in PROC/PEND so the caller can poll
52 + them to completion
53 +
54 +Implementor contracts
55 +---------------------
56 +
57 +- write_blocks() must not block. The transfer runs asynchronously.
58 +- poll() must be called periodically. It drives completion checking.
59 +- transfer_ids are unique across the lifetime of the transport.
60 +- add_remote_peer() must be called before write_blocks() to that peer.
61 +- get_agent_metadata() returns opaque bytes that the remote peer
62 + needs to call add_remote_peer() (e.g., RDMA connection info).
63 +- config_fingerprint is a content hash of the model configuration.
64 + Peers with different fingerprints are incompatible and must not
65 + exchange blocks (validated during the control-plane handshake).
66 +- close() releases all resources (memory registrations, handles).
67 + After close(), no other methods may be called.
68 +
69 +Threading model: no background threads. All I/O driven by poll().
70 +"""
71 +
72 +from __future__ import annotations
73 +
74 +import ctypes
75 +import hashlib
76 +import json
77 +from abc import ABC, abstractmethod
78 +from collections.abc import Iterable, Sequence
79 +from typing import Literal, NamedTuple
80 +
81 +CancelMode = Literal["immediate", "wait"]
82 +
83 +
84 +class PollResult(NamedTuple):
85 + """Result of polling inflight transfers.
86 +
87 + Attributes:
88 + done: Transfer IDs that completed successfully.
89 + failed: Transfer IDs that failed (error, timeout, etc.).
90 + """
91 +
92 + done: Sequence[int]
93 + failed: Sequence[int]
94 +
95 +
96 +class DataTransport(ABC):
97 + """Abstract data-plane transport for RDMA-style block transfers.
98 +
99 + Owns the local KV block memory region and manages transfers to/from
100 + registered remote peers.
101 +
102 + Construction:
103 + view: A 2D memoryview (num_blocks × block_len bytes) over the
104 + local KV cache block storage.
105 + config_fields: Dict of model config values used to compute the
106 + compatibility fingerprint. None → empty fingerprint
107 + (compatible with any peer).
108 + """
109 +
110 + def __init__(self, view: memoryview, config_fields: dict | None = None) -> None:
111 + assert view.shape is not None
112 + self._view = view
113 + self._base_addr = ctypes.addressof(ctypes.c_char.from_buffer(view))
114 + self._num_blocks = view.shape[0]
115 + self._block_len = view.shape[1]
116 + self._config_fingerprint = self._compute_fingerprint(config_fields)
117 +
118 + @property
119 + def base_addr(self) -> int:
120 + """Base address of the local block memory region."""
121 + return self._base_addr
122 +
123 + @property
124 + def num_blocks(self) -> int:
125 + """Number of blocks in the local region."""
126 + return self._num_blocks
127 +
128 + @property
129 + def block_len(self) -> int:
130 + """Size of each block in bytes."""
131 + return self._block_len
132 +
133 + @property
134 + def config_fingerprint(self) -> str:
135 + """Content-hash of the model configuration (hex string).
136 +
137 + Peers must have matching fingerprints to exchange blocks.
138 + Empty string means no fingerprint (always compatible).
139 + """
140 + return self._config_fingerprint
141 +
142 + @staticmethod
143 + def _compute_fingerprint(config_fields: dict | None) -> str:
144 + if not config_fields:
145 + return ""
146 + canonical = json.dumps(config_fields, sort_keys=True, separators=(",", ":"))
147 + return hashlib.sha256(canonical.encode()).hexdigest()[:16]
148 +
149 + @abstractmethod
150 + def get_agent_metadata(self) -> bytes:
151 + """Return opaque metadata needed by remote peers to connect.
152 +
153 + The returned bytes are sent during the control-plane handshake
154 + and passed to the remote peer's add_remote_peer().
155 + """
156 + ...
157 +
158 + @abstractmethod
159 + def add_remote_peer(
160 + self,
161 + peer_id: str,
162 + agent_metadata: bytes,
163 + base_addr: int,
164 + num_blocks: int,
165 + block_len: int,
166 + ) -> None:
167 + """Register a remote peer for block transfers.
168 +
169 + Must be called before write_blocks() to this peer.
170 +
171 + Args:
172 + peer_id: Unique identifier for the remote peer.
173 + agent_metadata: Opaque bytes from the peer's get_agent_metadata().
174 + base_addr: Base address of the peer's block memory region.
175 + num_blocks: Number of blocks in the peer's region.
176 + block_len: Size of each block (must match local block_len).
177 + """
178 + ...
179 +
180 + @abstractmethod
181 + def remove_remote_peer(self, peer_id: str) -> None:
182 + """Unregister a remote peer and release associated resources.
183 +
184 + Inflight transfers to this peer should be cancelled first.
185 + """
186 + ...
187 +
188 + @abstractmethod
189 + def write_blocks(
190 + self,
191 + peer_id: str,
192 + local_idxs: list[int],
193 + remote_idxs: list[int],
194 + ) -> int | None:
195 + """Submit a WRITE transfer: local blocks → remote peer's blocks.
196 +
197 + Args:
198 + peer_id: Target peer (must be registered via add_remote_peer).
199 + local_idxs: Indexes of local blocks to read from.
200 + remote_idxs: Indexes of remote blocks to write to.
201 + Must be same length as local_idxs.
202 +
203 + Returns:
204 + A unique transfer_id (int) to track this transfer, or
205 + None if the peer is not registered or submission failed.
206 + """
207 + ...
208 +
209 + @abstractmethod
210 + def poll(self) -> PollResult:
211 + """Poll all inflight transfers for completion.
212 +
213 + Returns:
214 + PollResult with lists of completed and failed transfer_ids.
215 + Completed/failed transfers are removed from the inflight set.
216 +
217 + Must be called periodically to drive progress checking.
218 + """
219 + ...
220 +
221 + @abstractmethod
222 + def cancel(
223 + self,
224 + transfer_ids: Iterable[int],
225 + mode: CancelMode = "immediate",
226 + ) -> list[int]:
227 + """Cancel inflight transfers by their IDs.
228 +
229 + Best-effort: transfers that already completed are ignored.
230 +
231 + Args:
232 + transfer_ids: IDs to cancel. Unknown IDs are ignored.
233 + mode:
234 + "immediate" (default): pop and release each handle and
235 + return []. Matches the legacy fire-and-forget
236 + behavior — the caller does not wait for the
237 + underlying transfer to drain.
238 + "wait": attempt to release each handle. If the release
239 + cannot complete because the transfer is still
240 + PROC/PEND, the entry stays in the inflight set and
241 + its id is included in the returned list. The
242 + caller is expected to keep calling poll() until
243 + every returned id surfaces in done/failed.
244 +
245 + Returns:
246 + For mode="wait", the subset of *transfer_ids* still
247 + tracked as inflight after the cancel attempt. For
248 + mode="immediate", always [].
249 + """
250 + ...
251 +
252 + @abstractmethod
253 + def close(self) -> None:
254 + """Release all resources (registrations, handles, memory).
255 +
256 + Cancels any remaining inflight transfers. Idempotent.
257 + After close(), no other methods may be called.
258 + """
259 + ...
vllm/v1/kv_offload/tiering/p2p/data/nixl.py new
+297
@@ -0,0 +1,297 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""
4 +NixlTransport: Data-plane transport for RDMA-based KV block transfers via NIXL.
5 +"""
6 +
7 +from __future__ import annotations
8 +
9 +import itertools
10 +from collections.abc import Iterable
11 +from typing import Any
12 +
13 +from vllm.distributed.nixl_utils import NixlWrapper as _NixlAgent
14 +from vllm.distributed.nixl_utils import nixl_agent_config as _NixlAgentConfig
15 +from vllm.logger import init_logger
16 +from vllm.v1.kv_offload.tiering.p2p.data.base import (
17 + CancelMode,
18 + DataTransport,
19 + PollResult,
20 +)
21 +
22 +logger = init_logger(__name__)
23 +
24 +# Shared sentinel returned by poll() in the steady state (no inflight, or
25 +# no transfer changed state since the last poll). Tuples make it immutable;
26 +# callers only iterate / membership-test / equality-check.
27 +_EMPTY_POLL_RESULT: PollResult = PollResult(done=(), failed=())
28 +
29 +
30 +class NixlTransport(DataTransport):
31 + """Manages a NIXL agent, memory registration, and block transfers.
32 +
33 + Wraps the NIXL C library behind a Python interface so the rest of the
34 + P2P tier code never touches NIXL types directly. Tracks inflight
35 + handles internally and returns completed/failed tags on poll.
36 + """
37 +
38 + def __init__(
39 + self,
40 + local_id: str,
41 + view: memoryview,
42 + config_fields: dict | None = None,
43 + backends: list[str] | None = None,
44 + num_threads: int = 4,
45 + ) -> None:
46 + super().__init__(view, config_fields=config_fields)
47 + self._local_id = local_id
48 + self._backends = list(backends) if backends else ["UCX"]
49 + self._num_threads = num_threads
50 + self._agent: Any = None
51 + self._reg: Any = None
52 + self._local_dlist: Any = None
53 + self._remote_dlists: dict[str, object] = {}
54 + self._peer_nixl_names: dict[str, str] = {}
55 + self._inflight: dict[int, object] = {} # transfer_id → handle
56 + self._next_id = itertools.count()
57 +
58 + self._init(view)
59 +
60 + @property
61 + def available(self) -> bool:
62 + return self._agent is not None
63 +
64 + def _init(self, view: memoryview) -> None:
65 + if _NixlAgent is None:
66 + return
67 +
68 + non_ucx_backends = [b for b in self._backends if b != "UCX"]
69 + if non_ucx_backends:
70 + cfg = _NixlAgentConfig(backends=self._backends, capture_telemetry=True)
71 + logger.info(
72 + "NixlTransport %s: NIXL backends=%s",
73 + self._local_id,
74 + self._backends,
75 + )
76 + else:
77 + cfg = _NixlAgentConfig(
78 + num_threads=self._num_threads, capture_telemetry=True
79 + )
80 + logger.info(
81 + "NixlTransport %s: NIXL backends=[UCX] num_threads=%d",
82 + self._local_id,
83 + self._num_threads,
84 + )
85 + self._agent = _NixlAgent(self._local_id, cfg)
86 +
87 + total_size = self._num_blocks * self._block_len
88 + reg_descs = [(self._base_addr, total_size, 0, "")]
89 + self._reg = self._agent.register_memory(reg_descs, mem_type="DRAM")
90 +
91 + block_tuples = [
92 + (self._base_addr + i * self._block_len, self._block_len, 0)
93 + for i in range(self._num_blocks)
94 + ]
95 + xfer_dlist = self._agent.get_xfer_descs(block_tuples, mem_type="DRAM")
96 + self._local_dlist = self._agent.prep_xfer_dlist("NIXL_INIT_AGENT", xfer_dlist)
97 + logger.info(
98 + "NixlTransport %s: registered %d blocks", self._local_id, self._num_blocks
99 + )
100 +
101 + def get_agent_metadata(self) -> bytes:
102 + assert self._agent is not None
103 + return self._agent.get_agent_metadata()
104 +
105 + # ------------------------------------------------------------------
106 + # Peer management
107 + # ------------------------------------------------------------------
108 +
109 + def add_remote_peer(
110 + self,
111 + peer_id: str,
112 + agent_metadata: bytes,
113 + base_addr: int,
114 + num_blocks: int,
115 + block_len: int,
116 + ) -> None:
117 + nixl_name = self._agent.add_remote_agent(agent_metadata)
118 + block_descs = [
119 + (base_addr + i * block_len, block_len, 0) for i in range(num_blocks)
120 + ]
121 + xfer_dlist = self._agent.get_xfer_descs(block_descs, mem_type="DRAM")
122 + remote_dlist = self._agent.prep_xfer_dlist(nixl_name, xfer_dlist)
123 + self._peer_nixl_names[peer_id] = nixl_name
124 + self._remote_dlists[peer_id] = remote_dlist
125 +
126 + def remove_remote_peer(self, peer_id: str) -> None:
127 + nixl_name = self._peer_nixl_names.pop(peer_id, None)
128 + dlist = self._remote_dlists.pop(peer_id, None)
129 + if self._agent is not None:
130 + if dlist is not None:
131 + self._agent.release_dlist_handle(dlist)
132 + if nixl_name:
133 + self._agent.remove_remote_agent(nixl_name)
134 +
135 + # ------------------------------------------------------------------
136 + # Transfer submission and polling
137 + # ------------------------------------------------------------------
138 +
139 + def write_blocks(
140 + self,
141 + peer_id: str,
142 + local_idxs: list[int],
143 + remote_idxs: list[int],
144 + ) -> int | None:
145 + """Submit a WRITE transfer to *peer_id*.
146 +
147 + Returns a transfer ID, or None if the peer is not registered.
148 + The ID is returned via poll() when the transfer completes or fails.
149 + """
150 + remote_dlist = self._remote_dlists.get(peer_id)
151 + if remote_dlist is None:
152 + logger.warning(
153 + "NixlTransport %s: write_blocks NO REMOTE DLIST for peer=%s "
154 + "(known peers=%s)",
155 + self._local_id,
156 + peer_id,
157 + list(self._remote_dlists.keys()),
158 + )
159 + return None
160 + logger.debug(
161 + "NixlTransport %s: write_blocks NIXL.transfer peer=%s blocks=%d",
162 + self._local_id,
163 + peer_id,
164 + len(local_idxs),
165 + )
166 + handle = self._agent.make_prepped_xfer(
167 + "WRITE",
168 + self._local_dlist,
169 + local_idxs,
170 + remote_dlist,
171 + remote_idxs,
172 + )
173 + self._agent.transfer(handle)
174 + transfer_id = next(self._next_id)
175 + self._inflight[transfer_id] = handle
176 + return transfer_id
177 +
178 + def poll(self) -> PollResult:
179 + """Poll all inflight transfers.
180 +
181 + Returns PollResult(done=..., failed=...) with transfer IDs.
182 + Completed handles are released automatically.
183 + """
184 + if not self._inflight:
185 + return _EMPTY_POLL_RESULT
186 +
187 + done_ids: list[int] | None = None
188 + failed_ids: list[int] | None = None
189 +
190 + for transfer_id, handle in self._inflight.items():
191 + try:
192 + state = self._agent.check_xfer_state(handle)
193 + except Exception as exc:
194 + logger.warning(
195 + "NixlTransport %s: check_xfer_state failed for transfer_id=%d: %s",
196 + self._local_id,
197 + transfer_id,
198 + exc,
199 + )
200 + continue
201 + if state == "DONE":
202 + if done_ids is None:
203 + done_ids = []
204 + done_ids.append(transfer_id)
205 + elif state not in ("PROC", "PEND"):
206 + if failed_ids is None:
207 + failed_ids = []
208 + failed_ids.append(transfer_id)
209 +
210 + if done_ids is None and failed_ids is None:
211 + return _EMPTY_POLL_RESULT
212 +
213 + handles_to_release = []
214 + for tid in done_ids or ():
215 + handles_to_release.append(self._inflight.pop(tid))
216 + for tid in failed_ids or ():
217 + handles_to_release.append(self._inflight.pop(tid))
218 + self._release_handles(handles_to_release)
219 +
220 + return PollResult(
221 + done=done_ids if done_ids is not None else _EMPTY_POLL_RESULT.done,
222 + failed=failed_ids if failed_ids is not None else _EMPTY_POLL_RESULT.failed,
223 + )
224 +
225 + def cancel(
226 + self,
227 + transfer_ids: Iterable[int],
228 + mode: CancelMode = "immediate",
229 + ) -> list[int]:
230 + """Cancel inflight transfers by their IDs.
231 +
232 + See ``DataTransport.cancel`` for the contract. In "wait" mode,
233 + transfers whose ``release_xfer_handle`` raises (NIXL could not
234 + complete the abort because the backend is still draining) stay
235 + in ``self._inflight`` so a later ``poll()`` will observe them.
236 + """
237 + if mode == "immediate":
238 + handles = [
239 + self._inflight.pop(tid) for tid in transfer_ids if tid in self._inflight
240 + ]
241 + self._release_handles(handles)
242 + return []
243 +
244 + still_inflight: list[int] = []
245 + for tid in transfer_ids:
246 + handle = self._inflight.get(tid)
247 + if handle is None:
248 + continue
249 + try:
250 + self._agent.release_xfer_handle(handle)
251 + except Exception as exc:
252 + logger.debug(
253 + "NixlTransport %s: cancel pending for transfer_id=%d: %s",
254 + self._local_id,
255 + tid,
256 + exc,
257 + )
258 + still_inflight.append(tid)
259 + continue
260 + del self._inflight[tid]
261 + return still_inflight
262 +
263 + # ------------------------------------------------------------------
264 + # Lifecycle
265 + # ------------------------------------------------------------------
266 +
267 + def close(self) -> None:
268 + if self._agent is None:
269 + return
270 + self._release_handles(list(self._inflight.values()))
271 + self._inflight.clear()
272 + for peer_id in list(self._remote_dlists):
273 + self.remove_remote_peer(peer_id)
274 + if self._local_dlist is not None:
275 + self._agent.release_dlist_handle(self._local_dlist)
276 + self._local_dlist = None
277 + if self._reg is not None:
278 + self._agent.deregister_memory(self._reg)
279 + self._reg = None
280 + self._agent = None
281 +
282 + # ------------------------------------------------------------------
283 + # Internal
284 + # ------------------------------------------------------------------
285 +
286 + def _release_handles(self, handles: list[object]) -> None:
287 + if self._agent is None:
288 + return
289 + for handle in handles:
290 + try:
291 + self._agent.release_xfer_handle(handle)
292 + except Exception as exc:
293 + logger.warning(
294 + "NixlTransport %s: release_xfer_handle failed: %s",
295 + self._local_id,
296 + exc,
297 + )
vllm/v1/kv_offload/tiering/p2p/manager.py new
+664
@@ -0,0 +1,664 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""
4 +P2PSecondaryTierManager: Secondary tier for P2P KV cache sharing.
5 +
6 +Owns transports and a single bidirectional P2PSession per remote peer.
7 +"""
8 +
9 +from __future__ import annotations
10 +
11 +import time
12 +from collections.abc import Iterable, Sequence
13 +from dataclasses import dataclass, field
14 +from typing import TYPE_CHECKING
15 +
16 +from typing_extensions import override
17 +
18 +from vllm.logger import init_logger
19 +from vllm.v1.kv_offload.base import (
20 + LookupResult,
21 + OffloadKey,
22 + ReqContext,
23 + RequestOffloadingContext,
24 +)
25 +from vllm.v1.kv_offload.file_mapper import FileMapper
26 +from vllm.v1.kv_offload.tiering.base import (
27 + JobMetadata,
28 + JobResult,
29 + SecondaryTierManager,
30 +)
31 +from vllm.v1.kv_offload.tiering.p2p.control import ControlTransport, ZmqTransport
32 +from vllm.v1.kv_offload.tiering.p2p.data import DataTransport, NixlTransport
33 +from vllm.v1.kv_offload.tiering.p2p.session import P2PSession
34 +
35 +if TYPE_CHECKING:
36 + from vllm.v1.kv_offload.base import OffloadingSpec
37 + from vllm.v1.kv_offload.tiering.p2p.control.base import ControlConnection
38 +
39 +logger = init_logger(__name__)
40 +
41 +# Reap unbound store batches that have been parked without a FetchMsg
42 +# binding them to a session for longer than this. Protects against the
43 +# prefiller buffering blocks for a decoder that never asks (decoder died,
44 +# network partition, lost kv_request_id). Must be longer than the per-store
45 +# deadline so the store-timeout path fires first for individual jobs.
46 +_UNBOUND_STORE_TIMEOUT_S = 60.0
47 +
48 +# Time we wait during shutdown for inflight transfers to drain via
49 +# cancel(mode="wait") before falling back to mode="immediate". Bounded
50 +# so a wedged peer can't hang shutdown.
51 +_SHUTDOWN_DRAIN_TIMEOUT_S = 3.0
52 +
53 +# Sleep between iterations of the bounded drain loops in drain_jobs() and
54 +# _drain_inflight_for_shutdown(). Short enough to keep latency low, long
55 +# enough to avoid busy-spinning the scheduler thread.
56 +_DRAIN_SLEEP_S = 0.001
57 +
58 +
59 +def _prefill_params(kv_params: dict | None) -> dict | None:
60 + """Return the ``prefill`` sub-dict, or None if absent.
61 +
62 + Set on decoder requests; carries kv_request_id, remote_host, remote_port.
63 + """
64 + if not kv_params:
65 + return None
66 + return kv_params.get("prefill")
67 +
68 +
69 +def _decode_params(kv_params: dict | None) -> dict | None:
70 + """Return the ``decode`` sub-dict, or None if absent.
71 +
72 + Set on prefiller requests; carries kv_request_id.
73 + """
74 + if not kv_params:
75 + return None
76 + return kv_params.get("decode")
77 +
78 +
79 +@dataclass
80 +class _UnboundStoreBatch:
81 + """A submit_store batch parked at the manager before any peer has fetched.
82 +
83 + Indexed by kv_request_id only — the prefiller no longer learns the peer
84 + identity at store time. When a FetchMsg(kv_request_id) arrives on some
85 + session, the manager binds the kv_request_id to that session and replays
86 + every parked batch into ServerRole via session.add_stored_blocks.
87 + """
88 +
89 + job_id: int
90 + keys: list[OffloadKey]
91 + block_ids: Sequence[int]
92 + submitted_at: float = field(default_factory=time.monotonic)
93 +
94 +
95 +class P2PSecondaryTierManager(SecondaryTierManager):
96 + """Secondary tier for P2P KV cache sharing.
97 +
98 + A single P2PSession per remote peer handles both client-role (loading
99 + blocks from the peer) and server-role (serving blocks to the peer)
100 + over the same control connection.
101 +
102 + Single-threaded: every public method runs on the scheduler thread, and
103 + the engine drives polling via ``get_finished_jobs()`` once per step.
104 + ``has_pending_work()`` keeps the engine ticking so the control transport
105 + and existing sessions are polled even when no requests are scheduled.
106 + """
107 +
108 + def __init__(
109 + self,
110 + offloading_spec: OffloadingSpec,
111 + primary_kv_view: memoryview,
112 + tier_type: str = "p2p",
113 + host: str = "0.0.0.0",
114 + port: int = 7777,
115 + backends: list[str] | None = None,
116 + num_threads: int = 4,
117 + **kwargs,
118 + ) -> None:
119 + """Initialize the P2P secondary tier manager.
120 +
121 + All keyword arguments after ``primary_kv_view`` come from the
122 + ``secondary_tiers`` entry in ``kv_connector_extra_config``. See
123 + ``docs/features/kv_offloading_usage.md`` for the user-facing
124 + configuration reference.
125 +
126 + Args:
127 + offloading_spec: Owning ``OffloadingSpec`` (provides
128 + ``vllm_config`` and the offloaded block layout).
129 + primary_kv_view: Memoryview over the CPU primary tier; the
130 + NIXL agent registers this region for RDMA transfers.
131 + tier_type: Tier identifier (defaults to ``"p2p"``).
132 + host: Address the ZMQ control socket binds to.
133 + port: Port for the ZMQ control socket. Must be reachable
134 + from peers.
135 + backends: NIXL transport backends (e.g. ``["UCX"]``,
136 + ``["MOONCAKE"]``, ``["LIBFABRIC"]``). Defaults to
137 + ``["UCX"]``. When any non-UCX backend is requested, the
138 + NIXL agent is initialized with ``backends=...``;
139 + otherwise it falls back to a UCX-only agent with
140 + ``num_threads`` threads.
141 + num_threads: NIXL agent worker threads for the UCX-only
142 + branch. Ignored when ``backends`` contains a non-UCX
143 + entry.
144 + **kwargs: Reserved for future tier-specific options.
145 + """
146 + super().__init__(offloading_spec, primary_kv_view, tier_type)
147 + port = int(port)
148 + self._local_id = f"{host}:{port}"
149 +
150 + config_fields = FileMapper.from_offloading_spec(
151 + root_dir="",
152 + offloading_spec=offloading_spec,
153 + gpu_blocks_per_file=offloading_spec.block_size_factor,
154 + parallel_agnostic=True,
155 + ).get_run_config()
156 + self._data: DataTransport = NixlTransport(
157 + self._local_id,
158 + primary_kv_view,
159 + config_fields=config_fields,
160 + backends=backends,
161 + num_threads=int(num_threads),
162 + )
163 + self._control: ControlTransport = ZmqTransport(self._local_id, host, port)
164 +
165 + self._sessions: dict[str, P2PSession] = {}
166 + # kv_request_id → session, set when the bound session has received
167 + # FetchMsg for that id. submit_store after binding routes directly
168 + # to the session; before binding, batches are parked in
169 + # _unbound_stores below. Stays in sync with _sessions: entries
170 + # pointing to a reaped session are purged in _reap_dead_sessions.
171 + self._kv_to_session: dict[str, P2PSession] = {}
172 + # kv_request_id → list of batches submit_store'd before any peer
173 + # asked for that id. Drained into a session by _on_session_fetch
174 + # when the corresponding FetchMsg arrives, or surfaced as failures
175 + # by _reap_unbound_stores after _UNBOUND_STORE_TIMEOUT_S.
176 + self._unbound_stores: dict[str, list[_UnboundStoreBatch]] = {}
177 +
178 + self._finished_jobs: list[JobResult] = []
179 + # kv_request_ids that hit a transport/session failure; On load lookup()
180 + # rejects them so the request falls back to local prefill.
181 + self._failed_req_ids: set[str] = set()
182 +
183 + # ------------------------------------------------------------------
184 + # SecondaryTierManager interface
185 + # ------------------------------------------------------------------
186 +
187 + @override
188 + def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult:
189 + prefill = _prefill_params(req_context.kv_transfer_params)
190 + if (
191 + not prefill
192 + or not prefill.get("remote_host")
193 + or not prefill.get("remote_port")
194 + or not prefill.get("kv_request_id")
195 + ):
196 + return LookupResult.MISS
197 +
198 + kv_request_id = prefill["kv_request_id"]
199 + if kv_request_id in self._failed_req_ids:
200 + return LookupResult.MISS
201 + return LookupResult.HIT
202 +
203 + @override
204 + def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext:
205 + """Open the outbound session toward the producer if needed.
206 +
207 + On the decoder side (``prefill`` set), open a session toward the
208 + producer at remote_host:remote_port so submit_load can issue
209 + FetchMsg as soon as it fires. On the prefiller side, sessions
210 + are created when the consumer's inbound connection arrives in
211 + _accept_new_peers — submit_store no longer pre-creates anything.
212 + """
213 + prefill = _prefill_params(req_context.kv_transfer_params)
214 + if prefill:
215 + peer_id = self._remote_id_from_params(prefill)
216 + if peer_id:
217 + self._get_or_create_session(peer_id)
218 + return RequestOffloadingContext()
219 +
220 + @override
221 + def on_request_finished(self, req_context: ReqContext) -> None:
222 + """Cancels pending loads and prunes session-scoped state.
223 +
224 + Decoder side (``prefill`` set): looks up the session by peer_id
225 + because the producer's address is what addresses the client-role
226 + load to cancel. Prefiller side (``decode`` set): looks up via
227 + kv_request_id because peer_id is no longer carried on store-time
228 + kv_transfer_params; if a session has bound the id, finish it. If
229 + no session has bound the id yet, this is a no-op: parked batches
230 + in `_unbound_stores` are left in place and cleaned up only by
231 + `_reap_unbound_stores` after `_UNBOUND_STORE_TIMEOUT_S`.
232 + """
233 + kv_params = req_context.kv_transfer_params
234 + if not kv_params:
235 + return
236 + prefill = _prefill_params(kv_params)
237 + decode = _decode_params(kv_params)
238 + kv_request_id = (prefill or decode or {}).get("kv_request_id")
239 + if not kv_request_id:
240 + return
241 + self._failed_req_ids.discard(kv_request_id)
242 +
243 + if prefill:
244 + peer_id = self._remote_id_from_params(prefill)
245 + if peer_id:
246 + session = self._sessions.get(peer_id)
247 + if session is not None:
248 + session.finish_request(kv_request_id)
249 + return
250 +
251 + # Prefiller-side finish: identify the session via kv_request_id.
252 + session = self._kv_to_session.pop(kv_request_id, None)
253 + if session is not None:
254 + session.finish_request(kv_request_id)
255 + return
256 +
257 + @override
258 + def submit_store(self, job_metadata: JobMetadata) -> None:
259 + job_id = job_metadata.job_id
260 + keys = list(job_metadata.keys)
261 + block_ids = job_metadata.block_ids
262 +
263 + assert len(keys) == len(block_ids)
264 +
265 + kv_params = job_metadata.req_context.kv_transfer_params
266 + decode = _decode_params(kv_params)
267 + logger.debug(
268 + "P2P %s: submit_store ENTRY job_id=%d blocks=%d decode=%s kv_request_id=%s",
269 + self._local_id,
270 + job_id,
271 + len(block_ids),
272 + decode is not None,
273 + (decode or {}).get("kv_request_id"),
274 + )
275 + # Absent ``decode`` block => not a remote-decode request: succeed
276 + # locally without parking. An empty/malformed dict is still a
277 + # remote-decode signal and must fail the missing-id check below.
278 + if decode is None:
279 + self._finished_jobs.append(JobResult(job_id=job_id, success=True))
280 + return
281 +
282 + kv_request_id = decode.get("kv_request_id")
283 + if not kv_request_id:
284 + logger.warning(
285 + "P2P %s: submit_store missing kv_request_id",
286 + self._local_id,
287 + )
288 + self._finished_jobs.append(JobResult(job_id=job_id, success=False))
289 + return
290 +
291 + # Fast path: a session has already received FetchMsg for this id,
292 + # so we can route the batch straight into its ServerRole.
293 + session = self._kv_to_session.get(kv_request_id)
294 + if session is not None:
295 + session.add_stored_blocks(kv_request_id, keys, block_ids, job_id)
296 + return
297 +
298 + # No session bound yet — park the batch keyed by kv_request_id.
299 + # _on_session_fetch drains it on the first FetchMsg; if no peer
300 + # ever asks, _reap_unbound_stores surfaces the job as failed.
301 + self._unbound_stores.setdefault(kv_request_id, []).append(
302 + _UnboundStoreBatch(
303 + job_id=job_id,
304 + keys=keys,
305 + block_ids=block_ids,
306 + )
307 + )
308 + logger.debug(
309 + "P2P %s: parked submit_store kv_request_id=%s job_id=%d blocks=%d",
310 + self._local_id,
311 + kv_request_id,
312 + job_id,
313 + len(block_ids),
314 + )
315 +
316 + @override
317 + def submit_load(self, job_metadata: JobMetadata) -> None:
318 + job_id = job_metadata.job_id
319 + keys = list(job_metadata.keys)
320 + block_ids = job_metadata.block_ids
321 +
322 + prefill = _prefill_params(job_metadata.req_context.kv_transfer_params)
323 + logger.debug(
324 + "P2P %s: submit_load ENTRY job_id=%d blocks=%d kv_request_id=%s peer=%s",
325 + self._local_id,
326 + job_id,
327 + len(block_ids),
328 + (prefill or {}).get("kv_request_id"),
329 + self._remote_id_from_params(prefill or {}),
330 + )
331 + if (
332 + not prefill
333 + or not prefill.get("remote_host")
334 + or not prefill.get("remote_port")
335 + or not prefill.get("kv_request_id")
336 + ):
337 + logger.debug(
338 + "P2P %s: submit_load job_id=%d FAILED missing prefill params",
339 + self._local_id,
340 + job_id,
341 + )
342 + self._finished_jobs.append(JobResult(job_id=job_id, success=False))
343 + return
344 +
345 + kv_request_id = prefill["kv_request_id"]
346 + peer_id = self._remote_id_from_params(prefill)
347 + assert peer_id is not None # guaranteed by prefill checks above
348 +
349 + if not keys:
350 + logger.debug(
351 + "P2P %s: submit_load job_id=%d short-circuit success (no keys)",
352 + self._local_id,
353 + job_id,
354 + )
355 + self._finished_jobs.append(JobResult(job_id=job_id, success=True))
356 + return
357 +
358 + session = self._sessions.get(peer_id)
359 + if session is None:
360 + logger.warning(
361 + "P2P %s: submit_load job_id=%d NO SESSION for peer=%s",
362 + self._local_id,
363 + job_id,
364 + peer_id,
365 + )
366 + self._finished_jobs.append(JobResult(job_id=job_id, success=False))
367 + self._failed_req_ids.add(kv_request_id)
368 + return
369 + logger.debug(
370 + "P2P %s: submit_load job_id=%d -> request_blocks peer=%s "
371 + "kv_request_id=%s blocks=%d session_ready=%s",
372 + self._local_id,
373 + job_id,
374 + peer_id,
375 + kv_request_id,
376 + len(block_ids),
377 + session.ready,
378 + )
379 + session.request_blocks(job_id, kv_request_id, keys, block_ids)
380 +
381 + @override
382 + def get_finished_jobs(self) -> Iterable[JobResult]:
383 + # Drive one polling sweep on the scheduler thread, then hand off
384 + # whatever has accumulated. The engine calls this once per step
385 + # (and keeps stepping while has_pending_work() is True).
386 + self._poll_once()
387 + result = self._finished_jobs
388 + self._finished_jobs = []
389 + return result
390 +
391 + @override
392 + def has_pending_work(self) -> bool:
393 + # The engine tick is the only driver of _control.poll() and
394 + # session.poll(); without it we miss new peer connects and
395 + # inbound fetch messages on existing sessions. Keep the engine
396 + # ticking for the lifetime of this manager.
397 + return True
398 +
399 + @override
400 + def drain_jobs(self) -> None:
401 + """Block until every submitted load/store job has completed or failed.
402 +
403 + Loops calling ``_poll_once()`` until no session has outstanding
404 + inbound loads or in-flight outbound stores. Mid-flight transfers
405 + are NOT cancelled — the caller (``TieringOffloadingManager.reset_cache``)
406 + needs the primary memoryview to be quiescent, not aborted. Results
407 + accumulate in ``_finished_jobs`` and are surfaced by the next
408 + ``get_finished_jobs()`` call.
409 + """
410 + start = time.monotonic()
411 + warned = False
412 + while True:
413 + self._poll_once()
414 + pending = any(
415 + s._client._inbound or s._server._inflight
416 + for s in self._sessions.values()
417 + )
418 + if not pending:
419 + return
420 + if not warned and time.monotonic() - start > 5.0:
421 + logger.warning(
422 + "P2PSecondaryTierManager.drain_jobs: still draining "
423 + "after 5s; a stuck transfer will block the engine.",
424 + )
425 + warned = True
426 + time.sleep(_DRAIN_SLEEP_S)
427 +
428 + @override
429 + def on_schedule_end(self) -> None:
430 + return
431 +
432 + # ------------------------------------------------------------------
433 + # Internal
434 + # ------------------------------------------------------------------
435 +
436 + @staticmethod
437 + def _remote_id_from_params(role_params: dict) -> str | None:
438 + """Build peer_id from a role-scoped sub-dict (``prefill``/``p2p``)."""
439 + host = role_params.get("remote_host")
440 + port = role_params.get("remote_port")
441 + if host and port:
442 + return f"{host}:{port}"
443 + return None
444 +
445 + def _get_or_create_session(self, peer_id: str) -> P2PSession:
446 + """Return the existing session for peer_id, or open one outbound.
447 +
448 + Decoder-side helper for on_new_request: when ``prefill`` is set,
449 + the consumer must reach the producer at peer_id. If we already
450 + have a session toward that peer (from a prior load or a
451 + peer-initiated inbound), reuse it; otherwise open an outbound
452 + ControlConnection and build a connected session.
453 + """
454 + session = self._sessions.get(peer_id)
455 + if session is not None:
456 + return session
457 + conn = self._control.connect(peer_id)
458 + session = P2PSession(
459 + peer_id=peer_id,
460 + local_id=self._local_id,
461 + transport=self._data,
462 + local_block_len=self._data.block_len,
463 + conn=conn,
464 + )
465 + self._sessions[peer_id] = session
466 + return session
467 +
468 + def _accept_new_peers(self, new_connections: Sequence[ControlConnection]) -> None:
469 + for conn in new_connections:
470 + logger.info(
471 + "P2P %s: accepting incoming connection from %s",
472 + self._local_id,
473 + conn.peer_id,
474 + )
475 + try:
476 + existing = self._sessions.get(conn.peer_id)
477 + if existing is not None:
478 + raise ValueError(f"duplicate connection from {conn.peer_id}")
479 + self._sessions[conn.peer_id] = P2PSession(
480 + peer_id=conn.peer_id,
481 + local_id=self._local_id,
482 + transport=self._data,
483 + local_block_len=self._data.block_len,
484 + conn=conn,
485 + )
486 + logger.info(
487 + "P2P %s: created connected session for %s",
488 + self._local_id,
489 + conn.peer_id,
490 + )
491 + except (ValueError, KeyError, TypeError, AssertionError) as exc:
492 + logger.error("P2P %s: rejecting peer: %s", self._local_id, exc)
493 + conn.close()
494 +
495 + def _reap_dead_sessions(self) -> None:
496 + # Reap connected sessions whose connection died — peer is gone.
497 + # Stranded prefiller-side stores are no longer tracked through a
498 + # session (they live in _unbound_stores keyed by kv_request_id);
499 + # _reap_unbound_stores handles their timeout independently.
500 + dead: list[str] | None = None
501 + for pid, s in self._sessions.items():
502 + if s.connected and not s.alive:
503 + if dead is None:
504 + dead = []
505 + dead.append(pid)
506 + if dead is None:
507 + return
508 + for pid in dead:
509 + session = self._sessions.pop(pid)
510 + # Purge any kv_request_id → session entries pointing at this
511 + # session so subsequent submit_stores fall back to the unbound
512 + # path (which will time out into failure if no peer rebinds).
513 + stale_kv_ids = [
514 + kid for kid, s in self._kv_to_session.items() if s is session
515 + ]
516 + for kid in stale_kv_ids:
517 + del self._kv_to_session[kid]
518 + failed_loads, failed_stores = session.close()
519 + for job_id, kv_request_id in failed_loads:
520 + self._finished_jobs.append(JobResult(job_id=job_id, success=False))
521 + self._failed_req_ids.add(kv_request_id)
522 + for job_id in failed_stores:
523 + self._finished_jobs.append(JobResult(job_id=job_id, success=False))
524 + self._data.remove_remote_peer(pid)
525 + logger.warning("P2P %s: peer %s down", self._local_id, pid)
526 +
527 + def _reap_unbound_stores(self) -> None:
528 + """Time out submit_store batches that no peer has ever fetched.
529 +
530 + Walks `_unbound_stores` for entries whose oldest batch is older
531 + than `_UNBOUND_STORE_TIMEOUT_S`. Drops the kv_request_id, surfaces
532 + every batched job as failed, and adds the id to `_failed_req_ids`
533 + so a late inbound FetchMsg short-circuits to a clean rejection.
534 + """
535 + if not self._unbound_stores:
536 + return
537 + deadline = time.monotonic() - _UNBOUND_STORE_TIMEOUT_S
538 + expired: list[str] | None = None
539 + for kid, batches in self._unbound_stores.items():
540 + # Batches are appended in arrival order, so the head is oldest.
541 + if batches and batches[0].submitted_at <= deadline:
542 + if expired is None:
543 + expired = []
544 + expired.append(kid)
545 + if expired is None:
546 + return
547 + for kid in expired:
548 + batches = self._unbound_stores.pop(kid)
549 + self._failed_req_ids.add(kid)
550 + for batch in batches:
551 + self._finished_jobs.append(
552 + JobResult(job_id=batch.job_id, success=False)
553 + )
554 + logger.warning(
555 + "P2P %s: unbound store kv_request_id=%s timed out after %.0fs "
556 + "without a fetch — failing %d job(s)",
557 + self._local_id,
558 + kid,
559 + _UNBOUND_STORE_TIMEOUT_S,
560 + len(batches),
561 + )
562 +
563 + # ------------------------------------------------------------------
564 + # Polling
565 + # ------------------------------------------------------------------
566 +
567 + def _poll_once(self) -> None:
568 + """One sweep of the polling work.
569 +
570 + Drains the control transport, polls every session, accumulates
571 + their results into ``_finished_jobs``, and reaps any dead sessions.
572 + Runs on the scheduler thread.
573 + """
574 + new_connections = self._control.poll()
575 + if new_connections:
576 + logger.info(
577 + "P2P %s: _poll_once got %d new connection(s): %s",
578 + self._local_id,
579 + len(new_connections),
580 + [c.peer_id for c in new_connections],
581 + )
582 +
583 + self._accept_new_peers(new_connections)
584 +
585 + for session in self._sessions.values():
586 + result = session.poll()
587 + for lr in result.loads:
588 + self._finished_jobs.append(
589 + JobResult(job_id=lr.job_id, success=lr.success)
590 + )
591 + if not lr.success:
592 + self._failed_req_ids.add(lr.kv_request_id)
593 + for sr in result.stores:
594 + self._finished_jobs.append(
595 + JobResult(job_id=sr.job_id, success=sr.success)
596 + )
597 + # Bind kv_request_id → session for any FetchMsg this tick and
598 + # replay any submit_store batches parked while no peer was
599 + # asking. ServerRole.on_fetch already recorded the demand
600 + # inline in dispatch, so the replayed add_stored_blocks calls
601 + # match that demand and submit transfers immediately.
602 + for kv_request_id in result.new_fetch_ids:
603 + self._kv_to_session[kv_request_id] = session
604 + for batch in self._unbound_stores.pop(kv_request_id, ()):
605 + session.add_stored_blocks(
606 + kv_request_id, batch.keys, batch.block_ids, batch.job_id
607 + )
608 +
609 + self._reap_dead_sessions()
610 + self._reap_unbound_stores()
611 +
612 + # ------------------------------------------------------------------
613 + # Lifecycle
614 + # ------------------------------------------------------------------
615 +
616 + @override
617 + def shutdown(self) -> None:
618 + self._drain_inflight_for_shutdown()
619 + for session in self._sessions.values():
620 + session.close()
621 + self._sessions.clear()
622 + self._kv_to_session.clear()
623 + # Surface buffered store jobs as failed so the engine doesn't
624 + # leak them; the manager is going away after this call.
625 + for batches in self._unbound_stores.values():
626 + for batch in batches:
627 + self._finished_jobs.append(
628 + JobResult(job_id=batch.job_id, success=False)
629 + )
630 + self._unbound_stores.clear()
631 + self._control.close()
632 + self._data.close()
633 +
634 + def _drain_inflight_for_shutdown(self) -> None:
635 + """Best-effort drain of inflight transfers before closing _data.
636 +
637 + Mirrors session._drain_abort but as a single bounded loop. Collects
638 + inflight transfer_ids from each session, repeatedly calls
639 + _data.cancel(..., mode="wait") and _data.poll() so handles can
640 + surface as done/failed, and falls back to mode="immediate" once
641 + _SHUTDOWN_DRAIN_TIMEOUT_S elapses so a wedged peer can't hang us.
642 + """
643 + ids = [tid for s in self._sessions.values() for tid in s._server._inflight]
644 + if not ids:
645 + return
646 + deadline = time.monotonic() + _SHUTDOWN_DRAIN_TIMEOUT_S
647 + still: list[int] = ids
648 + while still and time.monotonic() < deadline:
649 + still = list(self._data.cancel(still, mode="wait"))
650 + if not still:
651 + break
652 + # poll() advances NIXL handle state so the next wait-cancel
653 + # has a chance to release the handles.
654 + self._data.poll()
655 + time.sleep(_DRAIN_SLEEP_S)
656 + if still:
657 + logger.warning(
658 + "P2P %s: shutdown drain timed out after %.1fs with %d "
659 + "transfers still inflight — force-cancelling",
660 + self._local_id,
661 + _SHUTDOWN_DRAIN_TIMEOUT_S,
662 + len(still),
663 + )
664 + self._data.cancel(still, mode="immediate")
vllm/v1/kv_offload/tiering/p2p/session/__init__.py new
+15
@@ -0,0 +1,15 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from vllm.v1.kv_offload.tiering.p2p.session.session import (
4 + LoadResult,
5 + P2PSession,
6 + SessionPollResult,
7 + StoreResult,
8 +)
9 +
10 +__all__ = [
11 + "LoadResult",
12 + "P2PSession",
13 + "SessionPollResult",
14 + "StoreResult",
15 +]
vllm/v1/kv_offload/tiering/p2p/session/client.py new
+210
@@ -0,0 +1,210 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Client-role state machine for a single peer session.
4 +
5 +Handles outgoing fetch requests, abort-on-timeout, abort-ack timeout,
6 +and produces ``LoadResult`` for completed loads. The session coordinator
7 +parses wire messages and dispatches typed arguments here; this module
8 +never touches ``ControlConnection`` directly — it emits via the ``send``
9 +callback injected by the coordinator (which gates on ConnectAck).
10 +"""
11 +
12 +from __future__ import annotations
13 +
14 +import time
15 +from collections.abc import Callable, Sequence
16 +from dataclasses import dataclass
17 +from typing import TYPE_CHECKING, NamedTuple
18 +
19 +from vllm.logger import init_logger
20 +from vllm.v1.kv_offload.tiering.p2p.session.protocol import (
21 + TYPE_KEY,
22 + AbortFetchMsg,
23 + FetchMsg,
24 +)
25 +
26 +if TYPE_CHECKING:
27 + from vllm.v1.kv_offload.tiering.base import JobId
28 +
29 +logger = init_logger(__name__)
30 +
31 +_LOAD_TIMEOUT_S = 30.0
32 +_ABORT_ACK_TIMEOUT_S = 10.0
33 +
34 +
35 +@dataclass
36 +class _InboundRequestState:
37 + """Client-role state for a single load request."""
38 +
39 + job_id: int # opaque ID assigned by the manager to this load request
40 + kv_request_id: str
41 + submitted_at: float
42 + aborted_at: float | None = None
43 +
44 +
45 +class LoadResult(NamedTuple):
46 + """Result from a session poll, client side."""
47 +
48 + job_id: int
49 + kv_request_id: str
50 + success: bool
51 +
52 +
53 +class ClientRole:
54 + """Client-side load state machine for one peer session.
55 +
56 + The coordinator owns the connection and the send-gating; this role
57 + is given a ``send`` callback and a ``peer_id`` for log messages and
58 + is otherwise self-contained.
59 + """
60 +
61 + def __init__(self, peer_id: str, send: Callable[[dict], None]) -> None:
62 + self._peer_id = peer_id
63 + self._send = send
64 + self._inbound: dict[str, _InboundRequestState] = {}
65 + self._completed_loads: list[LoadResult] = []
66 +
67 + # ------------------------------------------------------------------
68 + # Public API
69 + # ------------------------------------------------------------------
70 +
71 + def request_blocks(
72 + self,
73 + job_id: JobId,
74 + kv_request_id: str,
75 + keys: Sequence[bytes],
76 + block_ids: Sequence[int],
77 + send_ready: bool,
78 + ) -> None:
79 + """Register a load request and send the FetchMsg."""
80 + logger.debug(
81 + "P2PSession %s: request_blocks job_id=%d kv_request_id=%s "
82 + "blocks=%d ready=%s",
83 + self._peer_id,
84 + job_id,
85 + kv_request_id,
86 + len(block_ids),
87 + send_ready,
88 + )
89 + self._inbound[kv_request_id] = _InboundRequestState(
90 + job_id=job_id,
91 + kv_request_id=kv_request_id,
92 + submitted_at=time.monotonic(),
93 + )
94 + self._send(
95 + {
96 + TYPE_KEY: FetchMsg.TYPE,
97 + FetchMsg.KV_REQUEST_ID: kv_request_id,
98 + FetchMsg.BLOCK_HASHES: list(keys),
99 + FetchMsg.BLOCK_INDEXES: [int(idx) for idx in block_ids],
100 + }
101 + )
102 +
103 + def cancel(self, kv_request_id: str) -> None:
104 + """Cancel a pending load. Sends AbortFetchMsg if still active."""
105 + req = self._inbound.pop(kv_request_id, None)
106 + if req is not None and req.aborted_at is None:
107 + self._send(
108 + {
109 + TYPE_KEY: AbortFetchMsg.TYPE,
110 + AbortFetchMsg.KV_REQUEST_ID: kv_request_id,
111 + }
112 + )
113 +
114 + def on_transfer_done(self, kv_request_id: str, success: bool) -> None:
115 + """Handle a TransferDoneMsg from the peer."""
116 + req = self._inbound.pop(kv_request_id, None)
117 + if req is not None:
118 + self._completed_loads.append(
119 + LoadResult(
120 + job_id=req.job_id,
121 + kv_request_id=kv_request_id,
122 + success=success,
123 + )
124 + )
125 + else:
126 + # No matching _inbound entry: either a duplicate
127 + # transfer_done from the peer (protocol violation) or a
128 + # benign race with a local cancel/abort/timeout that
129 + # already popped the entry. We don't track terminated ids,
130 + # so we can't tell — log so it's findable.
131 + logger.warning(
132 + "P2PSession %s: transfer_done for unknown kv_request_id=%s "
133 + "(duplicate from peer, or raced with local cancel/timeout)",
134 + self._peer_id,
135 + kv_request_id,
136 + )
137 +
138 + def on_abort_ack(self, kv_request_id: str) -> None:
139 + """Handle an AbortAckMsg from the peer."""
140 + req = self._inbound.pop(kv_request_id, None)
141 + if req is not None:
142 + self._completed_loads.append(
143 + LoadResult(
144 + job_id=req.job_id,
145 + kv_request_id=kv_request_id,
146 + success=False,
147 + )
148 + )
149 + else:
150 + # See on_transfer_done: same ambiguity (duplicate ack
151 + # vs. raced with local cancel/timeout that already popped).
152 + logger.warning(
153 + "P2PSession %s: abort_ack for unknown kv_request_id=%s "
154 + "(duplicate from peer, or raced with local cancel/timeout)",
155 + self._peer_id,
156 + kv_request_id,
157 + )
158 +
159 + def collect_results(self) -> list[LoadResult]:
160 + """Walk timeouts and drain completed loads.
161 +
162 + Active requests past ``_LOAD_TIMEOUT_S`` get an AbortFetchMsg
163 + sent and enter the aborting phase. Aborting requests past
164 + ``_ABORT_ACK_TIMEOUT_S`` are surfaced as failed loads.
165 + """
166 + now = time.monotonic()
167 + to_remove: list[str] = []
168 + for req_id, req in self._inbound.items():
169 + if req.aborted_at is None:
170 + if now - req.submitted_at >= _LOAD_TIMEOUT_S:
171 + req.aborted_at = now
172 + logger.warning(
173 + "P2PSession %s: %s timed out, sending abort",
174 + self._peer_id,
175 + req_id,
176 + )
177 + self._send(
178 + {
179 + TYPE_KEY: AbortFetchMsg.TYPE,
180 + AbortFetchMsg.KV_REQUEST_ID: req_id,
181 + }
182 + )
183 + else:
184 + if now - req.aborted_at >= _ABORT_ACK_TIMEOUT_S:
185 + to_remove.append(req_id)
186 + self._completed_loads.append(
187 + LoadResult(
188 + job_id=req.job_id,
189 + kv_request_id=req_id,
190 + success=False,
191 + )
192 + )
193 + logger.warning(
194 + "P2PSession %s: abort_ack timed out for kv_request_id=%s",
195 + self._peer_id,
196 + req_id,
197 + )
198 + for req_id in to_remove:
199 + self._inbound.pop(req_id)
200 +
201 + results = self._completed_loads
202 + self._completed_loads = []
203 + return results
204 +
205 + def close(self) -> list[tuple[int, str]]:
206 + """Tear down. Returns ``(job_id, kv_request_id)`` for pending loads."""
207 + failed = [(req.job_id, req.kv_request_id) for req in self._inbound.values()]
208 + self._inbound.clear()
209 + self._completed_loads.clear()
210 + return failed
vllm/v1/kv_offload/tiering/p2p/session/protocol.py new
+247
@@ -0,0 +1,247 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""
4 +P2P KV cache sharing protocol constants and documentation.
5 +
6 +Protocol Overview
7 +=================
8 +
9 +Two session types communicate over a bidirectional message channel:
10 +- P2PClientSession (requests blocks from a server)
11 +- P2PServerSession (serves blocks to a client)
12 +
13 +Connection Lifecycle
14 +--------------------
15 +
16 +1. Client opens a connection and sends ConnectMsg with its identity,
17 + RDMA metadata, memory layout, and config fingerprint.
18 +2. Server validates block_len and config_fingerprint, registers the
19 + RDMA peer, and replies with ConnectAckMsg.
20 +3. Client receives ConnectAckMsg and transitions to ready state
21 + (flushes any queued messages).
22 +4. Either side may send DisconnectMsg to gracefully close.
23 +
24 +Block Transfer Flow (happy path)
25 +---------------------------------
26 +
27 +1. Client sends FetchMsg with a kv_request_id and lists of
28 + block keys + remote indexes where it wants the data written.
29 +2. Server matches requested blocks against locally stored blocks:
30 + - Blocks already available are transferred immediately via RDMA.
31 + - Blocks not yet available are recorded as "demanded" and
32 + transferred when the server later stores them.
33 +3. When all blocks for a kv_request_id are transferred, the server
34 + sends TransferDoneMsg (success=True) to the client.
35 +4. Client reports the load job as complete.
36 +
37 +Abort Flow (timeout path)
38 +--------------------------
39 +
40 +1. If the client times out waiting for TransferDoneMsg, it sends
41 + AbortFetchMsg to cancel the request.
42 +2. Server cancels inflight transfers for that kv_request_id and
43 + replies with AbortAckMsg.
44 +3. Client receives AbortAckMsg and reports the load job as failed.
45 +4. If AbortAckMsg itself times out, the client fails the job anyway.
46 +
47 +Message Format
48 +--------------
49 +
50 +All messages are dicts serialized with msgpack. Every message has a
51 +TYPE_KEY key identifying its type. Additional fields depend on the
52 +message type (see per-message class docstrings below).
53 +
54 +Security
55 +--------
56 +
57 +Sessions wrap all incoming message handling in try/except to guard
58 +against malformed messages from adversarial or buggy peers. Invalid
59 +messages are logged and dropped without crashing the session.
60 +
61 +The config_fingerprint field in ConnectMsg ensures peers have
62 +compatible model configurations (model, dtype, block sizes). Mismatches
63 +are rejected during the handshake.
64 +"""
65 +
66 +TYPE_KEY = "type"
67 +
68 +
69 +# ---------------------------------------------------------------------------
70 +# Validation helpers
71 +# ---------------------------------------------------------------------------
72 +
73 +
74 +def _require(msg: dict, key: str, typ: type, *, name: str = "") -> None:
75 + """Raise ValueError if msg[key] is missing or not isinstance(typ)."""
76 + val = msg.get(key)
77 + if not isinstance(val, typ):
78 + label = name or key
79 + raise ValueError(f"{label}: expected {typ.__name__}, got {type(val).__name__}")
80 +
81 +
82 +def _require_pos_int(msg: dict, key: str, *, name: str = "") -> None:
83 + """Raise ValueError if msg[key] is not a positive int."""
84 + val = msg.get(key)
85 + if not isinstance(val, int) or val <= 0:
86 + label = name or key
87 + raise ValueError(f"{label}: expected positive int, got {val!r}")
88 +
89 +
90 +def _require_non_neg_int(msg: dict, key: str, *, name: str = "") -> None:
91 + """Raise ValueError if msg[key] is not a non-negative int."""
92 + val = msg.get(key)
93 + if not isinstance(val, int) or val < 0:
94 + label = name or key
95 + raise ValueError(f"{label}: expected non-negative int, got {val!r}")
96 +
97 +
98 +def _require_list(msg: dict, key: str, *, name: str = "") -> None:
99 + """Raise ValueError if msg[key] is not a list."""
100 + val = msg.get(key)
101 + if not isinstance(val, list):
102 + label = name or key
103 + raise ValueError(f"{label}: expected list, got {type(val).__name__}")
104 +
105 +
106 +# ---------------------------------------------------------------------------
107 +# Message classes
108 +# ---------------------------------------------------------------------------
109 +
110 +
111 +class ConnectMsg:
112 + """Client → Server: initial handshake request.
113 +
114 + Fields:
115 + PEER_ID: Local peer identity string.
116 + AGENT_METADATA: RDMA agent metadata (opaque bytes).
117 + BASE_ADDR: Base memory address of the KV block region.
118 + NUM_BLOCKS: Number of blocks in the KV block region.
119 + BLOCK_LEN: Size in bytes of each block (must match between peers).
120 + CONFIG_FINGERPRINT: SHA-256 prefix of the model configuration.
121 + Peers with different fingerprints are incompatible.
122 + """
123 +
124 + TYPE = "connect"
125 + PEER_ID = "peer_id"
126 + AGENT_METADATA = "agent_metadata"
127 + BASE_ADDR = "base_addr"
128 + NUM_BLOCKS = "num_blocks"
129 + BLOCK_LEN = "block_len"
130 + CONFIG_FINGERPRINT = "config_fingerprint"
131 +
132 + @staticmethod
133 + def validate(msg: dict) -> None:
134 + """Raise ValueError if any field has an invalid type or value."""
135 + _require(msg, ConnectMsg.PEER_ID, str)
136 + _require(msg, ConnectMsg.AGENT_METADATA, bytes)
137 + _require_non_neg_int(msg, ConnectMsg.BASE_ADDR)
138 + _require_pos_int(msg, ConnectMsg.NUM_BLOCKS)
139 + _require_pos_int(msg, ConnectMsg.BLOCK_LEN)
140 +
141 +
142 +class ConnectAckMsg:
143 + """Server → Client: handshake acknowledgement.
144 +
145 + Fields:
146 + PEER_ID: Server's peer identity string.
147 + """
148 +
149 + TYPE = "connect_ack"
150 + PEER_ID = "peer_id"
151 +
152 + @staticmethod
153 + def validate(msg: dict) -> None:
154 + """Raise ValueError if any field has an invalid type or value."""
155 + _require(msg, ConnectAckMsg.PEER_ID, str)
156 +
157 +
158 +class DisconnectMsg:
159 + """Either → Either: graceful connection close.
160 +
161 + No additional fields beyond TYPE_KEY.
162 + """
163 +
164 + TYPE = "disconnect"
165 +
166 +
167 +class FetchMsg:
168 + """Client → Server: request blocks by key.
169 +
170 + Fields:
171 + KV_REQUEST_ID: Identifies this block transfer request.
172 + BLOCK_HASHES: List of block keys (OffloadKey bytes).
173 + BLOCK_INDEXES: List of remote block indexes (same length as BLOCK_HASHES).
174 + """
175 +
176 + TYPE = "fetch"
177 + KV_REQUEST_ID = "kv_request_id"
178 + BLOCK_HASHES = "block_hashes"
179 + BLOCK_INDEXES = "block_indexes"
180 +
181 + @staticmethod
182 + def validate(msg: dict) -> None:
183 + """Raise ValueError if any field has an invalid type or value."""
184 + _require(msg, FetchMsg.KV_REQUEST_ID, str)
185 + _require_list(msg, FetchMsg.BLOCK_HASHES)
186 + _require_list(msg, FetchMsg.BLOCK_INDEXES)
187 + hashes = msg[FetchMsg.BLOCK_HASHES]
188 + indexes = msg[FetchMsg.BLOCK_INDEXES]
189 + if len(hashes) != len(indexes):
190 + raise ValueError(
191 + f"block_hashes/block_indexes length mismatch: "
192 + f"{len(hashes)} vs {len(indexes)}"
193 + )
194 + for idx in indexes:
195 + if not isinstance(idx, int) or idx < 0:
196 + raise ValueError(f"block_indexes: invalid index {idx!r}")
197 +
198 +
199 +class TransferDoneMsg:
200 + """Server → Client: all blocks transferred for a request.
201 +
202 + Fields:
203 + KV_REQUEST_ID: The request that completed.
204 + SUCCESS: Whether the transfer completed successfully.
205 + """
206 +
207 + TYPE = "transfer_done"
208 + KV_REQUEST_ID = "kv_request_id"
209 + SUCCESS = "success"
210 +
211 + @staticmethod
212 + def validate(msg: dict) -> None:
213 + """Raise ValueError if any field has an invalid type or value."""
214 + _require(msg, TransferDoneMsg.KV_REQUEST_ID, str)
215 + _require(msg, TransferDoneMsg.SUCCESS, bool)
216 +
217 +
218 +class AbortFetchMsg:
219 + """Client → Server: cancel a pending request.
220 +
221 + Fields:
222 + KV_REQUEST_ID: The request to cancel.
223 + """
224 +
225 + TYPE = "abort_fetch"
226 + KV_REQUEST_ID = "kv_request_id"
227 +
228 + @staticmethod
229 + def validate(msg: dict) -> None:
230 + """Raise ValueError if any field has an invalid type or value."""
231 + _require(msg, AbortFetchMsg.KV_REQUEST_ID, str)
232 +
233 +
234 +class AbortAckMsg:
235 + """Server → Client: acknowledge cancellation.
236 +
237 + Fields:
238 + KV_REQUEST_ID: The request that was cancelled.
239 + """
240 +
241 + TYPE = "abort_ack"
242 + KV_REQUEST_ID = "kv_request_id"
243 +
244 + @staticmethod
245 + def validate(msg: dict) -> None:
246 + """Raise ValueError if any field has an invalid type or value."""
247 + _require(msg, AbortAckMsg.KV_REQUEST_ID, str)
vllm/v1/kv_offload/tiering/p2p/session/server.py new
+601
@@ -0,0 +1,601 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Server-role state machine for a single peer session.
4 +
5 +Owns block matching (supply vs. demand), inflight RDMA transfers, store-job
6 +timeouts, abort-drain, and produces ``StoreResult`` for completed stores.
7 +The session coordinator parses wire messages and dispatches typed
8 +arguments here; this module never touches ``ControlConnection`` directly
9 +— it emits via the ``send`` callback injected by the coordinator (which
10 +gates on ConnectAck).
11 +
12 +Protocol violations the role can detect (today: duplicate ``FetchMsg``
13 +for the same ``kv_request_id``) are surfaced as ``ValueError`` so the
14 +coordinator's ``_dispatch_message`` can reuse its existing
15 +``_protocol_error`` path.
16 +"""
17 +
18 +from __future__ import annotations
19 +
20 +import time
21 +from collections.abc import Callable, Sequence
22 +from dataclasses import dataclass, field
23 +from typing import TYPE_CHECKING, NamedTuple
24 +
25 +from vllm.logger import init_logger
26 +from vllm.v1.kv_offload.base import OffloadKey
27 +from vllm.v1.kv_offload.tiering.p2p.session.protocol import (
28 + TYPE_KEY,
29 + AbortAckMsg,
30 + TransferDoneMsg,
31 +)
32 +
33 +if TYPE_CHECKING:
34 + from vllm.v1.kv_offload.tiering.base import JobId
35 + from vllm.v1.kv_offload.tiering.p2p.data import DataTransport
36 +
37 +logger = init_logger(__name__)
38 +
39 +_STORE_TIMEOUT_S = 30.0
40 +_CANCEL_DRAIN_TIMEOUT_S = 10.0
41 +
42 +
43 +class StoreResult(NamedTuple):
44 + """Result from a session poll, server side."""
45 +
46 + job_id: int
47 + success: bool
48 +
49 +
50 +class _InflightXfer(NamedTuple):
51 + """Metadata for a single inflight RDMA transfer, keyed by transfer_id."""
52 +
53 + kv_request_id: str
54 + block_count: int
55 + # The set of store job IDs that contributed blocks to this transfer.
56 + job_ids: set[int]
57 +
58 +
59 +class _MatchResult(NamedTuple):
60 + """Result of block matching: pairs ready for transfer."""
61 +
62 + local_idxs: list[int]
63 + remote_idxs: list[int]
64 + # The set of store job IDs that contributed blocks
65 + job_ids: set[int]
66 +
67 +
68 +@dataclass
69 +class _OutboundRequestState:
70 + """Server-role state for a single peer fetch request.
71 +
72 + The owning ``kv_request_id`` is the dict key in
73 + ``ServerRole._outbound`` and is not duplicated on the value.
74 + """
75 +
76 + demand_received: bool = False
77 + available: dict[OffloadKey, tuple[int, int]] = field(
78 + default_factory=dict
79 + ) # key → (job_id, local_block_idx): blocks we have, awaiting demand
80 + demanded: dict[OffloadKey, int] = field(
81 + default_factory=dict
82 + ) # key → remote_block_idx: blocks peer wants, awaiting supply
83 + remaining: int = 0 # blocks that need to be transferred to client
84 + finishing: bool = False # Signal finish request ASAP
85 + # Job IDs that submit_store'd blocks for this request and have not
86 + # yet emitted a StoreResult. The terminal-finalize helper drains
87 + # this set; poll-done and poll-failed discard entries as their
88 + # StoreResults fire.
89 + pending_job_ids: set[int] = field(default_factory=set)
90 +
91 + def add_stored_blocks(
92 + self,
93 + block_hashes: Sequence[OffloadKey],
94 + block_ids: Sequence[int],
95 + job_id: int,
96 + ) -> _MatchResult:
97 + """Add locally-stored blocks. Returns matched pairs."""
98 + self.pending_job_ids.add(job_id)
99 + local_idxs: list[int] = []
100 + remote_idxs: list[int] = []
101 + for block_hash, local_idx in zip(block_hashes, block_ids):
102 + remote_idx = self.demanded.pop(block_hash, None)
103 + if remote_idx is not None:
104 + local_idxs.append(local_idx)
105 + remote_idxs.append(remote_idx)
106 + else:
107 + self.available[block_hash] = (job_id, local_idx)
108 + return _MatchResult(
109 + local_idxs=local_idxs,
110 + remote_idxs=remote_idxs,
111 + job_ids={job_id} if local_idxs else set(),
112 + )
113 +
114 + def add_fetch_demand(
115 + self,
116 + block_hashes: Sequence[OffloadKey],
117 + block_indexes: Sequence[int],
118 + ) -> _MatchResult:
119 + """Register the peer's fetch demand. Returns matched pairs."""
120 + self.demand_received = True
121 + self.remaining = len(block_hashes)
122 +
123 + local_idxs: list[int] = []
124 + remote_idxs: list[int] = []
125 + job_ids: set[int] = set()
126 + for block_hash, remote_idx in zip(block_hashes, block_indexes):
127 + stored_entry = self.available.pop(block_hash, None)
128 + if stored_entry is not None:
129 + stored_job_id, local_idx = stored_entry
130 + local_idxs.append(local_idx)
131 + remote_idxs.append(remote_idx)
132 + job_ids.add(stored_job_id)
133 + else:
134 + self.demanded[block_hash] = remote_idx
135 + return _MatchResult(
136 + local_idxs=local_idxs,
137 + remote_idxs=remote_idxs,
138 + job_ids=job_ids,
139 + )
140 +
141 +
142 +class ServerRole:
143 + """Server-side store/serve state machine for one peer session.
144 +
145 + The coordinator owns the connection and the send-gating; this role
146 + is given a ``send`` callback, the ``DataTransport``, and the
147 + ``peer_id`` for transport calls and log messages.
148 + """
149 +
150 + def __init__(
151 + self,
152 + peer_id: str,
153 + transport: DataTransport,
154 + send: Callable[[dict], None],
155 + ) -> None:
156 + self._peer_id = peer_id
157 + self._transport = transport
158 + self._send = send
159 +
160 + self._outbound: dict[str, _OutboundRequestState] = {}
161 + # transfer_id → xfer. Mutate ONLY via _inflight_add / _inflight_pop
162 + # so the per-request count below stays in sync.
163 + self._inflight: dict[int, _InflightXfer] = {}
164 + # kv_request_id → number of entries in _inflight for that id.
165 + # Kept in sync with _inflight; entries that hit zero are removed
166 + # so `kv_request_id in self._inflight_per_req` is an exact
167 + # "has any inflight transfer" predicate (O(1) replacement for
168 + # the previous O(N) scan).
169 + self._inflight_per_req: dict[str, int] = {}
170 + self._store_jobs: dict[int, float] = {} # job_id → submitted_at
171 + self._pending_aborts: dict[str, float] = {} # kv_request_id → start
172 + # StoreResults queued by _finalize_outbound for the next poll
173 + # tick to surface. Mirrors the deferred-result pattern used for
174 + # load timeouts.
175 + self._pending_store_results: list[StoreResult] = []
176 +
177 + # ------------------------------------------------------------------
178 + # Public API
179 + # ------------------------------------------------------------------
180 +
181 + def add_stored_blocks(
182 + self,
183 + kv_request_id: str,
184 + keys: Sequence[OffloadKey],
185 + block_ids: Sequence[int],
186 + job_id: JobId,
187 + ) -> None:
188 + """New blocks stored locally — match against pending fetch demand."""
189 + self._store_jobs[job_id] = time.monotonic()
190 + req = self._outbound.setdefault(kv_request_id, _OutboundRequestState())
191 + result = req.add_stored_blocks(keys, block_ids, job_id)
192 + if result.local_idxs and req.demand_received:
193 + self._submit_transfer(kv_request_id, result)
194 +
195 + def on_fetch(
196 + self,
197 + kv_request_id: str,
198 + block_hashes: Sequence[OffloadKey],
199 + block_indexes: Sequence[int],
200 + ) -> None:
201 + """Handle a FetchMsg from the peer.
202 +
203 + Raises ``ValueError`` on a duplicate fetch for the same
204 + ``kv_request_id``; the coordinator's dispatch loop turns that
205 + into a protocol-error disconnect.
206 + """
207 + logger.debug(
208 + "P2PSession %s: fetch RECEIVED kv_request_id=%s blocks=%d",
209 + self._peer_id,
210 + kv_request_id,
211 + len(block_hashes),
212 + )
213 + existing = self._outbound.get(kv_request_id)
214 + if existing is not None and existing.demand_received:
215 + # A second fetch for the same kv_request_id would overwrite
216 + # `remaining` and leak inflight bookkeeping. Treat as a
217 + # protocol violation.
218 + raise ValueError(f"duplicate fetch for kv_request_id={kv_request_id}")
219 + req = self._outbound.setdefault(kv_request_id, _OutboundRequestState())
220 + result = req.add_fetch_demand(block_hashes, block_indexes)
221 + if result.local_idxs:
222 + self._submit_transfer(kv_request_id, result)
223 + # Prefiller-first mode: finish_request may have run before
224 + # fetch arrived. If so, finalize once we know what was
225 + # demanded — fully satisfied → success, else early-fail.
226 + if req.finishing and not self._has_inflight_for(kv_request_id):
227 + self._finalize_outbound(kv_request_id)
228 +
229 + def on_abort_fetch(self, kv_request_id: str) -> None:
230 + """Handle an AbortFetchMsg from the peer."""
231 + # Abort for an unknown id may be a benign race/duplicate or a
232 + # real protocol violation; we don't track completed ids, so warn.
233 + if kv_request_id not in self._outbound and not self._has_inflight_for(
234 + kv_request_id
235 + ):
236 + logger.warning(
237 + "P2PSession %s: abort_fetch for unknown kv_request_id=%s "
238 + "(no outbound or inflight state); benign race or stale",
239 + self._peer_id,
240 + kv_request_id,
241 + )
242 + # Idempotent: receiving AbortFetchMsg again before we've sent the
243 + # ack just triggers another drain attempt without resetting the
244 + # deadline.
245 + self._pending_aborts.setdefault(kv_request_id, time.monotonic())
246 + self._drain_abort(kv_request_id)
247 +
248 + def finish(self, kv_request_id: str) -> None:
249 + """Mark an outbound request finishing.
250 +
251 + No more submit_store calls will arrive for this id. Any blocks
252 + the peer demanded but we never stored will never come; tell the
253 + peer to stop waiting (TransferDoneMsg success=False) instead of
254 + letting it hit _LOAD_TIMEOUT_S.
255 +
256 + If the decoder hasn't sent fetch yet (no demand received),
257 + defer — on_fetch will finalize once demand arrives.
258 +
259 + If inflight transfers exist for this id, defer — the last
260 + completing transfer in collect_results will fire the message.
261 + """
262 + req = self._outbound.get(kv_request_id)
263 + if req is None:
264 + return
265 + req.finishing = True
266 + if not req.demand_received:
267 + return
268 + if self._has_inflight_for(kv_request_id):
269 + return
270 + # Remaining > 0 here: if it had hit 0, the poll-done success
271 + # branch would have already popped _outbound and we'd have
272 + # returned at `req is None` above. Helper derives success from
273 + # remaining and emits StoreResult(success=False) for any
274 + # leftover pending jobs.
275 + self._finalize_outbound(kv_request_id)
276 +
277 + def collect_results(self) -> list[StoreResult]:
278 + """Drain timeouts, deferred results, and transport completions."""
279 + results: list[StoreResult] = self._timeout_pending_store_jobs()
280 +
281 + if self._pending_store_results:
282 + results.extend(self._pending_store_results)
283 + self._pending_store_results.clear()
284 +
285 + poll_result = self._transport.poll()
286 +
287 + for tid in poll_result.done:
288 + xfer = self._inflight_pop(tid)
289 + if xfer is None:
290 + # Bug signal: transport reported a transfer we have no
291 + # bookkeeping for. Likely a double-completion in the
292 + # transport or a stale removal in the session. The
293 + # attached job(s) still live in _store_jobs and will be
294 + # surfaced as failures by _timeout_pending_store_jobs
295 + # after _STORE_TIMEOUT_S, but log loudly so the
296 + # underlying bug is findable.
297 + logger.error(
298 + "P2PSession %s: transport reported done for unknown "
299 + "transfer_id=%d; attached job(s) will fail via "
300 + "store-timeout instead of completing now",
301 + self._peer_id,
302 + tid,
303 + )
304 + continue
305 + req = self._outbound.get(xfer.kv_request_id)
306 + for job_id in xfer.job_ids:
307 + if self._store_jobs.pop(job_id, None) is None:
308 + # Already reported (timeout, cancellation, etc.) —
309 + # don't double-emit a contradictory success result.
310 + continue
311 + results.append(StoreResult(job_id=job_id, success=True))
312 + if req is not None:
313 + req.pending_job_ids.discard(job_id)
314 + if req is not None and req.demand_received:
315 + req.remaining -= xfer.block_count
316 + assert req.remaining >= 0, (
317 + f"remaining went negative for kv_request_id={xfer.kv_request_id}"
318 + )
319 + if req.remaining == 0:
320 + self._finalize_outbound(xfer.kv_request_id, success=True)
321 + elif req.finishing and not self._has_inflight_for(xfer.kv_request_id):
322 + self._finalize_outbound(xfer.kv_request_id, success=False)
323 +
324 + failed_kv_request_ids: set[str] | None = None
325 + for tid in poll_result.failed:
326 + xfer = self._inflight_pop(tid)
327 + if xfer is None:
328 + # See the matching error log in the done branch above.
329 + logger.error(
330 + "P2PSession %s: transport reported failed for unknown "
331 + "transfer_id=%d; attached job(s) will fail via "
332 + "store-timeout instead of completing now",
333 + self._peer_id,
334 + tid,
335 + )
336 + continue
337 + if failed_kv_request_ids is None:
338 + failed_kv_request_ids = set()
339 + failed_kv_request_ids.add(xfer.kv_request_id)
340 + req_for_xfer = self._outbound.get(xfer.kv_request_id)
341 + for job_id in xfer.job_ids:
342 + if self._store_jobs.pop(job_id, None) is None:
343 + # Already reported (timeout, cancellation, etc.) —
344 + # don't double-emit.
345 + continue
346 + results.append(StoreResult(job_id=job_id, success=False))
347 + if req_for_xfer is not None:
348 + req_for_xfer.pending_job_ids.discard(job_id)
349 + req = self._outbound.pop(xfer.kv_request_id, None)
350 + if req is not None and req.demand_received:
351 + self._send(
352 + {
353 + TYPE_KEY: TransferDoneMsg.TYPE,
354 + TransferDoneMsg.KV_REQUEST_ID: xfer.kv_request_id,
355 + TransferDoneMsg.SUCCESS: False,
356 + }
357 + )
358 +
359 + # Cancel other inflight for the same failed kv_request_ids
360 + if failed_kv_request_ids:
361 + ids_to_cancel = [
362 + tid
363 + for tid, xfer in self._inflight.items()
364 + if xfer.kv_request_id in failed_kv_request_ids
365 + ]
366 + for tid in ids_to_cancel:
367 + self._inflight_pop(tid)
368 + self._transport.cancel(ids_to_cancel)
369 +
370 + return results
371 +
372 + def collect_idle_timeouts(self) -> list[StoreResult]:
373 + """Run only the store-job timeout sweep.
374 +
375 + Used by the coordinator's no-conn poll path: a pending session
376 + cannot have inflight transfers (no peer registered yet), so we
377 + skip the transport poll and the deferred-result drain.
378 + """
379 + return self._timeout_pending_store_jobs()
380 +
381 + def drain_pending_aborts(self) -> None:
382 + """Re-attempt every parked abort once per poll tick."""
383 + if not self._pending_aborts:
384 + return
385 + for kv_request_id in list(self._pending_aborts):
386 + self._drain_abort(kv_request_id)
387 +
388 + def close(self) -> list[int]:
389 + """Tear down. Cancels inflight, returns failed store job ids."""
390 + failed_stores = list(self._store_jobs.keys())
391 + self._store_jobs.clear()
392 + if self._inflight:
393 + self._transport.cancel(list(self._inflight.keys()))
394 + self._inflight.clear()
395 + self._inflight_per_req.clear()
396 + self._outbound.clear()
397 + self._pending_aborts.clear()
398 + self._pending_store_results.clear()
399 + return failed_stores
400 +
401 + # ------------------------------------------------------------------
402 + # Internal — inflight bookkeeping
403 + # ------------------------------------------------------------------
404 +
405 + def _has_inflight_for(self, kv_request_id: str) -> bool:
406 + return kv_request_id in self._inflight_per_req
407 +
408 + def _inflight_add(self, tid: int, xfer: _InflightXfer) -> None:
409 + """Insert an inflight transfer and bump the per-request count."""
410 + self._inflight[tid] = xfer
411 + self._inflight_per_req[xfer.kv_request_id] = (
412 + self._inflight_per_req.get(xfer.kv_request_id, 0) + 1
413 + )
414 +
415 + def _inflight_pop(self, tid: int) -> _InflightXfer | None:
416 + """Pop an inflight transfer and decrement the per-request count.
417 +
418 + Removes the per-request entry once the count hits zero so the
419 + dict stays bounded and `_has_inflight_for` remains exact.
420 + """
421 + xfer = self._inflight.pop(tid, None)
422 + if xfer is None:
423 + return None
424 + new_count = self._inflight_per_req.get(xfer.kv_request_id, 0) - 1
425 + if new_count > 0:
426 + self._inflight_per_req[xfer.kv_request_id] = new_count
427 + else:
428 + self._inflight_per_req.pop(xfer.kv_request_id, None)
429 + return xfer
430 +
431 + # ------------------------------------------------------------------
432 + # Internal — finalize / abort drain
433 + # ------------------------------------------------------------------
434 +
435 + def _finalize_outbound(
436 + self,
437 + kv_request_id: str,
438 + success: bool | None = None,
439 + ) -> None:
440 + """Pop the outbound state and emit terminal results.
441 +
442 + Called when no further work will happen for this kv_request_id
443 + on the server side: either request_finish has fired and there
444 + are no inflight transfers, or the last inflight just completed
445 + while finishing.
446 +
447 + If ``success`` is None, derive it from ``req.remaining == 0``.
448 + The same flag is used for both the peer's TransferDoneMsg and
449 + the StoreResult(s) emitted for any leftover pending job_ids.
450 + """
451 + req = self._outbound.pop(kv_request_id, None)
452 + if req is None:
453 + return
454 + if success is None:
455 + success = req.remaining == 0
456 + for job_id in req.pending_job_ids:
457 + self._store_jobs.pop(job_id, None)
458 + self._pending_store_results.append(
459 + StoreResult(job_id=job_id, success=success)
460 + )
461 + self._send(
462 + {
463 + TYPE_KEY: TransferDoneMsg.TYPE,
464 + TransferDoneMsg.KV_REQUEST_ID: kv_request_id,
465 + TransferDoneMsg.SUCCESS: success,
466 + }
467 + )
468 +
469 + def _drain_abort(self, kv_request_id: str) -> None:
470 + """One drain attempt for a pending abort.
471 +
472 + Stops accepting more blocks for ``kv_request_id``, then asks the
473 + transport to cancel any matching inflight transfers in
474 + ``mode="wait"``. Sends ``AbortAckMsg`` once nothing remains
475 + inflight, or after ``_CANCEL_DRAIN_TIMEOUT_S`` falls back to
476 + ``mode="immediate"`` and acks anyway.
477 + """
478 + self._outbound.pop(kv_request_id, None)
479 + ids = [
480 + tid
481 + for tid, xfer in self._inflight.items()
482 + if xfer.kv_request_id == kv_request_id
483 + ]
484 + if not ids:
485 + self._finalize_abort(kv_request_id)
486 + return
487 +
488 + started_at = self._pending_aborts.get(kv_request_id)
489 + expired = (
490 + started_at is not None
491 + and time.monotonic() - started_at >= _CANCEL_DRAIN_TIMEOUT_S
492 + )
493 + if expired:
494 + for tid in ids:
495 + self._inflight_pop(tid)
496 + self._transport.cancel(ids, mode="immediate")
497 + logger.warning(
498 + "P2PSession %s: cancel drain timed out for kv_request_id=%s,"
499 + " force-canceled %d transfers",
500 + self._peer_id,
501 + kv_request_id,
502 + len(ids),
503 + )
504 + self._finalize_abort(kv_request_id)
505 + return
506 +
507 + still = self._transport.cancel(ids, mode="wait")
508 + # Tids the transport successfully released are gone from its
509 + # _inflight; mirror that in session bookkeeping so they don't
510 + # block the drain forever waiting for a poll() event that will
511 + # never come.
512 + still_set = set(still)
513 + for tid in ids:
514 + if tid not in still_set:
515 + self._inflight_pop(tid)
516 + if not still:
517 + self._finalize_abort(kv_request_id)
518 +
519 + def _finalize_abort(self, kv_request_id: str) -> None:
520 + self._pending_aborts.pop(kv_request_id, None)
521 + self._send(
522 + {
523 + TYPE_KEY: AbortAckMsg.TYPE,
524 + AbortAckMsg.KV_REQUEST_ID: kv_request_id,
525 + }
526 + )
527 +
528 + # ------------------------------------------------------------------
529 + # Internal — transfers and store-job timeouts
530 + # ------------------------------------------------------------------
531 +
532 + def _submit_transfer(self, kv_request_id: str, result: _MatchResult) -> None:
533 + logger.debug(
534 + "P2PSession %s: NIXL write_blocks CALL kv_request_id=%s "
535 + "local_idxs=%d remote_idxs=%d",
536 + self._peer_id,
537 + kv_request_id,
538 + len(result.local_idxs),
539 + len(result.remote_idxs),
540 + )
541 + transfer_id = self._transport.write_blocks(
542 + self._peer_id, result.local_idxs, result.remote_idxs
543 + )
544 + if transfer_id is not None:
545 + logger.debug(
546 + "P2PSession %s: NIXL write_blocks SUBMITTED kv_request_id=%s "
547 + "transfer_id=%d blocks=%d",
548 + self._peer_id,
549 + kv_request_id,
550 + transfer_id,
551 + len(result.local_idxs),
552 + )
553 + self._inflight_add(
554 + transfer_id,
555 + _InflightXfer(
556 + kv_request_id=kv_request_id,
557 + block_count=len(result.local_idxs),
558 + job_ids=result.job_ids,
559 + ),
560 + )
561 + else:
562 + logger.warning(
563 + "P2PSession %s: write_blocks failed for %s (%d blocks)",
564 + self._peer_id,
565 + kv_request_id,
566 + len(result.local_idxs),
567 + )
568 + # The matched blocks were popped from req.demanded /
569 + # req.available, but no inflight will satisfy them, so
570 + # remaining will never reach 0 on its own. Mark the
571 + # request as finishing so the existing terminal paths
572 + # clean up: if other inflight is in flight, the last one
573 + # to drain will fire _finalize_outbound(success=False)
574 + # via the elif branch in collect_results. If
575 + # nothing else is in flight, finalize now so the peer
576 + # and the local store jobs don't wait for finish_request
577 + # or for _STORE_TIMEOUT_S / _LOAD_TIMEOUT_S.
578 + req = self._outbound.get(kv_request_id)
579 + if req is not None:
580 + req.finishing = True
581 + if not self._has_inflight_for(kv_request_id):
582 + self._finalize_outbound(kv_request_id, success=False)
583 +
584 + def _timeout_pending_store_jobs(self) -> list[StoreResult]:
585 + if not self._store_jobs:
586 + return []
587 + deadline = time.monotonic() - _STORE_TIMEOUT_S
588 + timed_out: list[int] | None = None
589 + for jid, submitted_at in self._store_jobs.items():
590 + if submitted_at <= deadline:
591 + if timed_out is None:
592 + timed_out = []
593 + timed_out.append(jid)
594 + if timed_out is None:
595 + return []
596 + results: list[StoreResult] = []
597 + for jid in timed_out:
598 + del self._store_jobs[jid]
599 + results.append(StoreResult(job_id=jid, success=False))
600 + logger.warning("P2PSession %s: store job %d timed out", self._peer_id, jid)
601 + return results
vllm/v1/kv_offload/tiering/p2p/session/session.py new
+440
@@ -0,0 +1,440 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""
4 +P2PSession — bidirectional session combining client + server roles.
5 +
6 +A single P2PSession per remote peer handles BOTH directions of the P2P
7 +protocol on one ControlConnection: it can request blocks from the peer
8 +("client" role, in :mod:`.client`) AND serve blocks to the peer
9 +("server" role, in :mod:`.server`). This module is the thin coordinator
10 +that owns the connection, the handshake, send-gating, and the message
11 +dispatch — each parsed message is forwarded to the corresponding role.
12 +
13 +Wire protocol is unchanged. Both sides advertise their NIXL metadata
14 +via ConnectMsg when their session is connected; the peer's ConnectMsg
15 +triggers transport.add_remote_peer; ConnectAckMsg confirms the peer
16 +received our ConnectMsg, after which queued outgoing messages are flushed.
17 +"""
18 +
19 +from __future__ import annotations
20 +
21 +import contextlib
22 +from collections.abc import Sequence
23 +from typing import TYPE_CHECKING, NamedTuple
24 +
25 +from vllm.logger import init_logger
26 +from vllm.v1.kv_offload.base import OffloadKey
27 +from vllm.v1.kv_offload.tiering.p2p.control.base import ControlConnection
28 +from vllm.v1.kv_offload.tiering.p2p.session.client import ClientRole, LoadResult
29 +from vllm.v1.kv_offload.tiering.p2p.session.protocol import (
30 + TYPE_KEY,
31 + AbortAckMsg,
32 + AbortFetchMsg,
33 + ConnectAckMsg,
34 + ConnectMsg,
35 + DisconnectMsg,
36 + FetchMsg,
37 + TransferDoneMsg,
38 +)
39 +from vllm.v1.kv_offload.tiering.p2p.session.server import (
40 + ServerRole,
41 + StoreResult,
42 +)
43 +
44 +if TYPE_CHECKING:
45 + from vllm.v1.kv_offload.tiering.base import JobId
46 + from vllm.v1.kv_offload.tiering.p2p.data import DataTransport
47 +
48 +logger = init_logger(__name__)
49 +
50 +# Cap on consecutive non-protocol dispatch exceptions before we tear
51 +# down the session. Protocol violations (ValueError) disconnect on the
52 +# first occurrence; this threshold protects against repeated internal
53 +# bugs that may indicate a peer-induced bad state. Reset on any
54 +# successful dispatch.
55 +_MAX_CONSECUTIVE_DISPATCH_ERRORS = 5
56 +
57 +
58 +class SessionPollResult(NamedTuple):
59 + """Result of one P2PSession.poll() tick.
60 +
61 + `loads`/`stores` are the same per-role results the manager has always
62 + consumed. `new_fetch_ids` reports kv_request_ids whose FetchMsg
63 + arrived this tick — the manager uses them to bind kv_request_id →
64 + session and replay any submit_store batches parked while no peer had
65 + asked yet. Reporting (rather than calling back into the manager
66 + mid-dispatch) keeps the dependency strictly top-down.
67 + """
68 +
69 + loads: list[LoadResult]
70 + stores: list[StoreResult]
71 + new_fetch_ids: list[str]
72 +
73 +
74 +class P2PSession:
75 + """Bidirectional session — coordinator over ClientRole + ServerRole.
76 +
77 + Lifecycle:
78 + - Constructor with conn=None ⇒ pending. Accepts add_stored_blocks
79 + but cannot send (used by the prefiller to buffer blocks before
80 + the decoder connects).
81 + - Constructor with conn != None ⇒ connected. Sends our own ConnectMsg
82 + immediately; the peer's ConnectMsg arrives in poll() and is
83 + dispatched to _on_connect (which calls transport.add_remote_peer
84 + and replies with ConnectAckMsg). Outgoing sends are queued until
85 + ConnectAckMsg confirms our metadata reached the peer.
86 + - attach_connection(conn) on a pending session ⇒ same as above,
87 + starting from pending.
88 + """
89 +
90 + def __init__(
91 + self,
92 + peer_id: str,
93 + local_id: str,
94 + transport: DataTransport,
95 + local_block_len: int,
96 + conn: ControlConnection | None = None,
97 + ) -> None:
98 + self.peer_id = peer_id
99 + self._local_id = local_id
100 + self._transport = transport
101 + self._local_block_len = local_block_len
102 + self._conn: ControlConnection | None = None
103 +
104 + self._send_ready = False # True after the peer acked our ConnectMsg
105 + # Msgs waiting to be sent on connection establishment
106 + self._queued: list[dict] = []
107 +
108 + # Consecutive non-protocol dispatch errors. Reset on success.
109 + self._dispatch_error_count: int = 0
110 +
111 + # kv_request_ids whose FetchMsg arrived during the current poll
112 + # tick. Drained and returned in the next poll() result so the
113 + # manager can bind kv_request_id → session and replay any
114 + # submit_store batches parked before the binding existed.
115 + self._new_fetch_ids: list[str] = []
116 +
117 + self._client = ClientRole(peer_id=peer_id, send=self._send)
118 + self._server = ServerRole(peer_id=peer_id, transport=transport, send=self._send)
119 +
120 + if conn is not None:
121 + self.attach_connection(conn)
122 +
123 + # ------------------------------------------------------------------
124 + # Properties
125 + # ------------------------------------------------------------------
126 +
127 + @property
128 + def alive(self) -> bool:
129 + # Pending sessions (awaiting connection) are alive — only a
130 + # closed real connection counts as dead.
131 + return self._conn is None or self._conn.alive
132 +
133 + @property
134 + def connected(self) -> bool:
135 + return self._conn is not None
136 +
137 + @property
138 + def ready(self) -> bool:
139 + """True after the peer acked our ConnectMsg (we may send freely)."""
140 + return self._send_ready
141 +
142 + # ------------------------------------------------------------------
143 + # Connection lifecycle
144 + # ------------------------------------------------------------------
145 +
146 + def attach_connection(self, conn: ControlConnection) -> None:
147 + """Attach a connection to a pending session and announce ourselves.
148 +
149 + Symmetric: every side advertises its NIXL metadata on connect, so
150 + whichever peer receives a session first can register the other.
151 + """
152 + if self._conn is not None:
153 + raise ValueError(f"P2PSession {self.peer_id}: already connected")
154 + self._conn = conn
155 + self._send_connect()
156 +
157 + # ------------------------------------------------------------------
158 + # Public API
159 + # ------------------------------------------------------------------
160 +
161 + def request_blocks(
162 + self,
163 + job_id: JobId,
164 + kv_request_id: str,
165 + keys: Sequence[bytes],
166 + block_ids: Sequence[int],
167 + ) -> None:
168 + """Send fetch to the peer."""
169 + self._client.request_blocks(
170 + job_id, kv_request_id, keys, block_ids, send_ready=self._send_ready
171 + )
172 +
173 + def add_stored_blocks(
174 + self,
175 + kv_request_id: str,
176 + keys: Sequence[OffloadKey],
177 + block_ids: Sequence[int],
178 + job_id: JobId,
179 + ) -> None:
180 + """New blocks stored locally — match against pending fetch demand."""
181 + self._server.add_stored_blocks(kv_request_id, keys, block_ids, job_id)
182 +
183 + def finish_request(self, kv_request_id: str) -> None:
184 + """Called when the request is finishing locally.
185 +
186 + Cancels any inbound load (client role) and finalizes any
187 + outbound serving (server role) for this id. Roles that aren't
188 + active for this id are silent no-ops.
189 + """
190 + self._client.cancel(kv_request_id)
191 + self._server.finish(kv_request_id)
192 +
193 + def poll(self) -> SessionPollResult:
194 + """Process incoming messages, drive transfers, apply timeouts."""
195 + if self._conn is None:
196 + # Pending session — store-job timeouts still apply so buffered
197 + # jobs that never get picked up are surfaced as failures.
198 + return SessionPollResult(
199 + loads=[],
200 + stores=self._server.collect_idle_timeouts(),
201 + new_fetch_ids=[],
202 + )
203 +
204 + for msg in self._conn.recv():
205 + self._on_message(msg)
206 +
207 + loads = self._client.collect_results()
208 + stores = self._server.collect_results()
209 + self._server.drain_pending_aborts()
210 +
211 + new_fetch_ids = self._new_fetch_ids
212 + self._new_fetch_ids = []
213 + return SessionPollResult(
214 + loads=loads, stores=stores, new_fetch_ids=new_fetch_ids
215 + )
216 +
217 + def close(self) -> tuple[list[tuple[int, str]], list[int]]:
218 + """Shut down. Returns (failed_loads, failed_stores).
219 +
220 + failed_loads: list of (job_id, kv_request_id) pairs.
221 + failed_stores: list of job_ids.
222 + """
223 + failed_loads = self._client.close()
224 + failed_stores = self._server.close()
225 +
226 + if self._conn is not None:
227 + with contextlib.suppress(Exception):
228 + self._conn.send({TYPE_KEY: DisconnectMsg.TYPE})
229 + self._conn.close()
230 + self._conn = None
231 +
232 + return failed_loads, failed_stores
233 +
234 + # ------------------------------------------------------------------
235 + # Message dispatch
236 + # ------------------------------------------------------------------
237 +
238 + def _on_message(self, msg: dict) -> None:
239 + msg_type = msg.get(TYPE_KEY) if isinstance(msg, dict) else msg
240 + try:
241 + self._dispatch_message(msg)
242 + except ValueError as exc:
243 + # Protocol contract violation from the peer — *Msg.validate()
244 + # and handler-level checks raise ValueError. Retrying won't
245 + # help and may corrupt session state, so disconnect now.
246 + self._protocol_error(f"malformed {msg_type!r}: {exc}")
247 + return
248 + except Exception as exc:
249 + # Anything else is most likely an internal bug rather than a
250 + # peer fault. Log loudly with a traceback so it doesn't
251 + # disappear, but don't kill the session on a single hiccup.
252 + # Disconnect only if errors keep arriving — that pattern is
253 + # consistent with a peer wedging us into a broken state.
254 + self._dispatch_error_count += 1
255 + logger.exception(
256 + "P2PSession %s: error handling message %r (count=%d): %s",
257 + self.peer_id,
258 + msg_type,
259 + self._dispatch_error_count,
260 + exc,
261 + )
262 + if self._dispatch_error_count >= _MAX_CONSECUTIVE_DISPATCH_ERRORS:
263 + self._protocol_error(
264 + f"too many consecutive dispatch errors "
265 + f"({self._dispatch_error_count})"
266 + )
267 + return
268 + self._dispatch_error_count = 0
269 +
270 + def _protocol_error(self, reason: str) -> None:
271 + """Log a protocol violation and disconnect.
272 +
273 + Best-effort sends ``DisconnectMsg`` so the peer learns why we're
274 + going away, then marks the connection dead. The manager reaps
275 + the session on the next poll via ``alive``.
276 + """
277 + logger.error(
278 + "P2PSession %s: protocol error: %s — disconnecting",
279 + self.peer_id,
280 + reason,
281 + )
282 + if self._conn is not None:
283 + with contextlib.suppress(Exception):
284 + self._conn.send({TYPE_KEY: DisconnectMsg.TYPE})
285 + self._conn.mark_dead()
286 +
287 + def _dispatch_message(self, msg: dict) -> None:
288 + # Drop messages buffered before disconnect: a poll batch can
289 + # contain msg-after-DisconnectMsg, and dispatching them would
290 + # mutate state on a dead session.
291 + if self._conn is not None and not self._conn.alive:
292 + return
293 + msg_type = msg.get(TYPE_KEY) if isinstance(msg, dict) else None
294 + if msg_type == ConnectMsg.TYPE:
295 + self._on_connect(msg)
296 + elif msg_type == ConnectAckMsg.TYPE:
297 + ConnectAckMsg.validate(msg)
298 + self._on_connect_ack()
299 + elif msg_type == FetchMsg.TYPE:
300 + FetchMsg.validate(msg)
301 + kv_request_id = msg[FetchMsg.KV_REQUEST_ID]
302 + block_hashes = [
303 + OffloadKey(bh if isinstance(bh, bytes) else bytes(bh))
304 + for bh in msg[FetchMsg.BLOCK_HASHES]
305 + ]
306 + block_indexes = msg[FetchMsg.BLOCK_INDEXES]
307 + # Run the server-role state machine inline as today —
308 + # add_fetch_demand records demand against any blocks we've
309 + # already seen in `available`. Report the kv_request_id so
310 + # the manager (after poll() returns) can replay any parked
311 + # submit_store batches; their add_stored_blocks calls hit
312 + # the demand recorded here and submit transfers immediately.
313 + self._server.on_fetch(kv_request_id, block_hashes, block_indexes)
314 + self._new_fetch_ids.append(kv_request_id)
315 + elif msg_type == AbortFetchMsg.TYPE:
316 + AbortFetchMsg.validate(msg)
317 + self._server.on_abort_fetch(msg[AbortFetchMsg.KV_REQUEST_ID])
318 + elif msg_type == TransferDoneMsg.TYPE:
319 + TransferDoneMsg.validate(msg)
320 + self._client.on_transfer_done(
321 + msg[TransferDoneMsg.KV_REQUEST_ID],
322 + msg[TransferDoneMsg.SUCCESS],
323 + )
324 + elif msg_type == AbortAckMsg.TYPE:
325 + AbortAckMsg.validate(msg)
326 + self._client.on_abort_ack(msg[AbortAckMsg.KV_REQUEST_ID])
327 + elif msg_type == DisconnectMsg.TYPE:
328 + if self._conn is not None:
329 + self._conn.mark_dead()
330 + else:
331 + logger.warning(
332 + "P2PSession %s: unknown message type %r", self.peer_id, msg_type
333 + )
334 +
335 + # ------------------------------------------------------------------
336 + # Handshake
337 + # ------------------------------------------------------------------
338 +
339 + def _on_connect(self, msg: dict) -> None:
340 + # Validation failures here mean an incompatible or malicious peer.
341 + # Mark the connection dead so the manager reaps the session;
342 + # don't call add_remote_peer or send connect_ack.
343 + if self._send_ready:
344 + # We've already received connect_ack, so the handshake is
345 + # complete. A second connect from the peer is a protocol
346 + # violation — re-registering would corrupt transport state.
347 + self._protocol_error("duplicate connect after handshake")
348 + return
349 + try:
350 + ConnectMsg.validate(msg)
351 + if msg[ConnectMsg.BLOCK_LEN] != self._local_block_len:
352 + raise ValueError(
353 + f"block_len mismatch from {self.peer_id}: "
354 + f"remote={msg[ConnectMsg.BLOCK_LEN]}, "
355 + f"local={self._local_block_len}"
356 + )
357 + remote_fp = msg.get(ConnectMsg.CONFIG_FINGERPRINT, "")
358 + local_fp = self._transport.config_fingerprint
359 + if local_fp and remote_fp and remote_fp != local_fp:
360 + raise ValueError(
361 + f"config fingerprint mismatch from {self.peer_id}: "
362 + f"remote={remote_fp!r}, local={local_fp!r}"
363 + )
364 + self._transport.add_remote_peer(
365 + self.peer_id,
366 + agent_metadata=msg[ConnectMsg.AGENT_METADATA],
367 + base_addr=msg[ConnectMsg.BASE_ADDR],
368 + num_blocks=msg[ConnectMsg.NUM_BLOCKS],
369 + block_len=msg[ConnectMsg.BLOCK_LEN],
370 + )
371 + except ValueError as exc:
372 + logger.error("P2PSession %s: rejecting peer connect: %s", self.peer_id, exc)
373 + if self._conn is not None:
374 + self._conn.mark_dead()
375 + return
376 +
377 + if self._conn is not None:
378 + self._conn.send(
379 + {
380 + TYPE_KEY: ConnectAckMsg.TYPE,
381 + ConnectAckMsg.PEER_ID: self._local_id,
382 + }
383 + )
384 +
385 + def _on_connect_ack(self) -> None:
386 + if self._queued:
387 + logger.debug(
388 + "P2PSession %s: connect_ack received, flushing %d queued msg(s)",
389 + self.peer_id,
390 + len(self._queued),
391 + )
392 + self._send_ready = True
393 + for queued in self._queued:
394 + self._do_send(queued)
395 + self._queued.clear()
396 +
397 + # ------------------------------------------------------------------
398 + # Send helpers
399 + # ------------------------------------------------------------------
400 +
401 + def _send_connect(self) -> None:
402 + """Send our ConnectMsg announcing local NIXL metadata."""
403 + assert self._conn is not None
404 + self._conn.send(
405 + {
406 + TYPE_KEY: ConnectMsg.TYPE,
407 + ConnectMsg.PEER_ID: self._local_id,
408 + ConnectMsg.AGENT_METADATA: self._transport.get_agent_metadata(),
409 + ConnectMsg.BASE_ADDR: self._transport.base_addr,
410 + ConnectMsg.NUM_BLOCKS: self._transport.num_blocks,
411 + ConnectMsg.BLOCK_LEN: self._transport.block_len,
412 + ConnectMsg.CONFIG_FINGERPRINT: self._transport.config_fingerprint,
413 + }
414 + )
415 +
416 + def _send(self, msg: dict) -> None:
417 + if self._conn is None or not self._send_ready:
418 + logger.debug(
419 + "P2PSession %s: queueing %s (ready=%s queue_depth=%d)",
420 + self.peer_id,
421 + msg.get(TYPE_KEY),
422 + self._send_ready,
423 + len(self._queued) + 1,
424 + )
425 + self._queued.append(msg)
426 + return
427 + self._do_send(msg)
428 +
429 + def _do_send(self, msg: dict) -> None:
430 + if self._conn is None:
431 + return
432 + try:
433 + self._conn.send(msg)
434 + logger.debug("P2PSession %s: sent %s", self.peer_id, msg.get(TYPE_KEY))
435 + except Exception:
436 + logger.warning(
437 + "P2PSession %s: failed to send %s",
438 + self.peer_id,
439 + msg.get(TYPE_KEY),
440 + )