[KVOffload][P2P] Generic P2P secondary tier: peer lookup and serving via ParentManager (#48021)

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 Jul 26, 2026 at 11:45 UTC da3a252fd13f51c22657bfc8650936f2fbb5b6f3
11 files changed +2673 -396
docs/features/kv_offloading_usage.md
+66 -1
@@ -157,7 +157,7 @@ Object keys follow the same run-configuration digest scheme as the filesystem ti
157
158 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.
159
160 -PYTHONHASHSEED environment variable must be set to the same fixed value on all nodes.
160 +The `PYTHONHASHSEED` environment variable must be set to the same fixed value (e.g. `"0"`) on all nodes so that block content hashes match across instances (see [Cross-Process Sharing](#cross-process-sharing)). This is enforced: a P2P instance started without `PYTHONHASHSEED` set fails at startup, and each peer's value is verified during the connect handshake — a peer advertising a different `PYTHONHASHSEED` is rejected.
161
162 | Key | Required | Default | Notes |
163 | --- | --- | --- | --- |
@@ -176,6 +176,71 @@ Rather than embedding `host`/`port` in each `secondary_tiers` entry, set them on
176 - `VLLM_P2P_SIDE_CHANNEL_HOST` (default `localhost`): address the P2P control socket binds to. It is used **verbatim** as both the bind address and the identity peers dial back — there is no auto-detection (this mirrors `VLLM_NIXL_SIDE_CHANNEL_HOST`). The default binds the loopback interface only, so peers on another host cannot reach it. **For any cross-host P2P deployment you must set this explicitly to the node's routable IP** (e.g. the pod IP) before launching `vllm serve` — otherwise remote peers will fail to connect. The NIXL agent name is a separate per-process identifier, so peers sharing a `host:port` never collide.
177 - `VLLM_P2P_SIDE_CHANNEL_PORT` (default `5710`): base port for the P2P control socket. The port actually bound is `VLLM_P2P_SIDE_CHANNEL_PORT + data_parallel_index` — one socket per DP replica, matching NIXL (for DP=1 the offset is 0). The peer's port is passed as `remote_port` in `kv_transfer_params`; the router/EPP that selects the DP rank (e.g. via the `X-data-parallel-rank` header) computes `remote_port = base + rank`. The DP-index offset separates replicas *within* one deployment; two co-located *deployments* (a prefiller and a decoder on the same host) still need distinct base ports (e.g. decoder base `5711`) to avoid a bind collision.
178
179 +#### Orchestration-Layer Protocol
180 +
181 +The P2P tier does not decide *which* peer to pull from — that is the orchestration layer's job (the router/EPP and its scheduler). The orchestrator drives every transfer through a request's `kv_transfer_params` dict: it picks the request's role, allocates a unique transaction ID, and supplies the remote peer's address. All block lookup, hash matching, and NIXL transfer happen at the tier level below; the orchestrator only sets the correct role keys and enforces the allowed combinations.
182 +
183 +Every vLLM instance is a symmetric **peer**. Per request it acts as a **consumer** (pulls KV blocks from a remote peer's CPU cache instead of computing locally) or a **producer** (serves blocks from its own CPU cache to remote consumers) — or both, on the same session, for different requests. Roles are chosen per request by the keys below; there are no fixed prefiller/decoder processes.
184 +
185 +Three role keys are defined, each mapping to a sub-dict. All are optional; a request with none of them uses the tier only as a local CPU cache.
186 +
187 +Each key names the **remote counterpart** this peer transfers with (not this
188 +peer's own role), so the name reads as "the remote ___ I transfer with".
189 +
190 +| Key | Set on | Value fields | Meaning |
191 +| --- | --- | --- | --- |
192 +| `remote_decoder` | prefill producer request | `kv_request_id` | Peer computes KV and keeps it available in CPU cache for the remote decoder to pull. |
193 +| `remote_prefiller` | decode consumer request | `kv_request_id`, `remote_host`, `remote_port` | Peer pulls KV from the remote prefiller at the given address (classic P/D disaggregation). |
194 +| `remote_kv_source` | P2P consumer request | `kv_request_id`, `remote_host`, `remote_port` | Peer looks up and pulls whatever blocks the remote source currently holds in CPU cache. |
195 +
196 +Field semantics:
197 +
198 +- `kv_request_id` (str): unique transaction ID allocated by the orchestrator and pushed to every peer involved in the transfer; used to correlate the lookup, fetch, and transfer-done messages. The producer is implicit — it serves whatever block hashes it currently holds in its CPU cache for that ID.
199 +- `remote_host` (str): IP/hostname of the remote peer's control socket to query. Must be the peer's routable node IP (see [Environment Variables](#environment-variables)).
200 +- `remote_port` (int): the peer's bound control-socket port, i.e. `base + data_parallel_index` for the selected DP rank.
201 +
202 +Allowed and forbidden combinations:
203 +
204 +- **`remote_decoder` + `remote_kv_source`** is the only legal multi-key combination: a prefill producer may *also* act as a P2P consumer for the same request — skipping prefix prefill by pulling cached blocks from a source while still keeping its own computed blocks available for a downstream decoder.
205 +- Forbidden: `remote_prefiller` + `remote_decoder` (contradictory roles), `remote_prefiller` + `remote_kv_source` (two competing fetch sources), and all three together.
206 +
207 +Minimal examples (values that would appear in the request's `kv_transfer_params`):
208 +
209 +```python
210 +# Prefill producer — compute and keep KV for a remote decoder to pull
211 +kv_transfer_params = {"remote_decoder": {"kv_request_id": "<unique-transfer-id>"}}
212 +
213 +# Decode consumer — pull KV from a specific prefiller (classic P/D)
214 +kv_transfer_params = {
215 + "remote_prefiller": {
216 + "kv_request_id": "<unique-transfer-id>",
217 + "remote_host": "<prefiller-node-ip>",
218 + "remote_port": 5710,
219 + }
220 +}
221 +
222 +# P2P consumer — pull whatever the source already has cached
223 +kv_transfer_params = {
224 + "remote_kv_source": {
225 + "kv_request_id": "<unique-transfer-id>",
226 + "remote_host": "<source-node-ip>",
227 + "remote_port": 5710,
228 + }
229 +}
230 +```
231 +
232 +Runtime handshake for a P2P (or P/D) pull, once the orchestrator has set the keys above:
233 +
234 +1. Both peers already have listener threads on their control sockets (see [Environment Variables](#environment-variables)).
235 +2. **Lookup.** The consumer's tiering manager does per-block lookups; in P2P mode the tier returns `None` and registers the key. At `on_schedule_end` the consumer sends one **`LookupMsg`** (`kv_request_id` + block hashes) to the peer, per request, per step.
236 +3. The producer matches those hashes against its local CPU cache and replies with a **`LookupRespMsg`** carrying the hit block hashes.
237 +4. **Resolve.** Retried lookups now return hit / miss / in-flight. The consumer calls `submit_load` for hits only, allocating CPU slots only for hits.
238 +5. The consumer sends a **`FetchMsg`** (`kv_request_id`, block hashes, destination block indexes).
239 +6. The producer performs the **NIXL WRITE** transfer and sends **`TransferDone`** with a success status.
240 +7. On `get_finished`, hits are loaded into GPU as ordinary cache hits; misses are recomputed by the engine.
241 +
242 +In classic **P/D mode** (`remote_prefiller` set, no `remote_kv_source`), the lookup phase (steps 2–4) is skipped: the decode consumer assumes the prefiller holds all of the request's blocks, so every block `lookup()` returns an immediate hit and the consumer jumps straight to the **`FetchMsg`** in step 5. The `LookupMsg`/`LookupRespMsg` round-trip only happens in P2P mode, where the consumer does not know in advance which blocks the peer has cached.
243 +
244 ## Tuning Tips
245
246 - `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/p2p_connector_proxy.py
+22 -7
@@ -81,6 +81,8 @@ async def lifespan(app: FastAPI):
81 app.state.decode_dp_iterator = itertools.cycle(range(global_args.decoder_dp_size))
82
83 mode = "decoder-first" if global_args.decoder_first else "prefiller-first"
84 + if global_args.p2p:
85 + mode += ",p2p"
86 pd_host = global_args.p2p_connector_host
87 pd_port = global_args.p2p_connector_port
88 n_pref = len(app.state.prefill_clients)
@@ -145,6 +147,14 @@ def parse_args():
147 help="Send decode request before prefill so decoder is already "
148 "waiting when KV blocks arrive (decoder-first mode)",
149 )
150 + p.add_argument(
151 + "--p2p",
152 + action="store_true",
153 + help="P2P mode: do not inject kv_transfer_params on the prefiller; "
154 + "on the decoder, inject kv_transfer_params with a top-level "
155 + "'remote_kv_source' block ({kv_request_id, remote_host, remote_port}) "
156 + "instead of the default 'remote_prefiller' block.",
157 + )
158 p.add_argument(
159 "--prefiller-dp-size",
160 type=int,
@@ -206,11 +216,14 @@ def _auth_headers(request_id: str) -> dict:
216 async def _prefill(client_info, endpoint, req_data, request_id, dp_rank=None):
217 """Send a prefill-only request (max_tokens=1) to the prefiller."""
218 data = req_data.copy()
209 - data["kv_transfer_params"] = {
210 - "decode": {
211 - "kv_request_id": request_id,
212 - },
213 - }
219 + if global_args.p2p:
220 + data.pop("kv_transfer_params", None)
221 + else:
222 + data["kv_transfer_params"] = {
223 + "remote_decoder": {
224 + "kv_request_id": request_id,
225 + },
226 + }
227 data["stream"] = False
228 data["max_tokens"] = 1
229 data.pop("max_completion_tokens", None)
@@ -259,8 +272,9 @@ async def _handle_completions(api: str, request: Request):
272 # Inject the prefiller's P2PConnector address so the decoder can pull
273 # KV blocks from it. remote_port = base + prefill_rank targets the
274 # replica that produced the KV (base+0 == base when dp=1).
275 + decoder_key = "remote_kv_source" if global_args.p2p else "remote_prefiller"
276 req_data["kv_transfer_params"] = {
263 - "prefill": {
277 + decoder_key: {
278 "kv_request_id": request_id,
279 "remote_host": global_args.p2p_connector_host,
280 "remote_port": global_args.p2p_connector_port + prefill_rank,
@@ -311,9 +325,10 @@ async def _handle_completions_decoder_first(api: str, request: Request):
325 prefill_client = _get_next(request.app, "prefill")
326 decode_client = _get_next(request.app, "decode")
327
328 + decoder_key = "remote_kv_source" if global_args.p2p else "remote_prefiller"
329 decode_data = req_data.copy()
330 decode_data["kv_transfer_params"] = {
316 - "prefill": {
331 + decoder_key: {
332 "kv_request_id": request_id,
333 "remote_host": global_args.p2p_connector_host,
334 "remote_port": global_args.p2p_connector_port + prefill_rank,
tests/v1/kv_offload/tiering/p2p/test_manager.py
+256 -51
@@ -21,9 +21,11 @@ from vllm.v1.kv_offload.tiering.p2p import manager as manager_module
21 from vllm.v1.kv_offload.tiering.p2p.manager import (
22 _UNBOUND_STORE_TIMEOUT_S,
23 P2PSecondaryTierManager,
24 + _annotate_req_context,
25 )
26 from vllm.v1.kv_offload.tiering.p2p.session import (
27 LoadResult,
28 + SessionCloseResult,
29 SessionPollResult,
30 StoreResult,
31 )
@@ -33,15 +35,15 @@ from vllm.v1.kv_offload.tiering.p2p.session import (
35 # ---------------------------------------------------------------------------
36
37
36 -def _prefill_kv_params(
38 +def _remote_prefiller_kv_params(
39 remote_host: str = "10.0.0.1",
40 remote_port: int = 8000,
41 kv_request_id: str = "req-1",
42 ) -> dict:
41 - """Decoder-side kv_transfer_params: ``prefill`` sub-dict carries
43 + """Decoder-side kv_transfer_params: ``remote_prefiller`` sub-dict carries
44 kv_request_id + remote_host + remote_port."""
45 return {
44 - "prefill": {
46 + "remote_prefiller": {
47 "kv_request_id": kv_request_id,
48 "remote_host": remote_host,
49 "remote_port": remote_port,
@@ -49,14 +51,34 @@ def _prefill_kv_params(
51 }
52
53
52 -def _decode_kv_params(kv_request_id: str = "req-1") -> dict:
53 - """Prefiller-side kv_transfer_params: ``decode`` sub-dict carries
54 +def _remote_kv_source_kv_params(
55 + remote_host: str = "10.0.0.1",
56 + remote_port: int = 8000,
57 + kv_request_id: str = "req-1",
58 +) -> dict:
59 + """Symmetric-P2P consumer kv_transfer_params: ``remote_kv_source`` sub-dict has
60 + the same shape as ``remote_prefiller`` (kv_request_id + remote_host + port)."""
61 + return {
62 + "remote_kv_source": {
63 + "kv_request_id": kv_request_id,
64 + "remote_host": remote_host,
65 + "remote_port": remote_port,
66 + },
67 + }
68 +
69 +
70 +def _remote_decoder_kv_params(kv_request_id: str = "req-1") -> dict:
71 + """Prefiller-side kv_transfer_params: ``remote_decoder`` sub-dict carries
72 kv_request_id only."""
55 - return {"decode": {"kv_request_id": kv_request_id}}
73 + return {"remote_decoder": {"kv_request_id": kv_request_id}}
74
75
76 def _req_context(kv_params: dict | None = None) -> ReqContext:
59 - return ReqContext(req_id="test", kv_transfer_params=kv_params)
77 + ctx = ReqContext(req_id="test", kv_transfer_params=kv_params)
78 + # Mirror on_new_request: parse the P2P routing state once and cache it,
79 + # so lookup/submit_*/on_request_finished can read it back via get_state.
80 + _annotate_req_context(ctx)
81 + return ctx
82
83
84 def _job_metadata(
@@ -82,38 +104,78 @@ def _make_manager() -> P2PSecondaryTierManager:
104 """Create a manager with stubbed __init__."""
105 mgr = P2PSecondaryTierManager.__new__(P2PSecondaryTierManager)
106 mgr._local_id = "127.0.0.1:7777"
107 + mgr._hash_seed = "0"
108 mgr._finished_jobs = []
109 mgr._failed_req_ids = set()
110 mgr._sessions = {}
111 mgr._kv_to_session = {}
112 mgr._unbound_stores = {}
113 + mgr._failed_serve_ctxs = []
114 return mgr
115
116
117 +def _init_offloading_spec() -> SimpleNamespace:
118 + """Minimal offloading_spec for driving the real __init__."""
119 + return SimpleNamespace(
120 + config=SimpleNamespace(parallel=SimpleNamespace(data_parallel_index=0)),
121 + blocks_per_chunk=1,
122 + )
123 +
124 +
125 +# ---------------------------------------------------------------------------
126 +# Tests for __init__ PYTHONHASHSEED assertion
127 +# ---------------------------------------------------------------------------
128 +
129 +
130 +class TestInitHashSeedAssertion:
131 + def test_missing_pythonhashseed_raises(self, monkeypatch):
132 + """P2P instance refuses to start when PYTHONHASHSEED is unset."""
133 + monkeypatch.delenv("PYTHONHASHSEED", raising=False)
134 + with pytest.raises(ValueError, match="PYTHONHASHSEED"):
135 + P2PSecondaryTierManager(
136 + offloading_spec=_init_offloading_spec(),
137 + primary_kv_view=memoryview(bytearray(16)),
138 + )
139 +
140 + def test_pythonhashseed_set_succeeds(self, monkeypatch):
141 + """With PYTHONHASHSEED set, __init__ records it for the handshake."""
142 + monkeypatch.setenv("PYTHONHASHSEED", "12345")
143 + monkeypatch.setattr(manager_module, "NixlTransport", lambda *a, **k: object())
144 + monkeypatch.setattr(manager_module, "ZmqTransport", lambda *a, **k: object())
145 + monkeypatch.setattr(
146 + manager_module.FileMapper,
147 + "from_offloading_spec",
148 + lambda **k: SimpleNamespace(get_run_config=lambda: {}),
149 + )
150 + mgr = P2PSecondaryTierManager(
151 + offloading_spec=_init_offloading_spec(),
152 + primary_kv_view=memoryview(bytearray(16)),
153 + )
154 + assert mgr._hash_seed == "12345"
155 +
156 +
157 # ---------------------------------------------------------------------------
94 -# Tests for _remote_id_from_params
158 +# Tests for _peer_id_from_params
159 # ---------------------------------------------------------------------------
160
161
98 -class TestRemoteIdFromParams:
162 +class TestPeerIdFromParams:
163 def test_valid_params(self):
100 - result = P2PSecondaryTierManager._remote_id_from_params(
164 + result = manager_module._peer_id_from_params(
165 {"remote_host": "10.0.0.1", "remote_port": 8000}
166 )
167 assert result == "10.0.0.1:8000"
168
169 def test_missing_host(self):
106 - result = P2PSecondaryTierManager._remote_id_from_params({"remote_port": 8000})
170 + result = manager_module._peer_id_from_params({"remote_port": 8000})
171 assert result is None
172
173 def test_missing_port(self):
110 - result = P2PSecondaryTierManager._remote_id_from_params(
111 - {"remote_host": "10.0.0.1"}
112 - )
174 + result = manager_module._peer_id_from_params({"remote_host": "10.0.0.1"})
175 assert result is None
176
177 def test_empty_dict(self):
116 - result = P2PSecondaryTierManager._remote_id_from_params({})
178 + result = manager_module._peer_id_from_params({})
179 assert result is None
180
181
@@ -130,35 +192,105 @@ class TestLookup:
192
193 def test_lookup_returns_miss_without_required_fields(self):
194 mgr = _make_manager()
133 - ctx = _req_context(kv_params={"prefill": {"remote_host": "x"}})
195 + ctx = _req_context(kv_params={"remote_prefiller": {"remote_host": "x"}})
196 assert mgr.lookup(b"key", ctx) is LookupResult.MISS
197
198 def test_lookup_returns_hit_for_valid_request(self):
199 mgr = _make_manager()
138 - ctx = _req_context(kv_params=_prefill_kv_params())
200 + ctx = _req_context(kv_params=_remote_prefiller_kv_params())
201 assert mgr.lookup(b"key", ctx) is LookupResult.HIT
202
203 def test_lookup_returns_miss_for_failed_request(self):
204 mgr = _make_manager()
205 mgr._failed_req_ids.add("req-1")
144 - ctx = _req_context(kv_params=_prefill_kv_params(kv_request_id="req-1"))
206 + ctx = _req_context(kv_params=_remote_prefiller_kv_params(kv_request_id="req-1"))
207 assert mgr.lookup(b"key", ctx) is LookupResult.MISS
208
209 def test_lookup_returns_hit_for_different_request_id(self):
210 mgr = _make_manager()
211 mgr._failed_req_ids.add("req-1")
150 - ctx = _req_context(kv_params=_prefill_kv_params(kv_request_id="req-2"))
212 + ctx = _req_context(kv_params=_remote_prefiller_kv_params(kv_request_id="req-2"))
213 assert mgr.lookup(b"key", ctx) is LookupResult.HIT
214
215 def test_lookup_returns_miss_without_prefill_key(self):
154 - """No ``prefill`` sub-dict means the request was not routed for
216 + """No ``remote_prefiller`` sub-dict means the request was not routed for
217 remote prefill — local prefill should run instead, so lookup()
156 - returns MISS even when a stale ``decode`` block is present."""
218 + returns MISS even when a stale ``remote_decoder`` block is present."""
219 mgr = _make_manager()
158 - ctx = _req_context(kv_params=_decode_kv_params())
220 + ctx = _req_context(kv_params=_remote_decoder_kv_params())
221 assert mgr.lookup(b"key", ctx) is LookupResult.MISS
222
223
224 +# ---------------------------------------------------------------------------
225 +# Tests for serve_external_requests
226 +# ---------------------------------------------------------------------------
227 +
228 +
229 +class _RecordingParent:
230 + """Minimal ParentManager stub recording on_request_finished calls."""
231 +
232 + def __init__(self) -> None:
233 + self.finished: list[str] = []
234 +
235 + def on_new_request(self, ctx):
236 + from vllm.v1.kv_offload.base import RequestOffloadingContext
237 +
238 + return RequestOffloadingContext()
239 +
240 + def lookup(self, key, ctx):
241 + return LookupResult.MISS
242 +
243 + def create_store_job(self, keys, ctx):
244 + raise AssertionError("unreachable")
245 +
246 + def on_request_finished(self, ctx) -> None:
247 + self.finished.append(ctx.req_id)
248 +
249 +
250 +class _RecordingSession:
251 + """Fake P2PSession that records the parent it was served with."""
252 +
253 + def __init__(self) -> None:
254 + self.served_with: list[object] = []
255 +
256 + def serve_external_requests(self, parent) -> None:
257 + self.served_with.append(parent)
258 +
259 +
260 +class TestServeExternalRequests:
261 + def test_flushes_failed_serve_ctxs_then_serves_each_session(self):
262 + """serve_external_requests releases the failed serves left by
263 + reaped sessions via parent.on_request_finished (clearing the
264 + queue), then delegates to every live session with the same parent."""
265 + mgr = _make_manager()
266 + ctx = ReqContext(req_id="p2p:peer:req-1:lu1")
267 + mgr._failed_serve_ctxs = [ctx]
268 + sess_a = _RecordingSession()
269 + sess_b = _RecordingSession()
270 + mgr._sessions = {"a": sess_a, "b": sess_b} # type: ignore[assignment]
271 +
272 + parent = _RecordingParent()
273 + mgr.serve_external_requests(parent) # type: ignore[arg-type]
274 +
275 + # Failed serve released and queue cleared.
276 + assert parent.finished == ["p2p:peer:req-1:lu1"]
277 + assert mgr._failed_serve_ctxs == []
278 + # Every live session served with the same parent handle.
279 + assert sess_a.served_with == [parent]
280 + assert sess_b.served_with == [parent]
281 +
282 + def test_no_failed_serves_still_serves_sessions(self):
283 + mgr = _make_manager()
284 + sess = _RecordingSession()
285 + mgr._sessions = {"a": sess} # type: ignore[assignment]
286 +
287 + parent = _RecordingParent()
288 + mgr.serve_external_requests(parent) # type: ignore[arg-type]
289 +
290 + assert parent.finished == []
291 + assert sess.served_with == [parent]
292 +
293 +
294 # ---------------------------------------------------------------------------
295 # Tests for submit_store
296 # ---------------------------------------------------------------------------
@@ -166,16 +298,16 @@ class TestLookup:
298
299 class TestSubmitStore:
300 def test_no_decode_succeeds_immediately(self):
169 - """Without a ``decode`` block, job succeeds immediately."""
301 + """Without a ``remote_decoder`` block, job succeeds immediately."""
302 mgr = _make_manager()
303 job = _job_metadata(job_id=1, kv_params={})
304 mgr.submit_store(job)
305 assert mgr._finished_jobs == [JobResult(job_id=1, success=True)]
306
307 def test_missing_kv_request_id_fails(self):
176 - """Missing kv_request_id inside ``decode`` fails the job."""
308 + """Missing kv_request_id inside ``remote_decoder`` fails the job."""
309 mgr = _make_manager()
178 - params: dict = {"decode": {}}
310 + params: dict = {"remote_decoder": {}}
311 job = _job_metadata(job_id=1, kv_params=params)
312 mgr.submit_store(job)
313 assert mgr._finished_jobs == [JobResult(job_id=1, success=False)]
@@ -189,7 +321,7 @@ class TestSubmitStore:
321 job_id=1,
322 keys=[b"k1", b"k2"],
323 block_ids=[3, 4],
192 - kv_params=_decode_kv_params(kv_request_id="req-1"),
324 + kv_params=_remote_decoder_kv_params(kv_request_id="req-1"),
325 )
326 mgr.submit_store(job)
327
@@ -220,7 +352,7 @@ class TestSubmitStore:
352 job_id=7,
353 keys=[b"k1", b"k2"],
354 block_ids=[3, 4],
223 - kv_params=_decode_kv_params(kv_request_id="req-1"),
355 + kv_params=_remote_decoder_kv_params(kv_request_id="req-1"),
356 )
357 mgr.submit_store(job)
358
@@ -233,9 +365,9 @@ class TestSubmitStore:
365 def test_extra_top_level_keys_are_ignored(self):
366 """Producer-side kv_transfer_params should not pre-create a
367 session even when a stale caller still passes a top-level
236 - ``remote_host``/``remote_port`` next to ``decode``."""
368 + ``remote_host``/``remote_port`` next to ``remote_decoder``."""
369 mgr = _make_manager()
238 - params = _decode_kv_params()
370 + params = _remote_decoder_kv_params()
371 params["remote_host"] = "stale"
372 params["remote_port"] = 12345
373 job = _job_metadata(job_id=1, kv_params=params)
@@ -262,7 +394,7 @@ class TestSubmitLoad:
394 """Empty key list succeeds immediately."""
395 mgr = _make_manager()
396 job = _job_metadata(
265 - job_id=1, keys=[], block_ids=[], kv_params=_prefill_kv_params()
397 + job_id=1, keys=[], block_ids=[], kv_params=_remote_prefiller_kv_params()
398 )
399 mgr.submit_load(job)
400 assert mgr._finished_jobs == [JobResult(job_id=1, success=True)]
@@ -270,7 +402,7 @@ class TestSubmitLoad:
402 def test_no_session_fails(self):
403 """No session for peer fails and marks request failed."""
404 mgr = _make_manager()
273 - job = _job_metadata(job_id=1, kv_params=_prefill_kv_params())
405 + job = _job_metadata(job_id=1, kv_params=_remote_prefiller_kv_params())
406 mgr.submit_load(job)
407 assert mgr._finished_jobs == [JobResult(job_id=1, success=False)]
408 assert "req-1" in mgr._failed_req_ids
@@ -286,7 +418,7 @@ class TestSubmitLoad:
418 job_id=42,
419 keys=[b"k1", b"k2"],
420 block_ids=[5, 6],
289 - kv_params=_prefill_kv_params(kv_request_id="req-42"),
421 + kv_params=_remote_prefiller_kv_params(kv_request_id="req-42"),
422 )
423 mgr.submit_load(job)
424
@@ -294,6 +426,20 @@ class TestSubmitLoad:
426 assert mgr._finished_jobs == []
427 assert "req-42" not in mgr._failed_req_ids
428
429 + def test_missing_consumer_flag_fails(self):
430 + """Peer fields present but neither do_remote_prefill nor
431 + do_p2p_fetch is set — submit_load fails the job rather than
432 + emit a stray FetchMsg."""
433 + mgr = _make_manager()
434 + params = {
435 + "remote_host": "10.0.0.1",
436 + "remote_port": 8000,
437 + "kv_request_id": "req-1",
438 + }
439 + job = _job_metadata(job_id=1, kv_params=params)
440 + mgr.submit_load(job)
441 + assert mgr._finished_jobs == [JobResult(job_id=1, success=False)]
442 +
443
444 # ---------------------------------------------------------------------------
445 # Tests for on_request_finished
@@ -308,7 +454,7 @@ class TestOnRequestFinished:
454
455 def test_prunes_failed_req_ids(self):
456 mgr = self._make_with_failed()
311 - ctx = _req_context(kv_params=_prefill_kv_params(kv_request_id="req-1"))
457 + ctx = _req_context(kv_params=_remote_prefiller_kv_params(kv_request_id="req-1"))
458 mgr.on_request_finished(ctx)
459 assert "req-1" not in mgr._failed_req_ids
460
@@ -325,14 +471,26 @@ class TestOnRequestFinished:
471 assert "req-1" in mgr._failed_req_ids
472
473 def test_decoder_side_calls_session_finish_request(self):
328 - """Decoder-side finish (``prefill`` set) still routes via peer_id
474 + """Decoder-side finish (``remote_prefiller`` set) still routes via peer_id
475 because the consumer addresses the producer it loaded from. The
476 session's finish_request cancels the client-role load."""
477 mgr = _make_manager()
478 peer_id = "10.0.0.1:8000"
479 session = _FakeSession(peer_id=peer_id)
480 mgr._sessions[peer_id] = session
335 - ctx = _req_context(kv_params=_prefill_kv_params(kv_request_id="req-1"))
481 + ctx = _req_context(kv_params=_remote_prefiller_kv_params(kv_request_id="req-1"))
482 + mgr.on_request_finished(ctx)
483 + assert session.finishes == ["req-1"]
484 +
485 + def test_p2p_consumer_side_calls_session_finish_request(self):
486 + """Symmetric-P2P consumer finish (``remote_kv_source`` set) routes via peer_id
487 + so the session drops any pending lookups (cancel_lookups) and
488 + cancels any inbound load."""
489 + mgr = _make_manager()
490 + peer_id = "10.0.0.1:8000"
491 + session = _FakeSession(peer_id=peer_id)
492 + mgr._sessions[peer_id] = session
493 + ctx = _req_context(kv_params=_remote_kv_source_kv_params(kv_request_id="req-1"))
494 mgr.on_request_finished(ctx)
495 assert session.finishes == ["req-1"]
496
@@ -342,7 +500,7 @@ class TestOnRequestFinished:
500 mgr = _make_manager()
501 bound = _FakeSession(peer_id="some-peer:1", connected=True)
502 mgr._kv_to_session["req-1"] = bound # type: ignore[assignment]
345 - ctx = _req_context(kv_params=_decode_kv_params(kv_request_id="req-1"))
503 + ctx = _req_context(kv_params=_remote_decoder_kv_params(kv_request_id="req-1"))
504 mgr.on_request_finished(ctx)
505 assert bound.finishes == ["req-1"]
506 assert "req-1" not in mgr._kv_to_session
@@ -360,7 +518,7 @@ class TestOnRequestFinished:
518 _UnboundStoreBatch(job_id=10, keys=[b"k"], block_ids=[0]),
519 _UnboundStoreBatch(job_id=11, keys=[b"k2"], block_ids=[1]),
520 ]
363 - ctx = _req_context(kv_params=_decode_kv_params(kv_request_id="req-1"))
521 + ctx = _req_context(kv_params=_remote_decoder_kv_params(kv_request_id="req-1"))
522 mgr.on_request_finished(ctx)
523 assert "req-1" in mgr._unbound_stores
524 assert [b.job_id for b in mgr._unbound_stores["req-1"]] == [10, 11]
@@ -378,11 +536,19 @@ class _FakeServerHalf:
536 def __init__(self) -> None:
537 self._inflight: dict[int, object] = {}
538
539 + @property
540 + def has_inflight_transfers(self) -> bool:
541 + return bool(self._inflight)
542 +
543
544 class _FakeClientHalf:
545 def __init__(self) -> None:
546 self._inbound: dict[int, object] = {}
547
548 + @property
549 + def has_active_loads(self) -> bool:
550 + return bool(self._inbound)
551 +
552
553 class _FakeSession:
554 """Fake bidirectional session that returns canned poll() results."""
@@ -395,8 +561,10 @@ class _FakeSession:
561 loads: list[LoadResult] | None = None,
562 stores: list[StoreResult] | None = None,
563 new_fetch_ids: list[str] | None = None,
398 - close_loads: list[tuple[int, str]] | None = None,
564 + close_jobs: list[int] | None = None,
565 + close_req_ids: list[str] | None = None,
566 close_stores: list[int] | None = None,
567 + close_failed_serves: list[ReqContext] | None = None,
568 ) -> None:
569 self.peer_id = peer_id
570 self.alive = alive
@@ -405,18 +573,24 @@ class _FakeSession:
573 self._loads = loads or []
574 self._stores = stores or []
575 self._new_fetch_ids = new_fetch_ids or []
408 - self._close_loads = close_loads or []
576 + self._close_jobs = close_jobs or []
577 + self._close_req_ids = close_req_ids or []
578 self._close_stores = close_stores or []
579 + self._close_failed_serves = close_failed_serves or []
580 self.requests: list[tuple[int, str]] = []
581 self.stores_added: list[tuple[str, list, object, int]] = []
582 self.attached: list[object] = []
583 self.finishes: list[str] = []
584 # Mirror P2PSession._server._inflight (transfer_id → handle) and
415 - # P2PSession._client._inbound for the shutdown-drain and drain_jobs
416 - # paths. Tests populate _server._inflight when needed.
585 + # P2PSession._client.has_active_loads for the shutdown-drain and
586 + # drain_jobs paths. Tests populate _server._inflight when needed.
587 self._server = _FakeServerHalf()
588 self._client = _FakeClientHalf()
589
590 + @property
591 + def has_pending_work(self) -> bool:
592 + return self._client.has_active_loads or self._server.has_inflight_transfers
593 +
594 def poll(self):
595 result = SessionPollResult(
596 loads=self._loads,
@@ -442,7 +616,12 @@ class _FakeSession:
616 self.finishes.append(kv_request_id)
617
618 def close(self):
445 - return self._close_loads, self._close_stores
619 + return SessionCloseResult(
620 + failed_jobs=self._close_jobs,
621 + failed_req_ids=self._close_req_ids,
622 + failed_stores=self._close_stores,
623 + failed_serves=self._close_failed_serves,
624 + )
625
626
627 class TestGetFinished:
@@ -484,7 +663,8 @@ class TestGetFinished:
663 peer_id="dead:1234",
664 alive=False,
665 connected=True,
487 - close_loads=[(20, "req-load")],
666 + close_jobs=[20],
667 + close_req_ids=["req-load"],
668 close_stores=[10, 11],
669 )
670 mgr._sessions["dead:1234"] = dead # type: ignore[assignment]
@@ -498,6 +678,29 @@ class TestGetFinished:
678 assert "dead:1234" not in mgr._sessions
679 assert "req-load" in mgr._failed_req_ids
680
681 + def test_reap_fails_probes(self):
682 + """A reaped session's in-flight lookups land in _failed_req_ids so
683 + the consumer's lookup() returns MISS instead of RETRY forever."""
684 +
685 + class FakeData:
686 + def remove_remote_peer(self, pid):
687 + pass
688 +
689 + mgr = self._make()
690 + mgr._data = FakeData() # type: ignore[assignment]
691 + dead = _FakeSession(
692 + peer_id="dead:1234",
693 + alive=False,
694 + connected=True,
695 + close_req_ids=["req-probe-1", "req-probe-2"],
696 + )
697 + mgr._sessions["dead:1234"] = dead # type: ignore[assignment]
698 +
699 + list(mgr.get_finished_jobs())
700 + assert "dead:1234" not in mgr._sessions
701 + assert "req-probe-1" in mgr._failed_req_ids
702 + assert "req-probe-2" in mgr._failed_req_ids
703 +
704 def test_unbound_store_kept_within_timeout(self):
705 """Recently-parked unbound stores stay across a poll."""
706 mgr = self._make()
@@ -537,7 +740,7 @@ class TestGetFinished:
740 submitted_at stamp so the unbound-store sweep can age it out."""
741 mgr = _make_manager()
742 job = _job_metadata(
540 - job_id=1, kv_params=_decode_kv_params(kv_request_id="req-1")
743 + job_id=1, kv_params=_remote_decoder_kv_params(kv_request_id="req-1")
744 )
745 before = time.monotonic()
746 mgr.submit_store(job)
@@ -881,21 +1084,21 @@ class TestBidirectionalManager:
1084 b_loads_kv = "req-BtoA-load" # B loads, A serves
1085
1086 a_decoder_params = {
884 - "prefill": {
1087 + "remote_prefiller": {
1088 "kv_request_id": a_loads_kv,
1089 "remote_host": "B",
1090 "remote_port": 2,
1091 },
1092 }
1093 b_decoder_params = {
891 - "prefill": {
1094 + "remote_prefiller": {
1095 "kv_request_id": b_loads_kv,
1096 "remote_host": "A",
1097 "remote_port": 1,
1098 },
1099 }
897 - a_prefiller_params = {"decode": {"kv_request_id": b_loads_kv}}
898 - b_prefiller_params = {"decode": {"kv_request_id": a_loads_kv}}
1100 + a_prefiller_params = {"remote_decoder": {"kv_request_id": b_loads_kv}}
1101 + b_prefiller_params = {"remote_decoder": {"kv_request_id": a_loads_kv}}
1102
1103 # 1. Both sides open client-role sessions toward the peer.
1104 mgr_a.on_new_request(_req_context(a_decoder_params))
@@ -1065,7 +1268,8 @@ class TestPollOnce:
1268 peer_id=peer_dead,
1269 alive=False,
1270 connected=True,
1068 - close_loads=[(33, "req-33")],
1271 + close_jobs=[33],
1272 + close_req_ids=["req-33"],
1273 close_stores=[44],
1274 )
1275 mgr._sessions[peer_dead] = dead # type: ignore[assignment]
@@ -1309,13 +1513,13 @@ class TestConnectionDeathMidTransfer:
1513 mgr_a, mgr_b = _build_paired_managers()
1514
1515 a_decoder_params = {
1312 - "prefill": {
1516 + "remote_prefiller": {
1517 "kv_request_id": "req-load",
1518 "remote_host": "B",
1519 "remote_port": 2,
1520 },
1521 }
1318 - a_prefiller_params = {"decode": {"kv_request_id": "req-store"}}
1522 + a_prefiller_params = {"remote_decoder": {"kv_request_id": "req-store"}}
1523
1524 # Open the outbound session A->B and submit one load + one store.
1525 mgr_a.on_new_request(_req_context(a_decoder_params))
@@ -1394,6 +1598,7 @@ class TestBindHostPortDefaults:
1598 identity (``host:port``) stays decoupled from the NIXL agent name
1599 (a uuid).
1600 """
1601 + monkeypatch.setenv("PYTHONHASHSEED", "0")
1602 monkeypatch.setattr(
1603 manager_module,
1604 "FileMapper",
tests/v1/kv_offload/tiering/p2p/test_sessions.py
+966 -83
@@ -13,9 +13,18 @@ completes its own load.
13 from __future__ import annotations
14
15 import time
16 +from collections.abc import Sequence
17
18 +import numpy as np
19 import pytest
20
21 +from vllm.v1.kv_offload.base import (
22 + LookupResult,
23 + OffloadKey,
24 + ReqContext,
25 + RequestOffloadingContext,
26 +)
27 +from vllm.v1.kv_offload.tiering.base import JobMetadata
28 from vllm.v1.kv_offload.tiering.p2p.session import (
29 LoadResult,
30 P2PSession,
@@ -33,6 +42,8 @@ from vllm.v1.kv_offload.tiering.p2p.session.protocol import (
42 ConnectMsg,
43 DisconnectMsg,
44 FetchMsg,
45 + LookupMsg,
46 + LookupRespMsg,
47 TransferDoneMsg,
48 )
49 from vllm.v1.kv_offload.tiering.p2p.session.server import (
@@ -48,6 +59,11 @@ from vllm.v1.kv_offload.tiering.p2p.session.session import (
59 # ---------------------------------------------------------------------------
60
61
62 +# Shared PYTHONHASHSEED used by the session under test and the fake peer's
63 +# ConnectMsg so the handshake succeeds unless a test overrides one side.
64 +_DEFAULT_HASH_SEED = "0"
65 +
66 +
67 class FakeDataTransport:
68 """Minimal fake DataTransport for testing sessions."""
69
@@ -146,12 +162,16 @@ class FakeConnection:
162 self._inbox: list[dict] = []
163 self._sent: list[dict] = []
164 self._closed = False
165 + # When True, send() raises to simulate a broken/dead connection.
166 + self.fail_send = False
167
168 @property
169 def alive(self) -> bool:
170 return not self._closed
171
172 def send(self, msg: dict) -> None:
173 + if self.fail_send:
174 + raise ConnectionError("simulated dead connection")
175 self._sent.append(msg)
176
177 def recv(self) -> list[dict]:
@@ -173,6 +193,7 @@ def _peer_connect_msg(
193 peer_id: str = "peer:8000",
194 block_len: int = 4096,
195 fingerprint: str | None = None,
196 + hash_seed: str = _DEFAULT_HASH_SEED,
197 ) -> dict:
198 """Build a ConnectMsg as if the peer sent it."""
199 msg = {
@@ -182,17 +203,82 @@ def _peer_connect_msg(
203 ConnectMsg.BASE_ADDR: 0x2000,
204 ConnectMsg.NUM_BLOCKS: 16,
205 ConnectMsg.BLOCK_LEN: block_len,
206 + ConnectMsg.HASH_SEED: hash_seed,
207 }
208 if fingerprint is not None:
209 msg[ConnectMsg.CONFIG_FINGERPRINT] = fingerprint
210 return msg
211
212
213 +class FakeParent:
214 + """Configurable :class:`ParentManager` for server-role tests.
215 +
216 + ``stored`` is the dict of ready blocks (key → primary block_id).
217 + ``pending`` and ``retry`` script the first lookup() result for those
218 + keys; subsequent lookups behave normally (a key that promised
219 + HIT_PENDING / RETRY can later be promoted to HIT by adding it to
220 + ``stored`` and removing it from ``pending``/``retry``). ``calls``
221 + captures every parent invocation in order for assertions.
222 +
223 + Injected per-step via ``session.serve_external_requests(parent)`` —
224 + not held by the session, matching how ``TieringOffloadingManager``
225 + hands the tier a handle valid only for that call.
226 + """
227 +
228 + def __init__(
229 + self,
230 + stored: dict[OffloadKey, int] | None = None,
231 + pending: set[OffloadKey] | None = None,
232 + retry: set[OffloadKey] | None = None,
233 + ) -> None:
234 + self.stored: dict[OffloadKey, int] = dict(stored or {})
235 + self.pending: set[OffloadKey] = set(pending or ())
236 + self.retry: set[OffloadKey] = set(retry or ())
237 + self._next_job_id: int = 1000
238 + self.calls: list[tuple] = []
239 +
240 + def on_new_request(self, ctx: ReqContext) -> RequestOffloadingContext:
241 + self.calls.append(("on_new_request", ctx.req_id))
242 + return RequestOffloadingContext()
243 +
244 + def lookup(self, key: OffloadKey, ctx: ReqContext) -> LookupResult:
245 + self.calls.append(("lookup", key, ctx.req_id))
246 + if key in self.pending:
247 + return LookupResult.HIT_PENDING
248 + if key in self.retry:
249 + return LookupResult.RETRY
250 + if key in self.stored:
251 + return LookupResult.HIT
252 + return LookupResult.MISS
253 +
254 + def create_store_job(
255 + self,
256 + keys: Sequence[OffloadKey],
257 + ctx: ReqContext,
258 + ) -> JobMetadata:
259 + keys_list = list(keys)
260 + self.calls.append(("create_store_job", tuple(keys_list), ctx.req_id))
261 + block_ids = np.array([self.stored[k] for k in keys_list], dtype=np.int32)
262 + job_id = self._next_job_id
263 + self._next_job_id += 1
264 + return JobMetadata(
265 + job_id=job_id,
266 + keys=keys_list,
267 + block_ids=block_ids,
268 + is_promotion=False,
269 + req_context=ctx,
270 + )
271 +
272 + def on_request_finished(self, ctx: ReqContext) -> None:
273 + self.calls.append(("on_request_finished", ctx.req_id))
274 +
275 +
276 def _make_session(
277 conn: FakeConnection | None = None,
278 transport: FakeDataTransport | None = None,
279 peer_id: str = "peer:8000",
280 local_id: str = "local:9000",
281 + local_hash_seed: str = _DEFAULT_HASH_SEED,
282 ) -> tuple[P2PSession, FakeConnection, FakeDataTransport]:
283 if conn is None:
284 conn = FakeConnection(peer_id=peer_id)
@@ -203,11 +289,17 @@ def _make_session(
289 local_id=local_id,
290 transport=transport, # type: ignore[arg-type]
291 local_block_len=transport.block_len,
292 + local_hash_seed=local_hash_seed,
293 conn=conn, # type: ignore[arg-type]
294 )
295 return session, conn, transport
296
297
298 +def _serve(session: P2PSession, parent: FakeParent) -> None:
299 + """Resolve enqueued inbound lookups, as the manager does each step."""
300 + session.serve_external_requests(parent) # type: ignore[arg-type]
301 +
302 +
303 def _activate(
304 session: P2PSession, conn: FakeConnection, peer_id: str = "peer:8000"
305 ) -> None:
@@ -217,6 +309,42 @@ def _activate(
309 session.poll()
310
311
312 +# --- Accessors for the server role's consolidated per-request state.
313 +# ServerRole keeps one _ServerRequestState per kv_request_id; entries are
314 +# garbage-collected once fully idle, so "no outbound"/"no abort" reads as
315 +# either a missing entry or a None field. These helpers paper over that.
316 +
317 +
318 +def _srv_outbound(session: P2PSession, kv_request_id: str):
319 + """Outbound serve state for a kv_request_id, or None (idle / GC'd)."""
320 + st = session._server._requests.get(kv_request_id)
321 + return st.outbound if st is not None else None
322 +
323 +
324 +def _srv_lookups(session: P2PSession) -> list:
325 + """Every parked inbound _ActiveLookup across all requests."""
326 + return [
327 + lu for st in session._server._requests.values() for lu in st.lookups.values()
328 + ]
329 +
330 +
331 +def _srv_abort_started(session: P2PSession, kv_request_id: str) -> float | None:
332 + """Pending-abort start time for a kv_request_id, or None."""
333 + st = session._server._requests.get(kv_request_id)
334 + return st.abort_started_at if st is not None else None
335 +
336 +
337 +def _srv_inflight_count(session: P2PSession, kv_request_id: str) -> int:
338 + """Inflight-transfer count tracked for a kv_request_id (0 if idle)."""
339 + st = session._server._requests.get(kv_request_id)
340 + return len(st.inflight_tids) if st is not None else 0
341 +
342 +
343 +def _srv_total_inflight(session: P2PSession) -> int:
344 + """Sum of per-request inflight counts across all requests."""
345 + return sum(len(st.inflight_tids) for st in session._server._requests.values())
346 +
347 +
348 # ---------------------------------------------------------------------------
349 # Connect / handshake
350 # ---------------------------------------------------------------------------
@@ -300,6 +428,29 @@ class TestConnectHandshake:
428 session.poll()
429 assert "peer:8000" in transport._remote_peers
430
431 + def test_hash_seed_mismatch_marks_dead(self):
432 + """Mismatched PYTHONHASHSEED rejects peer and marks connection dead."""
433 + session, conn, transport = _make_session(local_hash_seed="0")
434 + conn.enqueue(_peer_connect_msg(hash_seed="12345")) # mismatch
435 + session.poll()
436 + assert "peer:8000" not in transport._remote_peers
437 + assert not session.alive
438 + assert not any(m[TYPE_KEY] == ConnectAckMsg.TYPE for m in conn._sent)
439 +
440 + def test_hash_seed_match_succeeds(self):
441 + """Matching PYTHONHASHSEED registers the peer and acks."""
442 + session, conn, transport = _make_session(local_hash_seed="12345")
443 + conn.enqueue(_peer_connect_msg(hash_seed="12345"))
444 + session.poll()
445 + assert "peer:8000" in transport._remote_peers
446 + assert session.alive
447 + assert any(m[TYPE_KEY] == ConnectAckMsg.TYPE for m in conn._sent)
448 +
449 + def test_hash_seed_advertised_in_connect_msg(self):
450 + """Session advertises its own PYTHONHASHSEED in the ConnectMsg."""
451 + _, conn, _ = _make_session(local_hash_seed="777")
452 + assert conn._sent[0][ConnectMsg.HASH_SEED] == "777"
453 +
454
455 # ---------------------------------------------------------------------------
456 # Client-role flows
@@ -316,7 +467,7 @@ class TestClientFlows:
467 lookup = conn._sent[-1]
468 assert lookup[TYPE_KEY] == FetchMsg.TYPE
469 assert lookup[FetchMsg.KV_REQUEST_ID] == "req-1"
319 - assert lookup[FetchMsg.BLOCK_HASHES] == [b"k1", b"k2"]
470 + assert lookup[FetchMsg.KEYS] == [b"k1", b"k2"]
471 assert lookup[FetchMsg.BLOCK_INDEXES] == [0, 1]
472
473 def test_transfer_done_success(self):
@@ -362,13 +513,46 @@ class TestClientFlows:
513 assert abort[TYPE_KEY] == AbortFetchMsg.TYPE
514 assert abort[AbortFetchMsg.KV_REQUEST_ID] == "req-1"
515
516 + def test_active_loads_work_list_tracks_in_flight(self):
517 + """collect_results / has_active_loads use the _active_loads work-list,
518 + armed when a fetch is issued and discarded exactly when its load
519 + clears — a probe-only request never enters it, and completion empties
520 + it while the entry may briefly linger for GC."""
521 + session, conn, _ = _make_session()
522 + _activate(session, conn)
523 + client = session._client
524 +
525 + # A probe-only request has no in-flight load: not in _active_loads.
526 + session.register_lookup("req-probe", b"hp")
527 + assert client._active_loads == set()
528 + assert client.has_active_loads is False
529 +
530 + # Issuing a fetch arms the work-list.
531 + session.request_blocks(
532 + job_id=1, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
533 + )
534 + assert client._active_loads == {"req-1"}
535 + assert client.has_active_loads is True
536 +
537 + # Completion clears the load and discards it from the work-list.
538 + conn.enqueue(
539 + {
540 + TYPE_KEY: TransferDoneMsg.TYPE,
541 + TransferDoneMsg.KV_REQUEST_ID: "req-1",
542 + TransferDoneMsg.SUCCESS: True,
543 + }
544 + )
545 + session.poll()
546 + assert client._active_loads == set()
547 + assert client.has_active_loads is False
548 +
549 def test_load_timeout_sends_abort(self):
550 session, conn, _ = _make_session()
551 _activate(session, conn)
552 session.request_blocks(
553 job_id=1, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
554 )
371 - session._client._inbound["req-1"].submitted_at = time.monotonic() - 60.0
555 + session._client._requests["req-1"].load.submitted_at = time.monotonic() - 60.0
556 session.poll()
557 abort = conn._sent[-1]
558 assert abort[TYPE_KEY] == AbortFetchMsg.TYPE
@@ -376,7 +560,7 @@ class TestClientFlows:
560 def test_load_abort_ack_timeout_surfaces_failure(self):
561 """After load timeout sends AbortFetch, if no AbortAck arrives within
562 _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
563 + from _requests — the engine cannot wait forever on a peer that won't
564 ack.
565 """
566 session, conn, _ = _make_session()
@@ -385,7 +569,7 @@ class TestClientFlows:
569 job_id=7, kv_request_id="req-7", keys=[b"k"], block_ids=[0]
570 )
571 # 1) Trip the load timeout to send AbortFetch and stamp aborted_at.
388 - session._client._inbound["req-7"].submitted_at = (
572 + session._client._requests["req-7"].load.submitted_at = (
573 time.monotonic() - _LOAD_TIMEOUT_S - 1.0
574 )
575 loads = session.poll().loads
@@ -395,32 +579,32 @@ class TestClientFlows:
579 and m[AbortFetchMsg.KV_REQUEST_ID] == "req-7"
580 for m in conn._sent
581 )
398 - assert session._client._inbound["req-7"].aborted_at is not None
582 + assert session._client._requests["req-7"].load.aborted_at is not None
583
584 # 2) Now backdate aborted_at past the abort-ack timeout. No ack ever
585 # arrived from the peer.
402 - session._client._inbound["req-7"].aborted_at = (
586 + session._client._requests["req-7"].load.aborted_at = (
587 time.monotonic() - _ABORT_ACK_TIMEOUT_S - 1.0
588 )
589 loads = session.poll().loads
590 assert loads == [LoadResult(job_id=7, kv_request_id="req-7", success=False)]
407 - assert "req-7" not in session._client._inbound
591 + assert "req-7" not in session._client._requests
592
593 def test_load_abort_ack_clears_request(self):
594 """After load timeout sends AbortFetch, an arriving AbortAckMsg from
595 the peer surfaces the failure cleanly and removes the request from
412 - _inbound — covers the on_abort_ack arrival path."""
596 + _requests — covers the on_abort_ack arrival path."""
597 session, conn, _ = _make_session()
598 _activate(session, conn)
599 session.request_blocks(
600 job_id=8, kv_request_id="req-8", keys=[b"k"], block_ids=[0]
601 )
418 - session._client._inbound["req-8"].submitted_at = (
602 + session._client._requests["req-8"].load.submitted_at = (
603 time.monotonic() - _LOAD_TIMEOUT_S - 1.0
604 )
605 # First poll: AbortFetch goes out.
606 session.poll()
423 - assert session._client._inbound["req-8"].aborted_at is not None
607 + assert session._client._requests["req-8"].load.aborted_at is not None
608
609 # Peer acks the abort.
610 conn.enqueue(
@@ -431,7 +615,652 @@ class TestClientFlows:
615 )
616 loads = session.poll().loads
617 assert loads == [LoadResult(job_id=8, kv_request_id="req-8", success=False)]
434 - assert "req-8" not in session._client._inbound
618 + assert "req-8" not in session._client._requests
619 +
620 +
621 +# ---------------------------------------------------------------------------
622 +# Symmetric-P2P lookup flow (do_p2p_fetch)
623 +# ---------------------------------------------------------------------------
624 +
625 +
626 +class TestLookupFlow:
627 + """Consumer-side state machine for do_p2p_fetch lookups."""
628 +
629 + def test_aggregate_flush_resolve_round_trip(self):
630 + """register_lookup → flush sends one LookupMsg → response
631 + resolves entries → register_lookup returns the cached bool on
632 + every call, and repeat probes never re-issue a LookupMsg."""
633 + session, conn, _ = _make_session()
634 + _activate(session, conn)
635 +
636 + # Aggregate two keys for the same kv_request_id; both return None.
637 + assert session.register_lookup("req-1", b"hA") is None
638 + assert session.register_lookup("req-1", b"hB") is None
639 +
640 + # Flush sends one LookupMsg with both keys.
641 + sent_before = len(conn._sent)
642 + session.flush_pending_lookups()
643 + new = conn._sent[sent_before:]
644 + assert len(new) == 1
645 + msg = new[0]
646 + assert msg[TYPE_KEY] == LookupMsg.TYPE
647 + assert msg[LookupMsg.KV_REQUEST_ID] == "req-1"
648 + assert sorted(msg[LookupMsg.KEYS]) == [b"hA", b"hB"]
649 +
650 + # Idempotent re-flush sends nothing — the entries are now in-flight.
651 + sent_before = len(conn._sent)
652 + session.flush_pending_lookups()
653 + assert conn._sent[sent_before:] == []
654 +
655 + # While in-flight, register_lookup keeps returning None.
656 + assert session.register_lookup("req-1", b"hA") is None
657 +
658 + # Peer answers: hA hit, hB miss.
659 + conn.enqueue(
660 + {
661 + TYPE_KEY: LookupRespMsg.TYPE,
662 + LookupRespMsg.KV_REQUEST_ID: "req-1",
663 + LookupRespMsg.KEYS: [b"hA", b"hB"],
664 + LookupRespMsg.HITS: [True, False],
665 + }
666 + )
667 + session.poll()
668 +
669 + # register_lookup returns the resolved bool.
670 + assert session.register_lookup("req-1", b"hA") is True
671 + assert session.register_lookup("req-1", b"hB") is False
672 + # The entry is cached, not popped: repeat probes keep returning the
673 + # same result and never re-queue the key, so a flush sends nothing.
674 + assert session.register_lookup("req-1", b"hA") is True
675 + assert session.register_lookup("req-1", b"hB") is False
676 + sent_before = len(conn._sent)
677 + session.flush_pending_lookups()
678 + assert [
679 + m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupMsg.TYPE
680 + ] == []
681 +
682 + def test_request_blocks_clears_probe_cache(self):
683 + """A resolved HIT probe is popped when its fetch is issued, so a
684 + re-scheduled request re-probes instead of trusting the stale True
685 + (the served block is unpinned and may have been evicted)."""
686 + session, conn, _ = _make_session()
687 + _activate(session, conn)
688 +
689 + # Probe hA, flush, and let the peer resolve it to a HIT.
690 + assert session.register_lookup("req-1", b"hA") is None
691 + session.flush_pending_lookups()
692 + conn.enqueue(
693 + {
694 + TYPE_KEY: LookupRespMsg.TYPE,
695 + LookupRespMsg.KV_REQUEST_ID: "req-1",
696 + LookupRespMsg.KEYS: [b"hA"],
697 + LookupRespMsg.HITS: [True],
698 + }
699 + )
700 + session.poll()
701 + assert session.register_lookup("req-1", b"hA") is True
702 +
703 + # Fetch consumes the probe.
704 + session.request_blocks(
705 + job_id=1, kv_request_id="req-1", keys=[b"hA"], block_ids=[0]
706 + )
707 + assert b"hA" not in session._client._requests["req-1"].probes
708 +
709 + # Re-scheduled probe of the same key is treated as brand-new: it
710 + # returns None and re-queues, so a flush emits a fresh LookupMsg.
711 + assert session.register_lookup("req-1", b"hA") is None
712 + sent_before = len(conn._sent)
713 + session.flush_pending_lookups()
714 + fresh = [m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupMsg.TYPE]
715 + assert len(fresh) == 1
716 + assert fresh[0][LookupMsg.KEYS] == [b"hA"]
717 +
718 + def test_flush_uses_work_list_not_full_scan(self):
719 + """flush drains a work-list rather than scanning every live request.
720 +
721 + A request with no newly-registered keys is not revisited: after a
722 + flush the work-list is empty, an idle re-flush sends nothing, and a
723 + subsequent register re-arms exactly the one affected id — even while
724 + an unrelated request stays live in ``_requests``.
725 + """
726 + session, conn, _ = _make_session()
727 + _activate(session, conn)
728 + client = session._client
729 +
730 + # Two requests register keys; both are queued for flush.
731 + session.register_lookup("req-A", b"hA")
732 + session.register_lookup("req-B", b"hB")
733 + assert client._flush_pending == {"req-A", "req-B"}
734 +
735 + # Flush drains the work-list even though both requests stay live.
736 + session.flush_pending_lookups()
737 + assert client._flush_pending == set()
738 + assert set(client._requests) == {"req-A", "req-B"}
739 +
740 + # An idle re-flush visits nothing and sends no LookupMsg.
741 + sent_before = len(conn._sent)
742 + session.flush_pending_lookups()
743 + assert [
744 + m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupMsg.TYPE
745 + ] == []
746 +
747 + # A new register re-arms only that id.
748 + session.register_lookup("req-A", b"hA2")
749 + assert client._flush_pending == {"req-A"}
750 +
751 + def test_separate_lookup_msg_per_kv_request_id(self):
752 + """Hashes for different kv_request_ids flush as separate LookupMsgs."""
753 + session, conn, _ = _make_session()
754 + _activate(session, conn)
755 +
756 + session.register_lookup("req-A", b"h1")
757 + session.register_lookup("req-B", b"h2")
758 + session.register_lookup("req-A", b"h3")
759 +
760 + sent_before = len(conn._sent)
761 + session.flush_pending_lookups()
762 + sent = [m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupMsg.TYPE]
763 + assert len(sent) == 2
764 + by_req = {m[LookupMsg.KV_REQUEST_ID]: m[LookupMsg.KEYS] for m in sent}
765 + assert sorted(by_req["req-A"]) == [b"h1", b"h3"]
766 + assert by_req["req-B"] == [b"h2"]
767 +
768 + def test_multiple_lookup_msgs_across_steps(self):
769 + """A request's block set may be discovered across scheduler steps:
770 + each step that registers new keys flushes its own LookupMsg for
771 + the same kv_request_id, carrying only the newly-probed keys."""
772 + session, conn, _ = _make_session()
773 + _activate(session, conn)
774 +
775 + # Step 1: probe hA, hB.
776 + session.register_lookup("req-1", b"hA")
777 + session.register_lookup("req-1", b"hB")
778 + sent_before = len(conn._sent)
779 + session.flush_pending_lookups()
780 + first = [m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupMsg.TYPE]
781 + assert len(first) == 1
782 + assert first[0][LookupMsg.KV_REQUEST_ID] == "req-1"
783 + assert sorted(first[0][LookupMsg.KEYS]) == [b"hA", b"hB"]
784 +
785 + # Step 2: a new key is discovered for the same request. The
786 + # in-flight keys from step 1 are not re-sent; a second LookupMsg
787 + # goes out carrying only the newly-probed key.
788 + assert session.register_lookup("req-1", b"hA") is None # in-flight no-op
789 + session.register_lookup("req-1", b"hC")
790 + sent_before = len(conn._sent)
791 + session.flush_pending_lookups()
792 + second = [m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupMsg.TYPE]
793 + assert len(second) == 1
794 + assert second[0][LookupMsg.KV_REQUEST_ID] == "req-1"
795 + assert second[0][LookupMsg.KEYS] == [b"hC"]
796 +
797 + def test_split_response_resolves_across_messages(self):
798 + """Producer may answer one LookupMsg's keys across multiple
799 + LookupRespMsgs — pairs are self-describing so each lands."""
800 + session, conn, _ = _make_session()
801 + _activate(session, conn)
802 +
803 + session.register_lookup("req-1", b"hA")
804 + session.register_lookup("req-1", b"hB")
805 + session.flush_pending_lookups()
806 +
807 + # Two responses, each carrying one of the two keys.
808 + conn.enqueue(
809 + {
810 + TYPE_KEY: LookupRespMsg.TYPE,
811 + LookupRespMsg.KV_REQUEST_ID: "req-1",
812 + LookupRespMsg.KEYS: [b"hA"],
813 + LookupRespMsg.HITS: [True],
814 + }
815 + )
816 + conn.enqueue(
817 + {
818 + TYPE_KEY: LookupRespMsg.TYPE,
819 + LookupRespMsg.KV_REQUEST_ID: "req-1",
820 + LookupRespMsg.KEYS: [b"hB"],
821 + LookupRespMsg.HITS: [False],
822 + }
823 + )
824 + session.poll()
825 +
826 + assert session.register_lookup("req-1", b"hA") is True
827 + assert session.register_lookup("req-1", b"hB") is False
828 +
829 + def test_finish_request_cancels_pending_lookups(self):
830 + """finish_request drops every pending lookup for the kv_request_id."""
831 + session, conn, _ = _make_session()
832 + _activate(session, conn)
833 +
834 + session.register_lookup("req-1", b"hA")
835 + session.register_lookup("req-1", b"hB")
836 + session.register_lookup("req-2", b"hC")
837 + session.finish_request("req-1")
838 +
839 + # req-1 entries gone, req-2 untouched.
840 + assert "req-1" not in session._client._requests
841 + assert b"hC" in session._client._requests["req-2"].probes
842 +
843 + def test_finish_after_flushed_lookup_sends_empty_fetch(self):
844 + """LookupMsg flushed but no FetchMsg sent (all-miss case) →
845 + finish_request emits an empty FetchMsg so the peer can drop its
846 + lookup state and call parent.on_request_finished."""
847 + session, conn, _ = _make_session()
848 + _activate(session, conn)
849 +
850 + session.register_lookup("req-1", b"hA")
851 + session.flush_pending_lookups()
852 + sent_before = len(conn._sent)
853 +
854 + session.finish_request("req-1")
855 +
856 + fetches = [m for m in conn._sent[sent_before:] if m[TYPE_KEY] == FetchMsg.TYPE]
857 + assert len(fetches) == 1
858 + assert fetches[0][FetchMsg.KV_REQUEST_ID] == "req-1"
859 + assert fetches[0][FetchMsg.KEYS] == []
860 + assert fetches[0][FetchMsg.BLOCK_INDEXES] == []
861 +
862 + def test_finish_without_flushed_lookup_sends_no_fetch(self):
863 + """No LookupMsg was ever sent → finish_request must not emit an
864 + empty FetchMsg (the peer has no state to release)."""
865 + session, conn, _ = _make_session()
866 + _activate(session, conn)
867 +
868 + # Register but never flush.
869 + session.register_lookup("req-1", b"hA")
870 + sent_before = len(conn._sent)
871 +
872 + session.finish_request("req-1")
873 +
874 + fetches = [m for m in conn._sent[sent_before:] if m[TYPE_KEY] == FetchMsg.TYPE]
875 + assert fetches == []
876 +
877 + def test_finish_after_real_fetch_sends_no_second_fetch(self):
878 + """A real FetchMsg was already sent for the id → finish_request
879 + must not emit a second (empty) FetchMsg."""
880 + session, conn, _ = _make_session()
881 + _activate(session, conn)
882 +
883 + session.register_lookup("req-1", b"hA")
884 + session.flush_pending_lookups()
885 + # Resolve the probe to a HIT before fetching, as the manager only
886 + # loads confirmed hits (an unresolved probe yields RETRY).
887 + conn.enqueue(
888 + {
889 + TYPE_KEY: LookupRespMsg.TYPE,
890 + LookupRespMsg.KV_REQUEST_ID: "req-1",
891 + LookupRespMsg.KEYS: [b"hA"],
892 + LookupRespMsg.HITS: [True],
893 + }
894 + )
895 + session.poll()
896 + session.request_blocks(
897 + job_id=1, kv_request_id="req-1", keys=[b"hA"], block_ids=[7]
898 + )
899 + sent_before = len(conn._sent)
900 +
901 + session.finish_request("req-1")
902 +
903 + fetches = [m for m in conn._sent[sent_before:] if m[TYPE_KEY] == FetchMsg.TYPE]
904 + assert fetches == []
905 +
906 + def test_server_lookup_deferred_until_serve_then_all_misses(self):
907 + """``poll()`` only enqueues an inbound LookupMsg — no response is
908 + sent until ``serve_external_requests``. With an all-miss parent
909 + the aggregated LookupRespMsg carries the same keys and
910 + ``hits=[False, ...]``."""
911 + session, conn, _ = _make_session()
912 + _activate(session, conn)
913 +
914 + sent_before = len(conn._sent)
915 + conn.enqueue(
916 + {
917 + TYPE_KEY: LookupMsg.TYPE,
918 + LookupMsg.KV_REQUEST_ID: "req-1",
919 + LookupMsg.KEYS: [b"hX", b"hY", b"hZ"],
920 + }
921 + )
922 + session.poll()
923 +
924 + # Dispatch alone must not answer — the parent handle is only valid
925 + # during serve_external_requests.
926 + assert [
927 + m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupRespMsg.TYPE
928 + ] == []
929 +
930 + _serve(session, FakeParent())
931 +
932 + resps = [
933 + m for m in conn._sent[sent_before:] if m[TYPE_KEY] == LookupRespMsg.TYPE
934 + ]
935 + assert len(resps) == 1
936 + resp = resps[0]
937 + assert resp[LookupRespMsg.KV_REQUEST_ID] == "req-1"
938 + assert resp[LookupRespMsg.KEYS] == [b"hX", b"hY", b"hZ"]
939 + assert resp[LookupRespMsg.HITS] == [False, False, False]
940 +
941 +
942 +# ---------------------------------------------------------------------------
943 +# Server-side handling of inbound LookupMsg (ParentManager-driven)
944 +#
945 +# poll() only enqueues the LookupMsg; serve_external_requests(parent)
946 +# resolves it. Tests follow the poll() → _serve() pattern.
947 +# ---------------------------------------------------------------------------
948 +
949 +
950 +def _send_lookup(conn: FakeConnection, kv_request_id: str, keys: list[bytes]):
951 + conn.enqueue(
952 + {
953 + TYPE_KEY: LookupMsg.TYPE,
954 + LookupMsg.KV_REQUEST_ID: kv_request_id,
955 + LookupMsg.KEYS: list(keys),
956 + }
957 + )
958 +
959 +
960 +def _lookup_resps(conn: FakeConnection, since: int = 0) -> list[dict]:
961 + return [m for m in conn._sent[since:] if m[TYPE_KEY] == LookupRespMsg.TYPE]
962 +
963 +
964 +class TestServerLookupHandling:
965 + def test_immediate_hits_create_one_store_job(self):
966 + """All-HIT batch: one create_store_job call with all keys, one
967 + LookupRespMsg with hits=[True]*N, on_request_finished fires at the
968 + end of serve, and `available` is populated for the eventual fetch."""
969 + cb = FakeParent(stored={b"hA": 1, b"hB": 2, b"hC": 3})
970 + session, conn, _ = _make_session()
971 + _activate(session, conn)
972 +
973 + sent_before = len(conn._sent)
974 + _send_lookup(conn, "req-1", [b"hA", b"hB", b"hC"])
975 + session.poll()
976 + _serve(session, cb)
977 +
978 + resps = _lookup_resps(conn, sent_before)
979 + assert len(resps) == 1
980 + assert resps[0][LookupRespMsg.KEYS] == [b"hA", b"hB", b"hC"]
981 + assert resps[0][LookupRespMsg.HITS] == [True, True, True]
982 +
983 + kinds = [c[0] for c in cb.calls]
984 + assert kinds.count("create_store_job") == 1
985 + cs = next(c for c in cb.calls if c[0] == "create_store_job")
986 + assert cs[1] == (b"hA", b"hB", b"hC")
987 + assert cb.calls[-1][0] == "on_request_finished"
988 +
989 + # Hits are pinned in outbound state for the upcoming FetchMsg match.
990 + assert set(_srv_outbound(session, "req-1").available) == {
991 + b"hA",
992 + b"hB",
993 + b"hC",
994 + }
995 +
996 + def test_all_misses_no_store_job_finish_fires(self):
997 + """All-MISS batch: no create_store_job call; one LookupRespMsg
998 + with hits=[False]*N; on_request_finished fires at end of serve."""
999 + cb = FakeParent()
1000 + session, conn, _ = _make_session()
1001 + _activate(session, conn)
1002 +
1003 + sent_before = len(conn._sent)
1004 + _send_lookup(conn, "req-1", [b"hA", b"hB"])
1005 + session.poll()
1006 + _serve(session, cb)
1007 +
1008 + resps = _lookup_resps(conn, sent_before)
1009 + assert len(resps) == 1
1010 + assert resps[0][LookupRespMsg.HITS] == [False, False]
1011 + assert all(c[0] != "create_store_job" for c in cb.calls)
1012 + assert cb.calls[-1][0] == "on_request_finished"
1013 +
1014 + def test_mixed_hit_miss_pending_defers_response_until_aggregate(self):
1015 + """HIT/MISS resolutions do not go out on first sight when any
1016 + key is still HIT_PENDING / RETRY. The lookup parks until every
1017 + key has settled (or the deadline fires), then one
1018 + LookupRespMsg carries all keys in wire order."""
1019 + cb = FakeParent(
1020 + stored={b"hA": 1},
1021 + pending={b"hB"},
1022 + retry={b"hD"},
1023 + )
1024 + session, conn, _ = _make_session()
1025 + _activate(session, conn)
1026 +
1027 + sent_before = len(conn._sent)
1028 + _send_lookup(conn, "req-1", [b"hA", b"hB", b"hC", b"hD"])
1029 + session.poll()
1030 + _serve(session, cb)
1031 +
1032 + # No LookupRespMsg yet — hB and hD are still pending.
1033 + assert _lookup_resps(conn, sent_before) == []
1034 + # HIT is still pinned immediately so the eventual FetchMsg matches.
1035 + cs_calls = [c for c in cb.calls if c[0] == "create_store_job"]
1036 + assert len(cs_calls) == 1
1037 + assert cs_calls[0][1] == (b"hA",)
1038 + # Lookup is parked; on_request_finished not yet called.
1039 + assert all(c[0] != "on_request_finished" for c in cb.calls)
1040 + assert len(_srv_lookups(session)) == 1
1041 +
1042 + def test_pending_resolves_then_aggregate_response_fires(self):
1043 + """A HIT_PENDING key that becomes HIT on a later poll releases
1044 + the deferred aggregate response: one LookupRespMsg carrying
1045 + both keys in wire order, and one create_store_job call per
1046 + HIT (the second HIT is pinned when it resolves, not when the
1047 + response goes out)."""
1048 + cb = FakeParent(stored={b"hA": 1}, pending={b"hB"})
1049 + session, conn, _ = _make_session()
1050 + _activate(session, conn)
1051 +
1052 + sent_before = len(conn._sent)
1053 + _send_lookup(conn, "req-1", [b"hA", b"hB"])
1054 + session.poll()
1055 + _serve(session, cb)
1056 + # No response yet — hB still pending.
1057 + assert _lookup_resps(conn, sent_before) == []
1058 +
1059 + # Promote hB.
1060 + cb.pending.discard(b"hB")
1061 + cb.stored[b"hB"] = 2
1062 +
1063 + # Drive resolver via a second serve_external_requests.
1064 + _serve(session, cb)
1065 +
1066 + resps = _lookup_resps(conn, sent_before)
1067 + assert len(resps) == 1
1068 + assert resps[0][LookupRespMsg.KEYS] == [b"hA", b"hB"]
1069 + assert resps[0][LookupRespMsg.HITS] == [True, True]
1070 +
1071 + cs_calls = [c for c in cb.calls if c[0] == "create_store_job"]
1072 + assert len(cs_calls) == 2
1073 + assert cs_calls[0][1] == (b"hA",)
1074 + assert cs_calls[1][1] == (b"hB",)
1075 + # on_request_finished fires once after the aggregate resolve.
1076 + assert sum(1 for c in cb.calls if c[0] == "on_request_finished") == 1
1077 + assert b"hA" in _srv_outbound(session, "req-1").available
1078 + assert b"hB" in _srv_outbound(session, "req-1").available
1079 +
1080 + def test_pending_timeout_replies_miss_no_store_job(self):
1081 + """A HIT_PENDING key that stays pending past the batch
1082 + ``deadline`` is force-MISS and never pinned; the deferred
1083 + aggregate response fires with hits=[False]."""
1084 + cb = FakeParent(pending={b"hA"})
1085 + session, conn, _ = _make_session()
1086 + _activate(session, conn)
1087 +
1088 + sent_before = len(conn._sent)
1089 + _send_lookup(conn, "req-1", [b"hA"])
1090 + session.poll()
1091 + _serve(session, cb)
1092 + # Initial serve: nothing immediate, lookup parked, no LookupRespMsg.
1093 + assert _lookup_resps(conn, sent_before) == []
1094 +
1095 + # Forge the deadline into the past to trigger the timeout branch.
1096 + lookup = _srv_lookups(session)[0]
1097 + lookup.deadline = time.monotonic() - 0.1
1098 +
1099 + _serve(session, cb)
1100 +
1101 + resps = _lookup_resps(conn, sent_before)
1102 + assert len(resps) == 1
1103 + assert resps[0][LookupRespMsg.KEYS] == [b"hA"]
1104 + assert resps[0][LookupRespMsg.HITS] == [False]
1105 + assert all(c[0] != "create_store_job" for c in cb.calls)
1106 + assert sum(1 for c in cb.calls if c[0] == "on_request_finished") == 1
1107 +
1108 + def test_finish_request_called_per_lookup_msg_not_per_kv_request_id(self):
1109 + """Two LookupMsgs for the same kv_request_id get distinct ctxs
1110 + and two on_request_finished calls (one per batch)."""
1111 + cb = FakeParent(stored={b"hA": 1, b"hB": 2})
1112 + session, conn, _ = _make_session()
1113 + _activate(session, conn)
1114 +
1115 + _send_lookup(conn, "req-1", [b"hA"])
1116 + session.poll()
1117 + _serve(session, cb)
1118 + _send_lookup(conn, "req-1", [b"hB"])
1119 + session.poll()
1120 + _serve(session, cb)
1121 +
1122 + finish_calls = [c for c in cb.calls if c[0] == "on_request_finished"]
1123 + assert len(finish_calls) == 2
1124 + # Distinct synthetic req_ids
1125 + assert finish_calls[0][1] != finish_calls[1][1]
1126 + # Both namespaced under the same kv_request_id
1127 + assert ":req-1:" in finish_calls[0][1]
1128 + assert ":req-1:" in finish_calls[1][1]
1129 +
1130 + def test_close_returns_open_batch_ctxs_as_failed_serves(self):
1131 + """Tearing the session down with a parked batch returns the
1132 + synthetic ctx as a failed serve (no parent handle at teardown) so
1133 + the manager can release the TieringManager's state on its next
1134 + serve."""
1135 + cb = FakeParent(pending={b"hA"})
1136 + session, conn, _ = _make_session()
1137 + _activate(session, conn)
1138 +
1139 + _send_lookup(conn, "req-1", [b"hA"])
1140 + session.poll()
1141 + _serve(session, cb)
1142 + assert len(_srv_lookups(session)) == 1
1143 + assert all(c[0] != "on_request_finished" for c in cb.calls)
1144 +
1145 + result = session.close()
1146 +
1147 + assert len(result.failed_serves) == 1
1148 + assert ":req-1:" in result.failed_serves[0].req_id
1149 + # close() itself must not call the parent.
1150 + assert all(c[0] != "on_request_finished" for c in cb.calls)
1151 +
1152 + def test_wire_finish_drops_pending_batches_for_kv_request_id(self):
1153 + """``ServerRole.finish(kv_request_id)`` drops every parked batch
1154 + whose kv_request_id matches and queues its ctx for the next
1155 + serve's on_request_finished."""
1156 + cb = FakeParent(pending={b"hA", b"hB"})
1157 + session, conn, _ = _make_session()
1158 + _activate(session, conn)
1159 +
1160 + _send_lookup(conn, "req-1", [b"hA"])
1161 + session.poll()
1162 + _serve(session, cb)
1163 + _send_lookup(conn, "req-2", [b"hB"])
1164 + session.poll()
1165 + _serve(session, cb)
1166 + assert len(_srv_lookups(session)) == 2
1167 +
1168 + session._server.finish("req-1")
1169 +
1170 + # req-1 batch dropped from parked lookups; its ctx queued for release.
1171 + remaining_kv_request_ids = {b.kv_request_id for b in _srv_lookups(session)}
1172 + assert remaining_kv_request_ids == {"req-2"}
1173 + queued = session._server._finished_lookup_ctxs
1174 + assert len(queued) == 1
1175 + assert ":req-1:" in queued[0].req_id
1176 +
1177 + # The next serve fires on_request_finished exactly once for req-1.
1178 + _serve(session, cb)
1179 + finish_calls = [c for c in cb.calls if c[0] == "on_request_finished"]
1180 + assert len(finish_calls) == 1
1181 + assert ":req-1:" in finish_calls[0][1]
1182 +
1183 + def test_incoming_fetch_drops_pending_lookups_for_kv_request_id(self):
1184 + """A peer FetchMsg terminates the lookup phase for its id: parked
1185 + lookups with matching kv_request_id are dropped and their
1186 + ctx queued for on_request_finished; other kv_request_ids untouched."""
1187 + cb = FakeParent(pending={b"hA", b"hB"})
1188 + session, conn, _ = _make_session()
1189 + _activate(session, conn)
1190 +
1191 + _send_lookup(conn, "req-1", [b"hA"])
1192 + session.poll()
1193 + _serve(session, cb)
1194 + _send_lookup(conn, "req-2", [b"hB"])
1195 + session.poll()
1196 + _serve(session, cb)
1197 + assert len(_srv_lookups(session)) == 2
1198 +
1199 + # Empty FetchMsg: peer signals "lookup phase done" without asking
1200 + # for any blocks (the all-miss case).
1201 + conn.enqueue(
1202 + {
1203 + TYPE_KEY: FetchMsg.TYPE,
1204 + FetchMsg.KV_REQUEST_ID: "req-1",
1205 + FetchMsg.KEYS: [],
1206 + FetchMsg.BLOCK_INDEXES: [],
1207 + }
1208 + )
1209 + session.poll()
1210 +
1211 + remaining_kv_request_ids = {lu.kv_request_id for lu in _srv_lookups(session)}
1212 + assert remaining_kv_request_ids == {"req-2"}
1213 + # Dispatch queues the ctx but does not call the parent yet.
1214 + queued = session._server._finished_lookup_ctxs
1215 + assert len(queued) == 1
1216 + assert ":req-1:" in queued[0].req_id
1217 +
1218 + # The next serve fires on_request_finished exactly once for req-1.
1219 + _serve(session, cb)
1220 + finish_calls = [c for c in cb.calls if c[0] == "on_request_finished"]
1221 + assert len(finish_calls) == 1
1222 + assert ":req-1:" in finish_calls[0][1]
1223 +
1224 + def test_lookup_then_fetch_round_trip_emits_store_result(self):
1225 + """End-to-end: lookup pins primary slots → fetch matches them →
1226 + NIXL transfer completes → StoreResult surfaces with the
1227 + create_store_job's job_id (the engine releases the pin)."""
1228 + cb = FakeParent(stored={b"hA": 7, b"hB": 8})
1229 + session, conn, transport = _make_session()
1230 + _activate(session, conn)
1231 +
1232 + _send_lookup(conn, "req-1", [b"hA", b"hB"])
1233 + session.poll()
1234 + _serve(session, cb)
1235 + cs = next(c for c in cb.calls if c[0] == "create_store_job")
1236 + # FakeParent issues monotonic job_ids starting at 1000.
1237 + expected_job_id = 1000
1238 +
1239 + # Consumer issues FetchMsg on the resolved hits.
1240 + conn.enqueue(
1241 + {
1242 + TYPE_KEY: FetchMsg.TYPE,
1243 + FetchMsg.KV_REQUEST_ID: "req-1",
1244 + FetchMsg.KEYS: [b"hA", b"hB"],
1245 + FetchMsg.BLOCK_INDEXES: [20, 21],
1246 + }
1247 + )
1248 + session.poll()
1249 +
1250 + # NIXL write_blocks called with our pinned local block_ids.
1251 + assert len(transport._transfers) == 1
1252 + _, (_peer, local, remote) = next(iter(transport._transfers.items()))
1253 + assert local == [7, 8]
1254 + assert remote == [20, 21]
1255 +
1256 + # Drive the transport completion.
1257 + transport._poll_done.append(0)
1258 + result = session.poll()
1259 +
1260 + store_results = [s for s in result.stores if s.success]
1261 + assert any(s.job_id == expected_job_id for s in store_results)
1262 + # Sanity: kv mention in synthetic ctx.
1263 + assert cs[2].startswith("p2p:")
1264
1265
1266 # ---------------------------------------------------------------------------
@@ -449,7 +1278,7 @@ class TestServerFlows:
1278 {
1279 TYPE_KEY: FetchMsg.TYPE,
1280 FetchMsg.KV_REQUEST_ID: "req-1",
452 - FetchMsg.BLOCK_HASHES: [b"k1", b"k2"],
1281 + FetchMsg.KEYS: [b"k1", b"k2"],
1282 FetchMsg.BLOCK_INDEXES: [10, 11],
1283 }
1284 )
@@ -467,7 +1296,7 @@ class TestServerFlows:
1296 {
1297 TYPE_KEY: FetchMsg.TYPE,
1298 FetchMsg.KV_REQUEST_ID: "req-1",
470 - FetchMsg.BLOCK_HASHES: [b"k1"],
1299 + FetchMsg.KEYS: [b"k1"],
1300 FetchMsg.BLOCK_INDEXES: [5],
1301 }
1302 )
@@ -485,7 +1314,7 @@ class TestServerFlows:
1314 {
1315 TYPE_KEY: FetchMsg.TYPE,
1316 FetchMsg.KV_REQUEST_ID: "req-1",
488 - FetchMsg.BLOCK_HASHES: [b"k1"],
1317 + FetchMsg.KEYS: [b"k1"],
1318 FetchMsg.BLOCK_INDEXES: [5],
1319 }
1320 )
@@ -508,11 +1337,11 @@ class TestServerFlows:
1337 session.poll()
1338 ack = next(m for m in conn._sent if m[TYPE_KEY] == AbortAckMsg.TYPE)
1339 assert ack[AbortAckMsg.KV_REQUEST_ID] == "req-1"
511 - assert "req-1" not in session._server._pending_aborts
1340 + assert _srv_abort_started(session, "req-1") is None
1341
1342 def test_abort_fetch_defers_ack_when_cancel_pending(self):
1343 """If cancel(mode='wait') reports still-inflight tids, the ack is
515 - deferred and the abort is parked in _pending_aborts."""
1344 + deferred and the abort is parked (abort_started_at set)."""
1345 session, conn, transport = _make_session()
1346 _activate(session, conn)
1347 # Seed an inflight transfer for req-1 that the transport pretends
@@ -533,7 +1362,7 @@ class TestServerFlows:
1362 session.poll()
1363
1364 assert not any(m[TYPE_KEY] == AbortAckMsg.TYPE for m in conn._sent)
536 - assert "req-1" in session._server._pending_aborts
1365 + assert _srv_abort_started(session, "req-1") is not None
1366 # First attempt happens inside _on_abort_fetch; the per-tick
1367 # drain runs again at the end of poll() — both are wait-mode.
1368 assert all(mode == "wait" for _, mode in transport._cancel_calls)
@@ -558,7 +1387,7 @@ class TestServerFlows:
1387 }
1388 )
1389 session.poll()
561 - assert "req-1" in session._server._pending_aborts
1390 + assert _srv_abort_started(session, "req-1") is not None
1391
1392 # Backend finishes draining: transport.poll() will return tid as
1393 # DONE, and the next cancel(mode='wait') call sees it's gone.
@@ -569,7 +1398,7 @@ class TestServerFlows:
1398
1399 ack = next(m for m in conn._sent if m[TYPE_KEY] == AbortAckMsg.TYPE)
1400 assert ack[AbortAckMsg.KV_REQUEST_ID] == "req-1"
572 - assert "req-1" not in session._server._pending_aborts
1401 + assert _srv_abort_started(session, "req-1") is None
1402 assert tid not in session._server._inflight
1403
1404 def test_abort_fetch_force_cancels_after_timeout(self):
@@ -591,9 +1420,9 @@ class TestServerFlows:
1420 }
1421 )
1422 session.poll()
594 - assert "req-1" in session._server._pending_aborts
1423 + assert _srv_abort_started(session, "req-1") is not None
1424 # Backdate past the drain deadline.
596 - session._server._pending_aborts["req-1"] = (
1425 + session._server._requests["req-1"].abort_started_at = (
1426 time.monotonic() - _CANCEL_DRAIN_TIMEOUT_S - 1.0
1427 )
1428 # Even if the transport still claims it can't cancel, the
@@ -604,7 +1433,7 @@ class TestServerFlows:
1433
1434 ack = next(m for m in conn._sent if m[TYPE_KEY] == AbortAckMsg.TYPE)
1435 assert ack[AbortAckMsg.KV_REQUEST_ID] == "req-1"
607 - assert "req-1" not in session._server._pending_aborts
1436 + assert _srv_abort_started(session, "req-1") is None
1437 assert tid not in session._server._inflight
1438 assert ([tid], "immediate") in transport._cancel_calls
1439
@@ -627,7 +1456,7 @@ class TestServerFlows:
1456 }
1457 )
1458 session.poll()
630 - first_started_at = session._server._pending_aborts["req-1"]
1459 + first_started_at = _srv_abort_started(session, "req-1")
1460
1461 # Second AbortFetchMsg for the same kv_request_id while still
1462 # draining must not reset the deadline.
@@ -638,7 +1467,7 @@ class TestServerFlows:
1467 }
1468 )
1469 session.poll()
641 - assert session._server._pending_aborts["req-1"] == first_started_at
1470 + assert _srv_abort_started(session, "req-1") == first_started_at
1471
1472 # Now let the drain succeed and confirm exactly one ack ever.
1473 transport._cancel_still_inflight.discard(tid)
@@ -669,7 +1498,7 @@ class TestServerFlows:
1498 {
1499 TYPE_KEY: FetchMsg.TYPE,
1500 FetchMsg.KV_REQUEST_ID: "req-1",
672 - FetchMsg.BLOCK_HASHES: [b"k1"],
1501 + FetchMsg.KEYS: [b"k1"],
1502 FetchMsg.BLOCK_INDEXES: [5],
1503 }
1504 )
@@ -701,7 +1530,7 @@ class TestServerFlows:
1530 {
1531 TYPE_KEY: FetchMsg.TYPE,
1532 FetchMsg.KV_REQUEST_ID: "req-1",
704 - FetchMsg.BLOCK_HASHES: [b"k1"],
1533 + FetchMsg.KEYS: [b"k1"],
1534 FetchMsg.BLOCK_INDEXES: [5],
1535 }
1536 )
@@ -743,12 +1572,12 @@ class TestFinishRequestServerSide:
1572 {
1573 TYPE_KEY: FetchMsg.TYPE,
1574 FetchMsg.KV_REQUEST_ID: "req-1",
746 - FetchMsg.BLOCK_HASHES: [b"k1"],
1575 + FetchMsg.KEYS: [b"k1"],
1576 FetchMsg.BLOCK_INDEXES: [5],
1577 }
1578 )
1579 session.poll()
751 - assert "req-1" in session._server._outbound
1580 + assert _srv_outbound(session, "req-1") is not None
1581
1582 session.finish_request("req-1")
1583
@@ -756,7 +1585,7 @@ class TestFinishRequestServerSide:
1585 assert msg is not None
1586 assert msg[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
1587 assert msg[TransferDoneMsg.SUCCESS] is False
759 - assert "req-1" not in session._server._outbound
1588 + assert _srv_outbound(session, "req-1") is None
1589
1590 def test_with_inflight_defers_then_fires_on_last_transfer(self):
1591 """finish_request with inflight defers; last transfer fires the
@@ -768,7 +1597,7 @@ class TestFinishRequestServerSide:
1597 {
1598 TYPE_KEY: FetchMsg.TYPE,
1599 FetchMsg.KV_REQUEST_ID: "req-1",
771 - FetchMsg.BLOCK_HASHES: [b"k1", b"k2"],
1600 + FetchMsg.KEYS: [b"k1", b"k2"],
1601 FetchMsg.BLOCK_INDEXES: [10, 11],
1602 }
1603 )
@@ -780,8 +1609,8 @@ class TestFinishRequestServerSide:
1609 before = len(conn._sent)
1610 session.finish_request("req-1")
1611 assert len(conn._sent) == before
783 - assert "req-1" in session._server._outbound
784 - assert session._server._outbound["req-1"].finishing
1612 + assert _srv_outbound(session, "req-1") is not None
1613 + assert _srv_outbound(session, "req-1").finishing
1614
1615 # Last inflight settles -> early-fail fires.
1616 tid = next(iter(transport._transfers))
@@ -792,7 +1621,7 @@ class TestFinishRequestServerSide:
1621 assert msg is not None
1622 assert msg[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
1623 assert msg[TransferDoneMsg.SUCCESS] is False
795 - assert "req-1" not in session._server._outbound
1624 + assert _srv_outbound(session, "req-1") is None
1625
1626 def test_full_demand_satisfied_still_sends_success(self):
1627 """finish_request must not override a fully-satisfied transfer:
@@ -803,7 +1632,7 @@ class TestFinishRequestServerSide:
1632 {
1633 TYPE_KEY: FetchMsg.TYPE,
1634 FetchMsg.KV_REQUEST_ID: "req-1",
806 - FetchMsg.BLOCK_HASHES: [b"k1"],
1635 + FetchMsg.KEYS: [b"k1"],
1636 FetchMsg.BLOCK_INDEXES: [10],
1637 }
1638 )
@@ -831,13 +1660,13 @@ class TestFinishRequestServerSide:
1660 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
1661 # finish_request first — no demand received yet -> defer.
1662 session.finish_request("req-1")
834 - assert "req-1" in session._server._outbound
1663 + assert _srv_outbound(session, "req-1") is not None
1664 # Fetch arrives now: demand fully satisfied by available.
1665 conn.enqueue(
1666 {
1667 TYPE_KEY: FetchMsg.TYPE,
1668 FetchMsg.KV_REQUEST_ID: "req-1",
840 - FetchMsg.BLOCK_HASHES: [b"k1"],
1669 + FetchMsg.KEYS: [b"k1"],
1670 FetchMsg.BLOCK_INDEXES: [10],
1671 }
1672 )
@@ -858,7 +1687,7 @@ class TestFinishRequestServerSide:
1687 {
1688 TYPE_KEY: FetchMsg.TYPE,
1689 FetchMsg.KV_REQUEST_ID: "req-2",
861 - FetchMsg.BLOCK_HASHES: [b"k1", b"k2"],
1690 + FetchMsg.KEYS: [b"k1", b"k2"],
1691 FetchMsg.BLOCK_INDEXES: [10, 11],
1692 }
1693 )
@@ -870,7 +1699,7 @@ class TestFinishRequestServerSide:
1699 session.poll()
1700 msg = next(m for m in conn._sent if m[TYPE_KEY] == TransferDoneMsg.TYPE)
1701 assert msg[TransferDoneMsg.SUCCESS] is False
873 - assert "req-2" not in session._server._outbound
1702 + assert _srv_outbound(session, "req-2") is None
1703
1704 def test_unknown_request_is_noop(self):
1705 session, conn, _ = _make_session()
@@ -891,7 +1720,7 @@ class TestFinishRequestServerSide:
1720 {
1721 TYPE_KEY: FetchMsg.TYPE,
1722 FetchMsg.KV_REQUEST_ID: "req-1",
894 - FetchMsg.BLOCK_HASHES: [b"demand"],
1723 + FetchMsg.KEYS: [b"demand"],
1724 FetchMsg.BLOCK_INDEXES: [5],
1725 }
1726 )
@@ -900,7 +1729,7 @@ class TestFinishRequestServerSide:
1729 # matches demand. Without the shortcut, job 42 sits in _store_jobs
1730 # for _STORE_TIMEOUT_S.
1731 session.add_stored_blocks("req-1", [b"unrelated"], [0], job_id=42)
903 - assert session._server._outbound["req-1"].pending_job_ids == {42}
1732 + assert _srv_outbound(session, "req-1").pending_job_ids == {42}
1733
1734 session.finish_request("req-1")
1735
@@ -908,7 +1737,7 @@ class TestFinishRequestServerSide:
1737 msg = self._last_transfer_done(conn)
1738 assert msg is not None
1739 assert msg[TransferDoneMsg.SUCCESS] is False
911 - assert "req-1" not in session._server._outbound
1740 + assert _srv_outbound(session, "req-1") is None
1741
1742 # Local store job surfaces on the next poll, success=False.
1743 stores = session.poll().stores
@@ -925,7 +1754,7 @@ class TestFinishRequestServerSide:
1754 {
1755 TYPE_KEY: FetchMsg.TYPE,
1756 FetchMsg.KV_REQUEST_ID: "req-1",
928 - FetchMsg.BLOCK_HASHES: [b"k1"],
1757 + FetchMsg.KEYS: [b"k1"],
1758 FetchMsg.BLOCK_INDEXES: [10],
1759 }
1760 )
@@ -933,7 +1762,7 @@ class TestFinishRequestServerSide:
1762 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=7)
1763 # finish_request races with the inflight transfer.
1764 session.finish_request("req-1")
936 - assert "req-1" in session._server._outbound # deferred
1765 + assert _srv_outbound(session, "req-1") is not None # deferred
1766
1767 # Last inflight completes -> _finalize_outbound(success=True) fires.
1768 tid = next(iter(transport._transfers))
@@ -944,7 +1773,7 @@ class TestFinishRequestServerSide:
1773 assert msg is not None
1774 assert msg[TransferDoneMsg.SUCCESS] is True
1775 assert StoreResult(job_id=7, success=True) in stores
947 - assert "req-1" not in session._server._outbound
1776 + assert _srv_outbound(session, "req-1") is None
1777
1778 def test_write_blocks_failure_finalizes_with_failure(self):
1779 """write_blocks returning None must not leave the request hanging.
@@ -962,7 +1791,7 @@ class TestFinishRequestServerSide:
1791 {
1792 TYPE_KEY: FetchMsg.TYPE,
1793 FetchMsg.KV_REQUEST_ID: "req-1",
965 - FetchMsg.BLOCK_HASHES: [b"k1"],
1794 + FetchMsg.KEYS: [b"k1"],
1795 FetchMsg.BLOCK_INDEXES: [10],
1796 }
1797 )
@@ -973,7 +1802,7 @@ class TestFinishRequestServerSide:
1802 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=42)
1803
1804 # Outbound was finalized immediately (no other inflight).
976 - assert "req-1" not in session._server._outbound
1805 + assert _srv_outbound(session, "req-1") is None
1806 # Peer notified with success=False.
1807 msg = next(m for m in conn._sent if m[TYPE_KEY] == TransferDoneMsg.TYPE)
1808 assert msg[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
@@ -996,14 +1825,14 @@ class TestFinishRequestServerSide:
1825 {
1826 TYPE_KEY: FetchMsg.TYPE,
1827 FetchMsg.KV_REQUEST_ID: "req-1",
999 - FetchMsg.BLOCK_HASHES: [b"k1", b"k2", b"k3"],
1828 + FetchMsg.KEYS: [b"k1", b"k2", b"k3"],
1829 FetchMsg.BLOCK_INDEXES: [10, 11, 12],
1830 }
1831 )
1832 session.poll()
1833 # Demand registered, no matches yet.
1834 assert session._server._inflight == {}
1006 - outbound = session._server._outbound["req-1"]
1835 + outbound = _srv_outbound(session, "req-1")
1836 assert outbound.remaining == 3
1837 assert set(outbound.demanded.keys()) == {b"k1", b"k2", b"k3"}
1838
@@ -1024,7 +1853,7 @@ class TestFinishRequestServerSide:
1853 assert session._server._inflight == {}
1854 assert outbound.remaining == 2
1855 # Not yet finalized — still 2 blocks demanded.
1027 - assert "req-1" in session._server._outbound
1856 + assert _srv_outbound(session, "req-1") is not None
1857
1858 # Round 2: k2 and k3 arrive together.
1859 session.add_stored_blocks("req-1", [b"k2", b"k3"], [1, 2], job_id=200)
@@ -1038,7 +1867,7 @@ class TestFinishRequestServerSide:
1867 stores = session.poll().stores
1868 assert StoreResult(job_id=200, success=True) in stores
1869 # _finalize_outbound fired — request gone, peer notified with success.
1041 - assert "req-1" not in session._server._outbound
1870 + assert _srv_outbound(session, "req-1") is None
1871 done = next(m for m in conn._sent if m.get(TYPE_KEY) == TransferDoneMsg.TYPE)
1872 assert done[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
1873 assert done[TransferDoneMsg.SUCCESS] is True
@@ -1057,7 +1886,7 @@ class TestFinishRequestServerSide:
1886 {
1887 TYPE_KEY: FetchMsg.TYPE,
1888 FetchMsg.KV_REQUEST_ID: "req-1",
1060 - FetchMsg.BLOCK_HASHES: [b"k1", b"k2"],
1889 + FetchMsg.KEYS: [b"k1", b"k2"],
1890 FetchMsg.BLOCK_INDEXES: [10, 11],
1891 }
1892 )
@@ -1067,7 +1896,7 @@ class TestFinishRequestServerSide:
1896 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=100)
1897 assert len(session._server._inflight) == 1
1898 tid_1 = next(iter(session._server._inflight))
1070 - outbound = session._server._outbound["req-1"]
1899 + outbound = _srv_outbound(session, "req-1")
1900 assert outbound.remaining == 2 # decrement happens on completion
1901 assert outbound.finishing is False
1902
@@ -1078,7 +1907,7 @@ class TestFinishRequestServerSide:
1907 assert list(session._server._inflight.keys()) == [tid_1]
1908 # Marked finishing, but NOT finalized yet (transfer_1 still inflight).
1909 assert outbound.finishing is True
1081 - assert "req-1" in session._server._outbound
1910 + assert _srv_outbound(session, "req-1") is not None
1911 done_msgs = [m for m in conn._sent if m.get(TYPE_KEY) == TransferDoneMsg.TYPE]
1912 assert done_msgs == []
1913
@@ -1090,7 +1919,7 @@ class TestFinishRequestServerSide:
1919 stores_first = session.poll().stores
1920 assert StoreResult(job_id=100, success=True) in stores_first
1921 # Outbound state cleaned up; peer notified with success=False.
1093 - assert "req-1" not in session._server._outbound
1922 + assert _srv_outbound(session, "req-1") is None
1923 done = next(m for m in conn._sent if m.get(TYPE_KEY) == TransferDoneMsg.TYPE)
1924 assert done[TransferDoneMsg.KV_REQUEST_ID] == "req-1"
1925 assert done[TransferDoneMsg.SUCCESS] is False
@@ -1123,7 +1952,7 @@ class TestBidirectional:
1952 {
1953 TYPE_KEY: FetchMsg.TYPE,
1954 FetchMsg.KV_REQUEST_ID: "req-srv",
1126 - FetchMsg.BLOCK_HASHES: [b"served"],
1955 + FetchMsg.KEYS: [b"served"],
1956 FetchMsg.BLOCK_INDEXES: [7],
1957 }
1958 )
@@ -1178,6 +2007,7 @@ class TestPendingSession:
2007 local_id="local:9000",
2008 transport=transport, # type: ignore[arg-type]
2009 local_block_len=4096,
2010 + local_hash_seed=_DEFAULT_HASH_SEED,
2011 conn=None,
2012 )
2013 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
@@ -1197,6 +2027,7 @@ class TestPendingSession:
2027 local_id="local:9000",
2028 transport=transport, # type: ignore[arg-type]
2029 local_block_len=4096,
2030 + local_hash_seed=_DEFAULT_HASH_SEED,
2031 conn=None,
2032 )
2033 conn = FakeConnection(peer_id="peer:8000")
@@ -1218,13 +2049,16 @@ class TestPendingSession:
2049 local_id="local:9000",
2050 transport=transport, # type: ignore[arg-type]
2051 local_block_len=4096,
2052 + local_hash_seed=_DEFAULT_HASH_SEED,
2053 conn=None,
2054 )
2055 session.add_stored_blocks("req-1", [b"k1"], [0], job_id=1)
2056 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}
2057 + result = session.close()
2058 + assert result.failed_jobs == []
2059 + assert result.failed_req_ids == []
2060 + assert set(result.failed_stores) == {1, 2}
2061 + assert result.failed_serves == []
2062
2063
2064 # ---------------------------------------------------------------------------
@@ -1246,9 +2080,50 @@ class TestDisconnect:
2080 session.request_blocks(1, "req-1", [b"k"], [0])
2081 session.request_blocks(2, "req-2", [b"k"], [0])
2082 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}
2083 + result = session.close()
2084 + assert set(result.failed_jobs) == {1, 2}
2085 + assert set(result.failed_req_ids) == {"req-1", "req-2"}
2086 + assert set(result.failed_stores) == {10}
2087 + assert result.failed_serves == []
2088 +
2089 + def test_send_failure_marks_connection_dead(self):
2090 + """A raising send must mark the connection dead, not silently drop
2091 + the message — otherwise the session lingers alive, is never reaped,
2092 + and in-flight lookups/loads toward the dead peer hang forever."""
2093 + session, conn, _ = _make_session()
2094 + _activate(session, conn)
2095 + assert session.alive
2096 +
2097 + conn.fail_send = True
2098 + # request_blocks flushes a FetchMsg synchronously via _do_send.
2099 + session.request_blocks(1, "req-1", [b"k"], [0])
2100 +
2101 + assert not session.alive
2102 +
2103 + def test_close_surfaces_inflight_lookups(self):
2104 + """close() reports kv_request_ids whose symmetric-P2P probe is still
2105 + unresolved; resolved probes are not reported (their answer is in)."""
2106 + session, conn, _ = _make_session()
2107 + _activate(session, conn)
2108 +
2109 + session.register_lookup("req-hit", b"hA")
2110 + session.register_lookup("req-inflight", b"hB")
2111 + session.flush_pending_lookups()
2112 +
2113 + # Only req-hit is answered; req-inflight stays in flight.
2114 + conn.enqueue(
2115 + {
2116 + TYPE_KEY: LookupRespMsg.TYPE,
2117 + LookupRespMsg.KV_REQUEST_ID: "req-hit",
2118 + LookupRespMsg.KEYS: [b"hA"],
2119 + LookupRespMsg.HITS: [True],
2120 + }
2121 + )
2122 + session.poll()
2123 +
2124 + result = session.close()
2125 + assert result.failed_jobs == []
2126 + assert result.failed_req_ids == ["req-inflight"]
2127
2128
2129 # ---------------------------------------------------------------------------
@@ -1294,7 +2169,7 @@ class TestAdversarial:
2169 {
2170 TYPE_KEY: FetchMsg.TYPE,
2171 FetchMsg.KV_REQUEST_ID: "req-bad",
1297 - FetchMsg.BLOCK_HASHES: [b"k1", b"k2"],
2172 + FetchMsg.KEYS: [b"k1", b"k2"],
2173 FetchMsg.BLOCK_INDEXES: [1],
2174 }
2175 )
@@ -1342,7 +2217,7 @@ class TestDispatchErrorHandling:
2217 {
2218 TYPE_KEY: FetchMsg.TYPE,
2219 FetchMsg.KV_REQUEST_ID: "req-bad",
1345 - FetchMsg.BLOCK_HASHES: [b"k1", b"k2"],
2220 + FetchMsg.KEYS: [b"k1", b"k2"],
2221 FetchMsg.BLOCK_INDEXES: [1],
2222 }
2223 )
@@ -1372,7 +2247,7 @@ class TestDispatchErrorHandling:
2247 {
2248 TYPE_KEY: FetchMsg.TYPE,
2249 FetchMsg.KV_REQUEST_ID: "req-1",
1375 - FetchMsg.BLOCK_HASHES: [b"k1"],
2250 + FetchMsg.KEYS: [b"k1"],
2251 FetchMsg.BLOCK_INDEXES: [0],
2252 }
2253 )
@@ -1395,7 +2270,7 @@ class TestDispatchErrorHandling:
2270 {
2271 TYPE_KEY: FetchMsg.TYPE,
2272 FetchMsg.KV_REQUEST_ID: "req-1",
1398 - FetchMsg.BLOCK_HASHES: [b"k1"],
2273 + FetchMsg.KEYS: [b"k1"],
2274 FetchMsg.BLOCK_INDEXES: [0],
2275 }
2276 )
@@ -1422,7 +2297,7 @@ class TestDispatchErrorHandling:
2297 {
2298 TYPE_KEY: FetchMsg.TYPE,
2299 FetchMsg.KV_REQUEST_ID: "req-1",
1425 - FetchMsg.BLOCK_HASHES: [b"k1"],
2300 + FetchMsg.KEYS: [b"k1"],
2301 FetchMsg.BLOCK_INDEXES: [0],
2302 }
2303 )
@@ -1438,10 +2313,10 @@ class TestDispatchErrorHandling:
2313
2314
2315 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.
2316 + """Per-request `inflight_tids` is the O(1) replacement for the
2317 + previous O(N) scan in `_has_inflight_for`. These tests check that
2318 + every mutation site keeps the set in sync with `_inflight` and
2319 + that the lookup is correct under high fan-out.
2320 """
2321
2322 def test_invariant_holds_through_lifecycle(self):
@@ -1452,17 +2327,14 @@ class TestInflightPerReqInvariant:
2327 _activate(session, conn)
2328
2329 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 - )
2330 + return _srv_total_inflight(session) == len(session._server._inflight)
2331
2332 assert _invariant_holds()
2333
2334 # Two requests, two blocks each, all dispatched in one batch.
2335 session.add_stored_blocks("req-A", [b"a1", b"a2"], [0, 1], job_id=10)
2336 session.add_stored_blocks("req-B", [b"b1", b"b2"], [2, 3], job_id=11)
1465 - for kv_id, hashes, indexes in (
2337 + for kv_id, keys, indexes in (
2338 ("req-A", [b"a1", b"a2"], [100, 101]),
2339 ("req-B", [b"b1", b"b2"], [102, 103]),
2340 ):
@@ -1470,7 +2342,7 @@ class TestInflightPerReqInvariant:
2342 {
2343 TYPE_KEY: FetchMsg.TYPE,
2344 FetchMsg.KV_REQUEST_ID: kv_id,
1473 - FetchMsg.BLOCK_HASHES: hashes,
2345 + FetchMsg.KEYS: keys,
2346 FetchMsg.BLOCK_INDEXES: indexes,
2347 }
2348 )
@@ -1493,7 +2365,7 @@ class TestInflightPerReqInvariant:
2365
2366 assert _invariant_holds()
2367 assert not session._server._has_inflight_for("req-A")
1496 - assert "req-A" not in session._server._inflight_per_req # entry was removed
2368 + assert _srv_inflight_count(session, "req-A") == 0 # entry drained
2369 assert session._server._has_inflight_for("req-B")
2370
2371 # Complete req-B; counter must drain to empty.
@@ -1508,7 +2380,7 @@ class TestInflightPerReqInvariant:
2380
2381 assert _invariant_holds()
2382 assert session._server._inflight == {}
1511 - assert session._server._inflight_per_req == {}
2383 + assert _srv_total_inflight(session) == 0
2384
2385 def test_has_inflight_for_correct_with_many_requests(self):
2386 """Populate many inflight xfers across many ids; lookup must
@@ -1524,9 +2396,7 @@ class TestInflightPerReqInvariant:
2396 tid,
2397 _InflightXfer(kv_request_id=kv_id, block_count=1, job_ids={tid}),
2398 )
1527 - assert sum(session._server._inflight_per_req.values()) == len(
1528 - session._server._inflight
1529 - )
2399 + assert _srv_total_inflight(session) == len(session._server._inflight)
2400 assert session._server._has_inflight_for("req-0")
2401 assert session._server._has_inflight_for("req-99")
2402 assert not session._server._has_inflight_for("req-missing")
@@ -1539,7 +2409,7 @@ class TestInflightPerReqInvariant:
2409 ]
2410 for tid in tids_50:
2411 session._server._inflight_pop(tid)
1542 - assert "req-50" not in session._server._inflight_per_req
2412 + assert _srv_inflight_count(session, "req-50") == 0
2413 assert not session._server._has_inflight_for("req-50")
2414 # Other ids unaffected.
2415 assert session._server._has_inflight_for("req-49")
@@ -1559,6 +2429,7 @@ class TestConnectMsgValidation:
2429 ConnectMsg.BASE_ADDR: 0x1000,
2430 ConnectMsg.NUM_BLOCKS: 8,
2431 ConnectMsg.BLOCK_LEN: 4096,
2432 + ConnectMsg.HASH_SEED: "0",
2433 }
2434
2435 def test_valid_message_passes(self):
@@ -1600,13 +2471,25 @@ class TestConnectMsgValidation:
2471 with pytest.raises(ValueError, match="block_len"):
2472 ConnectMsg.validate(msg)
2473
2474 + def test_missing_hash_seed(self):
2475 + msg = self._valid_msg()
2476 + del msg[ConnectMsg.HASH_SEED]
2477 + with pytest.raises(ValueError, match="hash_seed"):
2478 + ConnectMsg.validate(msg)
2479 +
2480 + def test_hash_seed_wrong_type(self):
2481 + msg = self._valid_msg()
2482 + msg[ConnectMsg.HASH_SEED] = 12345 # int, not str
2483 + with pytest.raises(ValueError, match="hash_seed"):
2484 + ConnectMsg.validate(msg)
2485 +
2486
2487 class TestFetchMsgValidation:
2488 def _valid_msg(self) -> dict:
2489 return {
2490 TYPE_KEY: FetchMsg.TYPE,
2491 FetchMsg.KV_REQUEST_ID: "req-1",
1609 - FetchMsg.BLOCK_HASHES: [b"k1", b"k2"],
2492 + FetchMsg.KEYS: [b"k1", b"k2"],
2493 FetchMsg.BLOCK_INDEXES: [0, 1],
2494 }
2495
vllm/v1/kv_offload/base.py
+15 -2
@@ -6,9 +6,9 @@ Core abstractions for KV cache offloading in vLLM v1.
6
7 from abc import ABC, abstractmethod
8 from collections.abc import Collection, Iterable, Sequence
9 -from dataclasses import dataclass
9 +from dataclasses import dataclass, field
10 from enum import Enum, auto
11 -from typing import TYPE_CHECKING, Any, NamedTuple, NewType
11 +from typing import TYPE_CHECKING, Any, NamedTuple, NewType, TypeVar
12
13 import numpy as np
14 import torch
@@ -45,10 +45,23 @@ def get_offload_group_idx(key: OffloadKey) -> int:
45 return int.from_bytes(key[-4:], "big", signed=False)
46
47
48 +_T = TypeVar("_T")
49 +
50 +
51 @dataclass
52 class ReqContext:
53 req_id: str
54 kv_transfer_params: dict[str, Any] | None = None
55 + # Per-request scratch space keyed by value type, so a tier can parse
56 + # kv_transfer_params once (in on_new_request) and read the result back
57 + # on later calls for the same request.
58 + _state: dict[type, Any] = field(default_factory=dict, repr=False, init=False)
59 +
60 + def set_state(self, val: Any) -> None:
61 + self._state[type(val)] = val
62 +
63 + def get_state(self, cls: type[_T]) -> _T | None:
64 + return self._state.get(cls)
65
66
67 class LookupResult(Enum):
vllm/v1/kv_offload/tiering/p2p/manager.py
+235 -93
@@ -8,6 +8,7 @@ Owns transports and a single bidirectional P2PSession per remote peer.
8
9 from __future__ import annotations
10
11 +import os
12 import time
13 import uuid
14 from collections.abc import Iterable, Sequence
@@ -23,12 +24,12 @@ from vllm.v1.kv_offload.base import (
24 OffloadKey,
25 ReqContext,
26 RequestOffloadingContext,
27 + ScheduleEndContext,
28 )
29 from vllm.v1.kv_offload.file_mapper import FileMapper
30 from vllm.v1.kv_offload.tiering.base import (
31 JobMetadata,
32 JobResult,
31 - ScheduleEndContext,
33 SecondaryTierManager,
34 )
35 from vllm.v1.kv_offload.tiering.p2p.control import ControlTransport, ZmqTransport
@@ -37,6 +38,7 @@ from vllm.v1.kv_offload.tiering.p2p.session import P2PSession
38
39 if TYPE_CHECKING:
40 from vllm.v1.kv_offload.base import OffloadingSpec
41 + from vllm.v1.kv_offload.tiering.base import ParentManager
42 from vllm.v1.kv_offload.tiering.p2p.control.base import ControlConnection
43
44 logger = init_logger(__name__)
@@ -59,24 +61,112 @@ _SHUTDOWN_DRAIN_TIMEOUT_S = 3.0
61 _DRAIN_SLEEP_S = 0.001
62
63
62 -def _prefill_params(kv_params: dict | None) -> dict | None:
63 - """Return the ``prefill`` sub-dict, or None if absent.
64 +def _remote_prefiller_params(kv_params: dict | None) -> dict | None:
65 + """Return the ``remote_prefiller`` sub-dict, or None if absent.
66 +
67 + Set on decoder requests to name the remote prefiller they pull from;
68 + carries kv_request_id, remote_host, remote_port.
69 + """
70 + if not kv_params:
71 + return None
72 + return kv_params.get("remote_prefiller")
73 +
74 +
75 +def _remote_decoder_params(kv_params: dict | None) -> dict | None:
76 + """Return the ``remote_decoder`` sub-dict, or None if absent.
77
65 - Set on decoder requests; carries kv_request_id, remote_host, remote_port.
78 + Set on prefiller requests to name the remote decoder they serve;
79 + carries kv_request_id.
80 """
81 if not kv_params:
82 return None
69 - return kv_params.get("prefill")
83 + return kv_params.get("remote_decoder")
84
85
72 -def _decode_params(kv_params: dict | None) -> dict | None:
73 - """Return the ``decode`` sub-dict, or None if absent.
86 +def _remote_kv_source_params(kv_params: dict | None) -> dict | None:
87 + """Return the ``remote_kv_source`` sub-dict, or None if absent.
88
75 - Set on prefiller requests; carries kv_request_id.
89 + Set on symmetric-P2P consumer requests to name the remote source they
90 + pull from; carries kv_request_id, remote_host, remote_port.
91 """
92 if not kv_params:
93 return None
79 - return kv_params.get("decode")
94 + return kv_params.get("remote_kv_source")
95 +
96 +
97 +def _peer_id_from_params(role_params: dict) -> str | None:
98 + """Build ``host:port`` peer_id from a role-scoped sub-dict, or None."""
99 + host = role_params.get("remote_host")
100 + port = role_params.get("remote_port")
101 + if host and port:
102 + return f"{host}:{port}"
103 + return None
104 +
105 +
106 +@dataclass(slots=True)
107 +class P2PSourceInfo:
108 + """Consumer side: this request fetches from a remote (prefiller or peer)."""
109 +
110 + kv_request_id: str
111 + peer_id: str
112 + do_probe: bool # False for remote_prefiller (PD), True for remote_kv_source
113 +
114 +
115 +@dataclass(slots=True)
116 +class P2PDestInfo:
117 + """Producer side: a remote fetches this request's blocks from us.
118 +
119 + ``kv_request_id`` is None when the ``remote_decoder`` block is present
120 + but malformed (no id); the block's presence still marks the request as
121 + remote-decode, so submit_store must fail rather than store locally.
122 + """
123 +
124 + kv_request_id: str | None
125 +
126 +
127 +def _parse_source(kv_params: dict | None) -> P2PSourceInfo | None:
128 + """Parse the consumer sub-dict (PD ``remote_prefiller`` or symmetric
129 + ``remote_kv_source``) into a ``P2PSourceInfo``, or None if absent/incomplete."""
130 + role = _remote_prefiller_params(kv_params)
131 + do_probe = False
132 + if role is None:
133 + role = _remote_kv_source_params(kv_params)
134 + do_probe = True
135 + if not role:
136 + return None
137 + peer_id = _peer_id_from_params(role)
138 + kv_request_id = role.get("kv_request_id")
139 + if peer_id is None or not kv_request_id:
140 + return None
141 + return P2PSourceInfo(
142 + kv_request_id=kv_request_id,
143 + peer_id=peer_id,
144 + do_probe=do_probe,
145 + )
146 +
147 +
148 +def _parse_dest(kv_params: dict | None) -> P2PDestInfo | None:
149 + """Parse the producer ``remote_decoder`` sub-dict into a ``P2PDestInfo``,
150 + or None if the block is absent (not a remote-decode request)."""
151 + role = _remote_decoder_params(kv_params)
152 + if role is None:
153 + return None
154 + return P2PDestInfo(kv_request_id=role.get("kv_request_id") or None)
155 +
156 +
157 +def _annotate_req_context(req_context: ReqContext) -> None:
158 + """Parse kv_transfer_params once and cache the P2P routing state.
159 +
160 + Called from ``on_new_request``; later calls for the same request read
161 + the cached ``P2PSourceInfo``/``P2PDestInfo`` via ``get_state`` instead
162 + of re-parsing.
163 + """
164 + source = _parse_source(req_context.kv_transfer_params)
165 + if source is not None:
166 + req_context.set_state(source)
167 + dest = _parse_dest(req_context.kv_transfer_params)
168 + if dest is not None:
169 + req_context.set_state(dest)
170
171
172 @dataclass
@@ -157,6 +247,20 @@ class P2PSecondaryTierManager(SecondaryTierManager):
247 **kwargs: Reserved for future tier-specific options.
248 """
249 super().__init__(offloading_spec, primary_kv_view, tier_type)
250 + # Block hashes chain from NONE_HASH, seeded from PYTHONHASHSEED
251 + # (see init_none_hash in v1/core/kv_cache_utils.py). Peers with
252 + # different seeds compute different hashes for identical content, so
253 + # lookups silently miss and no KV crosses the wire. Require it here so
254 + # a misconfigured P2P instance fails at startup rather than degrading
255 + # silently; the value is also verified against each peer on handshake.
256 + hash_seed = os.getenv("PYTHONHASHSEED")
257 + if hash_seed is None:
258 + raise ValueError(
259 + "PYTHONHASHSEED must be set for P2P KV offload so that block "
260 + "hashes match across instances. Set it to a fixed value (e.g. "
261 + "PYTHONHASHSEED=0) on every P2P peer."
262 + )
263 + self._hash_seed = hash_seed
264 if host is None:
265 host = envs.VLLM_P2P_SIDE_CHANNEL_HOST
266 if port is None:
@@ -210,6 +314,12 @@ class P2PSecondaryTierManager(SecondaryTierManager):
314 # kv_request_ids that hit a transport/session failure; On load lookup()
315 # rejects them so the request falls back to local prefill.
316 self._failed_req_ids: set[str] = set()
317 + # Synthetic lookup ctxs from reaped sessions still owing a
318 + # ``parent.on_request_finished`` (the session's failed_serves). The
319 + # dead session had no parent handle at teardown; these are flushed
320 + # at the top of the next ``serve_external_requests`` where the
321 + # handle is valid.
322 + self._failed_serve_ctxs: list[ReqContext] = []
323
324 # ------------------------------------------------------------------
325 # SecondaryTierManager interface
@@ -217,66 +327,81 @@ class P2PSecondaryTierManager(SecondaryTierManager):
327
328 @override
329 def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult:
220 - prefill = _prefill_params(req_context.kv_transfer_params)
221 - if (
222 - not prefill
223 - or not prefill.get("remote_host")
224 - or not prefill.get("remote_port")
225 - or not prefill.get("kv_request_id")
226 - ):
330 + source = req_context.get_state(P2PSourceInfo)
331 + if source is None:
332 return LookupResult.MISS
228 -
229 - kv_request_id = prefill["kv_request_id"]
230 - if kv_request_id in self._failed_req_ids:
333 + if source.kv_request_id in self._failed_req_ids:
334 return LookupResult.MISS
335 +
336 + # Symmetric-P2P consumer (``remote_kv_source`` sub-dict): probe the
337 + # peer asynchronously. First call registers the (kv_request_id,
338 + # key) entry and returns RETRY; flush_pending_lookups()
339 + # in on_schedule_end batches the LookupMsg; a later step's
340 + # lookup() returns HIT/MISS once LookupRespMsg has arrived.
341 + # PD path (``remote_prefiller`` sub-dict only) keeps the eager HIT.
342 + if source.do_probe:
343 + session = self._sessions.get(source.peer_id)
344 + if session is None:
345 + return LookupResult.MISS
346 + result = session.register_lookup(source.kv_request_id, key)
347 + if result is True:
348 + return LookupResult.HIT
349 + if result is False:
350 + return LookupResult.MISS
351 + return LookupResult.RETRY
352 +
353 + # PD consumer (we are the decoder): all kv blocks should be on the
354 + # prefiller side. Return HIT immediately.
355 return LookupResult.HIT
356
357 @override
358 def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext:
236 - """Open the outbound session toward the producer if needed.
237 -
238 - On the decoder side (``prefill`` set), open a session toward the
239 - producer at remote_host:remote_port so submit_load can issue
240 - FetchMsg as soon as it fires. On the prefiller side, sessions
241 - are created when the consumer's inbound connection arrives in
242 - _accept_new_peers — submit_store no longer pre-creates anything.
359 + """Parse kv_transfer_params once and open the outbound session.
360 +
361 + Parses the P2P routing state onto ``req_context`` (cached for the
362 + later lookup/submit/finish calls). On the consumer side
363 + (``remote_prefiller`` for PD or ``remote_kv_source`` for symmetric
364 + P2P), open a session toward the producer at remote_host:remote_port
365 + so submit_load can issue FetchMsg as soon as it fires. On the
366 + prefiller side, sessions are created when the consumer's inbound
367 + connection arrives in _accept_new_peers — submit_store no longer
368 + pre-creates anything.
369 """
244 - prefill = _prefill_params(req_context.kv_transfer_params)
245 - if prefill:
246 - peer_id = self._remote_id_from_params(prefill)
247 - if peer_id:
248 - self._get_or_create_session(peer_id)
370 + _annotate_req_context(req_context)
371 + source = req_context.get_state(P2PSourceInfo)
372 + if source is not None:
373 + self._get_or_create_session(source.peer_id)
374 return RequestOffloadingContext()
375
376 @override
377 def on_request_finished(self, req_context: ReqContext) -> None:
378 """Cancels pending loads and prunes session-scoped state.
379
255 - Decoder side (``prefill`` set): looks up the session by peer_id
256 - because the producer's address is what addresses the client-role
257 - load to cancel. Prefiller side (``decode`` set): looks up via
258 - kv_request_id because peer_id is no longer carried on store-time
380 + Consumer side (``remote_prefiller`` for PD or ``remote_kv_source``
381 + for symmetric-P2P): looks up the session by peer_id because the
382 + producer's address is what addresses the client-role load to
383 + cancel; also drops any pending symmetric-P2P lookup state via
384 + ``session.finish_request``.
385 + Prefiller side (``remote_decoder`` set): looks up via kv_request_id
386 + because peer_id is no longer carried on store-time
387 kv_transfer_params; if a session has bound the id, finish it. If
388 no session has bound the id yet, this is a no-op: parked batches
389 in `_unbound_stores` are left in place and cleaned up only by
390 `_reap_unbound_stores` after `_UNBOUND_STORE_TIMEOUT_S`.
391 """
264 - kv_params = req_context.kv_transfer_params
265 - if not kv_params:
266 - return
267 - prefill = _prefill_params(kv_params)
268 - decode = _decode_params(kv_params)
269 - kv_request_id = (prefill or decode or {}).get("kv_request_id")
392 + source = req_context.get_state(P2PSourceInfo)
393 + dest = req_context.get_state(P2PDestInfo)
394 + kv_request_id = source.kv_request_id if source is not None else None
395 + if kv_request_id is None and dest is not None:
396 + kv_request_id = dest.kv_request_id
397 if not kv_request_id:
398 return
399 self._failed_req_ids.discard(kv_request_id)
400
274 - if prefill:
275 - peer_id = self._remote_id_from_params(prefill)
276 - if peer_id:
277 - session = self._sessions.get(peer_id)
278 - if session is not None:
279 - session.finish_request(kv_request_id)
401 + if source is not None:
402 + session = self._sessions.get(source.peer_id)
403 + if session is not None:
404 + session.finish_request(kv_request_id)
405 return
406
407 # Prefiller-side finish: identify the session via kv_request_id.
@@ -293,24 +418,24 @@ class P2PSecondaryTierManager(SecondaryTierManager):
418
419 assert len(keys) == len(block_ids)
420
296 - kv_params = job_metadata.req_context.kv_transfer_params
297 - decode = _decode_params(kv_params)
421 + dest = job_metadata.req_context.get_state(P2PDestInfo)
422 logger.debug(
299 - "P2P %s: submit_store ENTRY job_id=%d blocks=%d decode=%s kv_request_id=%s",
423 + "P2P %s: submit_store ENTRY job_id=%d blocks=%d "
424 + "remote_decoder=%s kv_request_id=%s",
425 self._local_id,
426 job_id,
427 len(block_ids),
303 - decode is not None,
304 - (decode or {}).get("kv_request_id"),
428 + dest is not None,
429 + dest.kv_request_id if dest is not None else None,
430 )
306 - # Absent ``decode`` block => not a remote-decode request: succeed
307 - # locally without parking. An empty/malformed dict is still a
308 - # remote-decode signal and must fail the missing-id check below.
309 - if decode is None:
431 + # Absent ``remote_decoder`` block => not a remote-decode request:
432 + # succeed locally without parking. An empty/malformed dict is still
433 + # a remote-decode signal and must fail the missing-id check below.
434 + if dest is None:
435 self._finished_jobs.append(JobResult(job_id=job_id, success=True))
436 return
437
313 - kv_request_id = decode.get("kv_request_id")
438 + kv_request_id = dest.kv_request_id
439 if not kv_request_id:
440 logger.warning(
441 "P2P %s: submit_store missing kv_request_id",
@@ -350,32 +475,26 @@ class P2PSecondaryTierManager(SecondaryTierManager):
475 keys = list(job_metadata.keys)
476 block_ids = job_metadata.block_ids
477
353 - prefill = _prefill_params(job_metadata.req_context.kv_transfer_params)
478 + source = job_metadata.req_context.get_state(P2PSourceInfo)
479 logger.debug(
480 "P2P %s: submit_load ENTRY job_id=%d blocks=%d kv_request_id=%s peer=%s",
481 self._local_id,
482 job_id,
483 len(block_ids),
359 - (prefill or {}).get("kv_request_id"),
360 - self._remote_id_from_params(prefill or {}),
484 + source.kv_request_id if source is not None else None,
485 + source.peer_id if source is not None else None,
486 )
362 - if (
363 - not prefill
364 - or not prefill.get("remote_host")
365 - or not prefill.get("remote_port")
366 - or not prefill.get("kv_request_id")
367 - ):
487 + if source is None:
488 logger.debug(
369 - "P2P %s: submit_load job_id=%d FAILED missing prefill params",
489 + "P2P %s: submit_load job_id=%d FAILED missing consumer params",
490 self._local_id,
491 job_id,
492 )
493 self._finished_jobs.append(JobResult(job_id=job_id, success=False))
494 return
495
376 - kv_request_id = prefill["kv_request_id"]
377 - peer_id = self._remote_id_from_params(prefill)
378 - assert peer_id is not None # guaranteed by prefill checks above
496 + kv_request_id = source.kv_request_id
497 + peer_id = source.peer_id
498
499 if not keys:
500 logger.debug(
@@ -442,10 +561,7 @@ class P2PSecondaryTierManager(SecondaryTierManager):
561 warned = False
562 while True:
563 self._poll_once()
445 - pending = any(
446 - s._client._inbound or s._server._inflight
447 - for s in self._sessions.values()
448 - )
564 + pending = any(s.has_pending_work for s in self._sessions.values())
565 if not pending:
566 return
567 if not warned and time.monotonic() - start > 5.0:
@@ -456,31 +572,45 @@ class P2PSecondaryTierManager(SecondaryTierManager):
572 warned = True
573 time.sleep(_DRAIN_SLEEP_S)
574
575 + @override
576 + def serve_external_requests(self, parent: ParentManager) -> None:
577 + """Serve inbound peer lookups against the tiering manager.
578 +
579 + Called once per scheduler step (before this tier's
580 + ``on_schedule_end``) with a ``parent`` handle valid only for the
581 + duration of the call — the sole window in which the P2P server
582 + role may query the tiering manager. First release bookkeeping for
583 + the failed serves left by a reaped session, then let every live
584 + session resolve its enqueued inbound LookupMsgs.
585 + """
586 + if self._failed_serve_ctxs:
587 + for ctx in self._failed_serve_ctxs:
588 + parent.on_request_finished(ctx)
589 + self._failed_serve_ctxs = []
590 + for session in self._sessions.values():
591 + session.serve_external_requests(parent)
592 +
593 @override
594 def on_schedule_end(self, context: ScheduleEndContext) -> None:
461 - return
595 + # Flush any p2p lookups aggregated during this step.
596 + # One LookupMsg per (peer, kv_request_id) with unsent entries;
597 + # send-gating happens inside the session if not yet ready.
598 + for session in self._sessions.values():
599 + session.flush_pending_lookups()
600
601 # ------------------------------------------------------------------
602 # Internal
603 # ------------------------------------------------------------------
604
467 - @staticmethod
468 - def _remote_id_from_params(role_params: dict) -> str | None:
469 - """Build peer_id from a role-scoped sub-dict (``prefill``/``p2p``)."""
470 - host = role_params.get("remote_host")
471 - port = role_params.get("remote_port")
472 - if host and port:
473 - return f"{host}:{port}"
474 - return None
475 -
605 def _get_or_create_session(self, peer_id: str) -> P2PSession:
606 """Return the existing session for peer_id, or open one outbound.
607
479 - Decoder-side helper for on_new_request: when ``prefill`` is set,
480 - the consumer must reach the producer at peer_id. If we already
481 - have a session toward that peer (from a prior load or a
482 - peer-initiated inbound), reuse it; otherwise open an outbound
483 - ControlConnection and build a connected session.
608 + Consumer-side helper for on_new_request: when ``remote_prefiller``
609 + (PD) or ``remote_kv_source`` (symmetric P2P) is set, the consumer must reach the
610 + producer at peer_id. If we already have a session toward that
611 + peer (from a prior load or a peer-initiated inbound), reuse it;
612 + otherwise open an outbound ControlConnection and build a
613 + connected session.
614 """
615 session = self._sessions.get(peer_id)
616 if session is not None:
@@ -491,6 +621,7 @@ class P2PSecondaryTierManager(SecondaryTierManager):
621 local_id=self._local_id,
622 transport=self._data,
623 local_block_len=self._data.block_len,
624 + local_hash_seed=self._hash_seed,
625 conn=conn,
626 )
627 self._sessions[peer_id] = session
@@ -512,6 +643,7 @@ class P2PSecondaryTierManager(SecondaryTierManager):
643 local_id=self._local_id,
644 transport=self._data,
645 local_block_len=self._data.block_len,
646 + local_hash_seed=self._hash_seed,
647 conn=conn,
648 )
649 logger.info(
@@ -546,12 +678,19 @@ class P2PSecondaryTierManager(SecondaryTierManager):
678 ]
679 for kid in stale_kv_ids:
680 del self._kv_to_session[kid]
549 - failed_loads, failed_stores = session.close()
550 - for job_id, kv_request_id in failed_loads:
681 + close_result = session.close()
682 + for job_id in close_result.failed_jobs:
683 self._finished_jobs.append(JobResult(job_id=job_id, success=False))
552 - self._failed_req_ids.add(kv_request_id)
553 - for job_id in failed_stores:
684 + for job_id in close_result.failed_stores:
685 self._finished_jobs.append(JobResult(job_id=job_id, success=False))
686 + # Fail every client-side request (in-flight loads plus unresolved
687 + # symmetric-P2P probes) toward the dead peer so lookup() returns
688 + # MISS (local prefill) instead of RETRY forever — even if a fresh
689 + # session to the same peer is later opened by another request.
690 + self._failed_req_ids.update(close_result.failed_req_ids)
691 + # Release the TieringManager's per-request bookkeeping for the
692 + # dead session's synthetic lookups on the next serve_external_requests.
693 + self._failed_serve_ctxs.extend(close_result.failed_serves)
694 self._data.remove_remote_peer(pid)
695 logger.warning("P2P %s: peer %s down", self._local_id, pid)
696
@@ -648,6 +787,9 @@ class P2PSecondaryTierManager(SecondaryTierManager):
787 def shutdown(self) -> None:
788 self._drain_inflight_for_shutdown()
789 for session in self._sessions.values():
790 + # Orphan ctxs from close() are intentionally dropped: the manager
791 + # is being torn down, so there is no next serve_external_requests
792 + # to flush them and no TieringManager left to release.
793 session.close()
794 self._sessions.clear()
795 self._kv_to_session.clear()
vllm/v1/kv_offload/tiering/p2p/session/__init__.py
+2
@@ -3,6 +3,7 @@
3 from vllm.v1.kv_offload.tiering.p2p.session.session import (
4 LoadResult,
5 P2PSession,
6 + SessionCloseResult,
7 SessionPollResult,
8 StoreResult,
9 )
@@ -10,6 +11,7 @@ from vllm.v1.kv_offload.tiering.p2p.session.session import (
11 __all__ = [
12 "LoadResult",
13 "P2PSession",
14 + "SessionCloseResult",
15 "SessionPollResult",
16 "StoreResult",
17 ]
vllm/v1/kv_offload/tiering/p2p/session/client.py
+342 -36
@@ -11,16 +11,19 @@ callback injected by the coordinator (which gates on ConnectAck).
11
12 from __future__ import annotations
13
14 +import enum
15 import time
16 from collections.abc import Callable, Sequence
16 -from dataclasses import dataclass
17 +from dataclasses import dataclass, field
18 from typing import TYPE_CHECKING, NamedTuple
19
20 from vllm.logger import init_logger
21 +from vllm.v1.kv_offload.base import OffloadKey
22 from vllm.v1.kv_offload.tiering.p2p.session.protocol import (
23 TYPE_KEY,
24 AbortFetchMsg,
25 FetchMsg,
26 + LookupMsg,
27 )
28
29 if TYPE_CHECKING:
@@ -32,16 +35,60 @@ _LOAD_TIMEOUT_S = 30.0
35 _ABORT_ACK_TIMEOUT_S = 10.0
36
37
38 +class ClientPhase(enum.Enum):
39 + """Lifecycle of a request's client-side lookup/fetch signalling.
40 +
41 + Advances monotonically. Only ``finish`` reads it, to decide
42 + whether a terminal empty FetchMsg is owed to release the peer's
43 + lookup state (owed only from ``PROBING``: a LookupMsg went out but no
44 + FetchMsg has since closed the peer's lookup phase).
45 + """
46 +
47 + REGISTERED = enum.auto() # keys in probes/unsent, nothing sent yet
48 + PROBING = enum.auto() # LookupMsg flushed, awaiting responses
49 + FETCH_SENT = enum.auto() # FetchMsg sent (real or terminal empty)
50 +
51 +
52 @dataclass
36 -class _InboundRequestState:
37 - """Client-role state for a single load request."""
53 +class _InboundLoadState:
54 + """Client-role state for a single in-flight load request.
55 +
56 + Lives on ``_ClientRequestState.load`` for the duration of a fetch;
57 + the owning kv_request_id is the dict key, so it isn't stored here.
58 + """
59
60 job_id: int # opaque ID assigned by the manager to this load request
40 - kv_request_id: str
61 submitted_at: float
62 aborted_at: float | None = None
63
64
65 +@dataclass
66 +class _ClientRequestState:
67 + """Per-kv_request_id client-side state.
68 +
69 + One entry per kv_request_id we're driving. Lookup-phase fields are
70 + used only by symmetric P2P (``do_p2p_fetch``); PD-only loads leave
71 + ``probes``/``unsent`` empty and drive just ``phase`` and ``load``. An
72 + entry is dropped once every field is idle — see ``ClientRole._maybe_prune``.
73 + """
74 +
75 + # -- Lookup phase (symmetric P2P only; untouched for PD) --
76 + # Probe outcome per OffloadKey: None while in-flight (registered/sent
77 + # but unresolved), True/False once a LookupRespMsg lands. There is no
78 + # timeout — finish (via finish_request) is guaranteed after
79 + # the request's lookup() calls and clears every probe, so an
80 + # unanswered probe simply stays None until then.
81 + probes: dict[OffloadKey, bool | None] = field(default_factory=dict)
82 + # OffloadKeys registered but not yet flushed onto the wire. Drained and
83 + # cleared by the next flush_pending_lookups.
84 + unsent: list[OffloadKey] = field(default_factory=list)
85 +
86 + # Monotonic lookup/fetch signalling phase; see ``ClientPhase``.
87 + phase: ClientPhase = ClientPhase.REGISTERED
88 + # Set while a fetch is in flight; cleared on completion/abort/timeout.
89 + load: _InboundLoadState | None = None
90 +
91 +
92 class LoadResult(NamedTuple):
93 """Result from a session poll, client side."""
94
@@ -50,6 +97,21 @@ class LoadResult(NamedTuple):
97 success: bool
98
99
100 +class ClientCloseResult(NamedTuple):
101 + """What the manager must fail when a peer session is torn down.
102 +
103 + ``failed_jobs`` is the ``job_id`` of every load still in flight; each
104 + becomes a ``JobResult(success=False)``. ``failed_req_ids`` is every
105 + kv_request_id whose lookup() would otherwise defer forever on the dead
106 + peer — the in-flight loads plus any request holding an unresolved
107 + symmetric-P2P probe. ``failed_jobs`` is the subset of ``failed_req_ids``
108 + that had a load job.
109 + """
110 +
111 + failed_jobs: list[int]
112 + failed_req_ids: list[str]
113 +
114 +
115 class ClientRole:
116 """Client-side load state machine for one peer session.
117
@@ -61,9 +123,55 @@ class ClientRole:
123 def __init__(self, peer_id: str, send: Callable[[dict], None]) -> None:
124 self._peer_id = peer_id
125 self._send = send
64 - self._inbound: dict[str, _InboundRequestState] = {}
126 + # All per-kv_request_id state lives here. Entries are created
127 + # lazily by request_blocks / register_lookup and dropped by
128 + # _maybe_prune once every field is idle.
129 + self._requests: dict[str, _ClientRequestState] = {}
130 + # kv_request_ids with unsent lookup keys for the next flush to
131 + # visit — the work-list that keeps flush_pending_lookups from
132 + # scanning every request each scheduler step. Mirrors the server's
133 + # _serve_pending. Populated by register_lookup, drained by
134 + # flush_pending_lookups, and discarded on finish/close.
135 + self._flush_pending: set[str] = set()
136 + # kv_request_ids with a fetch in flight (``st.load is not None``) —
137 + # the work-list collect_results walks for timeouts, and the
138 + # has_active_loads predicate, instead of scanning every request.
139 + # Kept in exact sync with ``st.load``: armed in request_blocks,
140 + # discarded wherever load is cleared, and cleared on close.
141 + self._active_loads: set[str] = set()
142 self._completed_loads: list[LoadResult] = []
143
144 + # ------------------------------------------------------------------
145 + # State helpers
146 + # ------------------------------------------------------------------
147 +
148 + def _get_or_create_request(self, kv_request_id: str) -> _ClientRequestState:
149 + """Get or create the state entry for a kv_request_id."""
150 + st = self._requests.get(kv_request_id)
151 + if st is None:
152 + st = _ClientRequestState()
153 + self._requests[kv_request_id] = st
154 + return st
155 +
156 + def _maybe_prune(self, kv_request_id: str) -> None:
157 + """Drop the entry once it holds no live load or lookup state.
158 +
159 + The sticky ``phase`` is only read by ``finish``. A probe
160 + clears when its fetch is issued (``request_blocks``) or when the
161 + request finishes (``finish``/``close``); in the former case
162 + ``load`` is set and keeps the entry alive, in the latter the phase
163 + is no longer needed — so dropping on emptiness never loses a phase
164 + still in use.
165 + """
166 + st = self._requests.get(kv_request_id)
167 + if st is not None and st.load is None and not st.probes and not st.unsent:
168 + del self._requests[kv_request_id]
169 +
170 + @property
171 + def has_active_loads(self) -> bool:
172 + """True if any kv_request_id has a fetch in flight."""
173 + return bool(self._active_loads)
174 +
175 # ------------------------------------------------------------------
176 # Public API
177 # ------------------------------------------------------------------
@@ -72,7 +180,7 @@ class ClientRole:
180 self,
181 job_id: JobId,
182 kv_request_id: str,
75 - keys: Sequence[bytes],
183 + keys: Sequence[OffloadKey],
184 block_ids: Sequence[int],
185 send_ready: bool,
186 ) -> None:
@@ -86,44 +194,101 @@ class ClientRole:
194 len(block_ids),
195 send_ready,
196 )
89 - self._inbound[kv_request_id] = _InboundRequestState(
197 + st = self._get_or_create_request(kv_request_id)
198 + st.load = _InboundLoadState(
199 job_id=job_id,
91 - kv_request_id=kv_request_id,
200 submitted_at=time.monotonic(),
201 )
202 + self._active_loads.add(kv_request_id)
203 + st.phase = ClientPhase.FETCH_SENT
204 self._send(
205 {
206 TYPE_KEY: FetchMsg.TYPE,
207 FetchMsg.KV_REQUEST_ID: kv_request_id,
98 - FetchMsg.BLOCK_HASHES: list(keys),
208 + FetchMsg.KEYS: list(keys),
209 FetchMsg.BLOCK_INDEXES: [int(idx) for idx in block_ids],
210 }
211 )
212 + # Issuing the fetch ends this request's lookup phase, so drop all
213 + # probe state. Once the peer serves this fetch both sides unpin, so
214 + # the producer may evict the block; a stale cached True would
215 + # otherwise let a re-scheduled lookup() return HIT without
216 + # re-probing, pointing at a block the producer no longer holds.
217 + # Clearing forces a fresh LookupMsg on re-schedule so the producer
218 + # answers from current state. For a symmetric-P2P request (probes
219 + # populated) every fetched block was a confirmed HIT; a PD-only load
220 + # never probes, so probes is empty and the clear is a no-op.
221 + if st.probes:
222 + assert all(st.probes.get(key) is True for key in keys)
223 + st.probes.clear()
224 +
225 + def finish(self, kv_request_id: str) -> None:
226 + """Finish a request: abort any in-flight load and release lookup state.
227 +
228 + Called from the session's ``finish_request``. The two branches are
229 + mutually exclusive: ``load`` is set only by ``request_blocks``, which
230 + also advances ``phase`` to ``FETCH_SENT``, and nothing moves it back
231 + to ``PROBING`` — so a fetch in flight never coexists with the
232 + ``PROBING`` phase.
233
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:
234 + - Fetch in flight (``load`` set, phase ``FETCH_SENT``): send an
235 + AbortFetchMsg unless the load is already aborting, then drop it.
236 + - Outstanding lookups (``PROBING``): a LookupMsg was flushed but no
237 + FetchMsg has closed the peer's lookup phase. Every FetchMsg the
238 + server receives in p2p mode is its "request finished" signal (it
239 + releases lookup state and fires ``cb.finish_request``); when the
240 + client's lookups all missed no FetchMsg is otherwise sent, so emit
241 + a terminal empty one purely to trigger those semantics. In
242 + ``REGISTERED`` the peer never received a LookupMsg and in
243 + ``FETCH_SENT`` a FetchMsg already closed the phase, so neither owes
244 + a terminal FetchMsg.
245 +
246 + Then drop all probe/lookup state and prune the entry.
247 + """
248 + st = self._requests.get(kv_request_id)
249 + if st is None:
250 + return
251 + if st.load is not None:
252 + if st.load.aborted_at is None:
253 + self._send(
254 + {
255 + TYPE_KEY: AbortFetchMsg.TYPE,
256 + AbortFetchMsg.KV_REQUEST_ID: kv_request_id,
257 + }
258 + )
259 + st.load = None
260 + self._active_loads.discard(kv_request_id)
261 + elif st.phase is ClientPhase.PROBING:
262 + st.phase = ClientPhase.FETCH_SENT
263 self._send(
264 {
109 - TYPE_KEY: AbortFetchMsg.TYPE,
110 - AbortFetchMsg.KV_REQUEST_ID: kv_request_id,
265 + TYPE_KEY: FetchMsg.TYPE,
266 + FetchMsg.KV_REQUEST_ID: kv_request_id,
267 + FetchMsg.KEYS: [],
268 + FetchMsg.BLOCK_INDEXES: [],
269 }
270 )
271 + st.probes.clear()
272 + st.unsent.clear()
273 + self._flush_pending.discard(kv_request_id)
274 + self._maybe_prune(kv_request_id)
275
276 def on_transfer_done(self, kv_request_id: str, success: bool) -> None:
277 """Handle a TransferDoneMsg from the peer."""
116 - req = self._inbound.pop(kv_request_id, None)
117 - if req is not None:
278 + st = self._requests.get(kv_request_id)
279 + if st is not None and st.load is not None:
280 self._completed_loads.append(
281 LoadResult(
120 - job_id=req.job_id,
282 + job_id=st.load.job_id,
283 kv_request_id=kv_request_id,
284 success=success,
285 )
286 )
287 + st.load = None
288 + self._active_loads.discard(kv_request_id)
289 + self._maybe_prune(kv_request_id)
290 else:
126 - # No matching _inbound entry: either a duplicate
291 + # No matching in-flight load: either a duplicate
292 # transfer_done from the peer (protocol violation) or a
293 # benign race with a local cancel/abort/timeout that
294 # already popped the entry. We don't track terminated ids,
@@ -137,23 +302,26 @@ class ClientRole:
302
303 def on_abort_ack(self, kv_request_id: str) -> None:
304 """Handle an AbortAckMsg from the peer."""
140 - req = self._inbound.pop(kv_request_id, None)
141 - if req is not None:
305 + st = self._requests.get(kv_request_id)
306 + if st is not None and st.load is not None:
307 logger.warning(
308 "P2PSession %s: load request %s (job_id=%d) timed out; "
309 "load job completed with failure. If this recurs, ensure "
310 "PYTHONHASHSEED is set to the same value on all nodes.",
311 self._peer_id,
312 kv_request_id,
148 - req.job_id,
313 + st.load.job_id,
314 )
315 self._completed_loads.append(
316 LoadResult(
152 - job_id=req.job_id,
317 + job_id=st.load.job_id,
318 kv_request_id=kv_request_id,
319 success=False,
320 )
321 )
322 + st.load = None
323 + self._active_loads.discard(kv_request_id)
324 + self._maybe_prune(kv_request_id)
325 else:
326 # See on_transfer_done: same ambiguity (duplicate ack
327 # vs. raced with local cancel/timeout that already popped).
@@ -164,19 +332,141 @@ class ClientRole:
332 kv_request_id,
333 )
334
335 + # ------------------------------------------------------------------
336 + # Symmetric-P2P lookup (do_p2p_fetch=true)
337 + # ------------------------------------------------------------------
338 +
339 + def register_lookup(self, kv_request_id: str, key: bytes) -> bool | None:
340 + """Register or resolve one (kv_request_id, key) probe.
341 +
342 + Idempotent across scheduler steps:
343 + - First call: creates a pending entry, returns None.
344 + - Subsequent calls while in-flight: returns None.
345 + - Once a LookupRespMsg has resolved the entry: returns the cached
346 + bool result on every call without popping it.
347 +
348 + A resolved entry is retained until its fetch is issued
349 + (``request_blocks`` pops it) or the request finishes
350 + (``finish`` clears all entries for the id). A request's
351 + block set can be re-probed across steps, so popping on read would
352 + make a repeat probe of an already-resolved key look brand-new and
353 + re-queue it, emitting a redundant LookupMsg for an answer we
354 + already hold. Keeping the entry until fetch makes repeat probes
355 + free; clearing it at fetch forces a fresh probe if the request is
356 + re-scheduled, since the block is unpinned once served.
357 + """
358 + st = self._get_or_create_request(kv_request_id)
359 + okey = OffloadKey(key)
360 + if okey in st.probes:
361 + return st.probes[okey]
362 + st.probes[okey] = None
363 + st.unsent.append(okey)
364 + self._flush_pending.add(kv_request_id)
365 + logger.debug(
366 + "P2P LOOKUP client %s: REGISTER kv_request_id=%s key=%s (unsent=%d)",
367 + self._peer_id,
368 + kv_request_id,
369 + key.hex()[:16],
370 + len(st.unsent),
371 + )
372 + return None
373 +
374 + def flush_pending_lookups(self) -> None:
375 + """Send a LookupMsg for each kv_request_id with unsent entries.
376 +
377 + Called once per scheduler step from the manager's
378 + ``on_schedule_end()``. A request's block set may be discovered
379 + across several scheduler steps, so more than one LookupMsg can
380 + go out per kv_request_id — one per step that registered new
381 + keys. register_lookup() de-dups in-flight and already-resolved
382 + (req_id, key) pairs, so each LookupMsg carries only the keys
383 + first probed in that step. The peer's lookup phase for the id is
384 + still closed by exactly one FetchMsg, which the client contract
385 + guarantees is sent after every lookup for the id has resolved
386 + (see request_blocks / finish). Send-gating is handled by
387 + the injected ``_send`` callback (queues until ConnectAckMsg if
388 + needed).
389 +
390 + Only requests that registered new keys since the last flush are
391 + visited — the ``_flush_pending`` work-list avoids scanning every
392 + live request each scheduler step.
393 + """
394 + for req_id in self._flush_pending:
395 + st = self._requests.get(req_id)
396 + if st is None or not st.unsent:
397 + continue
398 + # Record that the peer now holds lookup state for this id so
399 + # finish knows a terminal empty FetchMsg may be owed.
400 + # Only promote from REGISTERED: once a fetch has gone out
401 + # (FETCH_SENT) a later LookupMsg must not regress the phase, as
402 + # no terminal FetchMsg is owed for an already-fetched request.
403 + if st.phase is ClientPhase.REGISTERED:
404 + st.phase = ClientPhase.PROBING
405 + logger.debug(
406 + "P2P LOOKUP client %s: SEND LookupMsg kv_request_id=%s keys=%d",
407 + self._peer_id,
408 + req_id,
409 + len(st.unsent),
410 + )
411 + self._send(
412 + {
413 + TYPE_KEY: LookupMsg.TYPE,
414 + LookupMsg.KV_REQUEST_ID: req_id,
415 + LookupMsg.KEYS: list(st.unsent),
416 + }
417 + )
418 + st.unsent = []
419 + self._flush_pending.clear()
420 +
421 + def on_lookup_resp(
422 + self,
423 + kv_request_id: str,
424 + keys: Sequence[bytes],
425 + hits: Sequence[bool],
426 + ) -> None:
427 + """Apply per-pair hit/miss results from a peer.
428 +
429 + Pairs that don't match a known entry (already cancelled or
430 + never asked) are silently dropped — the producer is free to
431 + split or coalesce responses.
432 + """
433 + n_hit = sum(1 for hit in hits if hit)
434 + logger.debug(
435 + "P2P LOOKUP client %s: RECV LookupRespMsg kv_request_id=%s "
436 + "keys=%d hits=%d misses=%d",
437 + self._peer_id,
438 + kv_request_id,
439 + len(keys),
440 + n_hit,
441 + len(hits) - n_hit,
442 + )
443 + st = self._requests.get(kv_request_id)
444 + if st is None:
445 + return
446 + for h, hit in zip(keys, hits):
447 + key = OffloadKey(h)
448 + if key in st.probes:
449 + st.probes[key] = hit
450 +
451 def collect_results(self) -> list[LoadResult]:
168 - """Walk timeouts and drain completed loads.
452 + """Walk load timeouts and drain completed loads.
453
454 Active requests past ``_LOAD_TIMEOUT_S`` get an AbortFetchMsg
455 sent and enter the aborting phase. Aborting requests past
456 ``_ABORT_ACK_TIMEOUT_S`` are surfaced as failed loads.
457 +
458 + Lookups have no timeout: an unanswered probe stays None (RETRY)
459 + until finish_request clears it — see ``_ClientRequestState.probes``.
460 """
461 now = time.monotonic()
462 to_remove: list[str] = []
176 - for req_id, req in self._inbound.items():
177 - if req.aborted_at is None:
178 - if now - req.submitted_at >= _LOAD_TIMEOUT_S:
179 - req.aborted_at = now
463 + for req_id in self._active_loads:
464 + st = self._requests[req_id]
465 + assert st.load is not None
466 + load = st.load
467 + if load.aborted_at is None:
468 + if now - load.submitted_at >= _LOAD_TIMEOUT_S:
469 + load.aborted_at = now
470 logger.warning(
471 "P2PSession %s: %s timed out, sending abort",
472 self._peer_id,
@@ -189,11 +479,11 @@ class ClientRole:
479 }
480 )
481 else:
192 - if now - req.aborted_at >= _ABORT_ACK_TIMEOUT_S:
482 + if now - load.aborted_at >= _ABORT_ACK_TIMEOUT_S:
483 to_remove.append(req_id)
484 self._completed_loads.append(
485 LoadResult(
196 - job_id=req.job_id,
486 + job_id=load.job_id,
487 kv_request_id=req_id,
488 success=False,
489 )
@@ -204,15 +494,31 @@ class ClientRole:
494 req_id,
495 )
496 for req_id in to_remove:
207 - self._inbound.pop(req_id)
497 + self._requests[req_id].load = None
498 + self._active_loads.discard(req_id)
499 + self._maybe_prune(req_id)
500
501 results = self._completed_loads
502 self._completed_loads = []
503 return results
504
213 - def close(self) -> list[tuple[int, str]]:
214 - """Tear down. Returns ``(job_id, kv_request_id)`` for pending loads."""
215 - failed = [(req.job_id, req.kv_request_id) for req in self._inbound.values()]
216 - self._inbound.clear()
505 + def close(self) -> ClientCloseResult:
506 + """Tear down, reporting work the dead peer can no longer complete.
507 +
508 + A request is failed if it has a load in flight (its job fails) or
509 + holds an unresolved symmetric-P2P probe (its lookup() would defer
510 + forever on an answer that can never arrive). See ``ClientCloseResult``.
511 + """
512 + failed_jobs = [
513 + st.load.job_id for st in self._requests.values() if st.load is not None
514 + ]
515 + failed_req_ids = [
516 + req_id
517 + for req_id, st in self._requests.items()
518 + if st.load is not None or any(hit is None for hit in st.probes.values())
519 + ]
520 + self._requests.clear()
521 + self._flush_pending.clear()
522 + self._active_loads.clear()
523 self._completed_loads.clear()
218 - return failed
524 + return ClientCloseResult(failed_jobs=failed_jobs, failed_req_ids=failed_req_ids)
vllm/v1/kv_offload/tiering/p2p/session/protocol.py
+91 -9
@@ -26,6 +26,14 @@ Block Transfer Flow (happy path)
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 + In p2p mode FetchMsg is also the server-side "request finished"
30 + signal for the id: no further ``cb.create_store_job`` will fire
31 + (parked LookupMsg batches are popped, so pending-key resolution
32 + cannot promote a HIT after this point), all server-side lookup
33 + state for the id is released, and ``cb.finish_request`` fires on
34 + each dropped batch. The client emits exactly one FetchMsg per
35 + lookup-touched request, including an empty one when no blocks
36 + end up being fetched.
37 2. Server matches requested blocks against locally stored blocks:
38 - Blocks already available are transferred immediately via RDMA.
39 - Blocks not yet available are recorded as "demanded" and
@@ -119,6 +127,9 @@ class ConnectMsg:
127 BLOCK_LEN: Size in bytes of each block (must match between peers).
128 CONFIG_FINGERPRINT: SHA-256 prefix of the model configuration.
129 Peers with different fingerprints are incompatible.
130 + HASH_SEED: The peer's PYTHONHASHSEED. Block hashes chain from a seed
131 + derived from it, so peers with different values compute different
132 + hashes for identical content and must not exchange blocks.
133 """
134
135 TYPE = "connect"
@@ -128,6 +139,7 @@ class ConnectMsg:
139 NUM_BLOCKS = "num_blocks"
140 BLOCK_LEN = "block_len"
141 CONFIG_FINGERPRINT = "config_fingerprint"
142 + HASH_SEED = "hash_seed"
143
144 @staticmethod
145 def validate(msg: dict) -> None:
@@ -137,6 +149,7 @@ class ConnectMsg:
149 _require_non_neg_int(msg, ConnectMsg.BASE_ADDR)
150 _require_pos_int(msg, ConnectMsg.NUM_BLOCKS)
151 _require_pos_int(msg, ConnectMsg.BLOCK_LEN)
152 + _require(msg, ConnectMsg.HASH_SEED, str)
153
154
155 class ConnectAckMsg:
@@ -165,37 +178,106 @@ class DisconnectMsg:
178
179
180 class FetchMsg:
168 - """Client → Server: request blocks by key.
181 + """Client → Server: request blocks by key and close the lookup phase.
182 +
183 + In p2p mode FetchMsg is also the server-side "request finished"
184 + signal for ``kv_request_id``: on receipt the server (a) fires no
185 + further ``cb.create_store_job`` for this id — parked LookupMsg
186 + batches are popped, so ``_resolve_pending_lookups`` cannot promote
187 + a HIT_PENDING / RETRY key into a fresh pin after this point — and
188 + (b) calls ``cb.finish_request(batch.ctx)`` on each dropped batch
189 + so the TieringManager can release per-batch bookkeeping. In the
190 + all-miss case the client emits an empty FetchMsg (``KEYS``
191 + and ``BLOCK_INDEXES`` both empty) purely to fire this signal.
192
193 Fields:
194 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).
195 + KEYS: List of block keys (OffloadKey bytes). May be empty.
196 + BLOCK_INDEXES: List of remote block indexes (same length as KEYS).
197 """
198
199 TYPE = "fetch"
200 KV_REQUEST_ID = "kv_request_id"
178 - BLOCK_HASHES = "block_hashes"
201 + KEYS = "keys"
202 BLOCK_INDEXES = "block_indexes"
203
204 @staticmethod
205 def validate(msg: dict) -> None:
206 """Raise ValueError if any field has an invalid type or value."""
207 _require(msg, FetchMsg.KV_REQUEST_ID, str)
185 - _require_list(msg, FetchMsg.BLOCK_HASHES)
208 + _require_list(msg, FetchMsg.KEYS)
209 _require_list(msg, FetchMsg.BLOCK_INDEXES)
187 - hashes = msg[FetchMsg.BLOCK_HASHES]
210 + keys = msg[FetchMsg.KEYS]
211 indexes = msg[FetchMsg.BLOCK_INDEXES]
189 - if len(hashes) != len(indexes):
212 + if len(keys) != len(indexes):
213 raise ValueError(
191 - f"block_hashes/block_indexes length mismatch: "
192 - f"{len(hashes)} vs {len(indexes)}"
214 + f"keys/block_indexes length mismatch: {len(keys)} vs {len(indexes)}"
215 )
216 for idx in indexes:
217 if not isinstance(idx, int) or idx < 0:
218 raise ValueError(f"block_indexes: invalid index {idx!r}")
219
220
221 +class LookupMsg:
222 + """Client → Server: probe which block keys the peer holds.
223 +
224 + Sent on the consumer side under symmetric P2P (do_p2p_fetch=true)
225 + after the consumer has aggregated per-block lookups across a
226 + scheduler step. The producer replies with one or more LookupRespMsg
227 + covering the requested keys.
228 +
229 + Fields:
230 + KV_REQUEST_ID: Identifies this lookup transaction.
231 + KEYS: List of block keys (OffloadKey bytes) to probe.
232 + """
233 +
234 + TYPE = "lookup"
235 + KV_REQUEST_ID = "kv_request_id"
236 + KEYS = "keys"
237 +
238 + @staticmethod
239 + def validate(msg: dict) -> None:
240 + """Raise ValueError if any field has an invalid type or value."""
241 + _require(msg, LookupMsg.KV_REQUEST_ID, str)
242 + _require_list(msg, LookupMsg.KEYS)
243 +
244 +
245 +class LookupRespMsg:
246 + """Server → Client: per-key hit/miss answer for a prior LookupMsg.
247 +
248 + Carries two parallel arrays of equal length so each (key,
249 + hit) pair is self-describing. The producer is free to split or
250 + coalesce responses across multiple LookupMsgs for the same
251 + KV_REQUEST_ID — the consumer matches each pair back to its
252 + pending entry by (KV_REQUEST_ID, key).
253 +
254 + Fields:
255 + KV_REQUEST_ID: The lookup transaction this responds to.
256 + KEYS: List of block keys answered by this message.
257 + HITS: Parallel list of bools — True if the producer holds the
258 + corresponding block, False otherwise.
259 + """
260 +
261 + TYPE = "lookup_resp"
262 + KV_REQUEST_ID = "kv_request_id"
263 + KEYS = "keys"
264 + HITS = "hits"
265 +
266 + @staticmethod
267 + def validate(msg: dict) -> None:
268 + """Raise ValueError if any field has an invalid type or value."""
269 + _require(msg, LookupRespMsg.KV_REQUEST_ID, str)
270 + _require_list(msg, LookupRespMsg.KEYS)
271 + _require_list(msg, LookupRespMsg.HITS)
272 + keys = msg[LookupRespMsg.KEYS]
273 + hits = msg[LookupRespMsg.HITS]
274 + if len(keys) != len(hits):
275 + raise ValueError(f"keys/hits length mismatch: {len(keys)} vs {len(hits)}")
276 + for hit in hits:
277 + if not isinstance(hit, bool):
278 + raise ValueError(f"hits: invalid value {hit!r}")
279 +
280 +
281 class TransferDoneMsg:
282 """Server → Client: all blocks transferred for a request.
283
vllm/v1/kv_offload/tiering/p2p/session/server.py
+558 -95
@@ -18,26 +18,32 @@ coordinator's ``_dispatch_message`` can reuse its existing
18 from __future__ import annotations
19
20 import time
21 -from collections.abc import Callable, Sequence
21 +from collections.abc import Callable, Iterable, 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
26 +from vllm.v1.kv_offload.base import LookupResult, OffloadKey, ReqContext
27 from vllm.v1.kv_offload.tiering.p2p.session.protocol import (
28 TYPE_KEY,
29 AbortAckMsg,
30 + LookupRespMsg,
31 TransferDoneMsg,
32 )
33
34 if TYPE_CHECKING:
34 - from vllm.v1.kv_offload.tiering.base import JobId
35 + from vllm.v1.kv_offload.tiering.base import JobId, ParentManager
36 from vllm.v1.kv_offload.tiering.p2p.data import DataTransport
37
38 logger = init_logger(__name__)
39
40 _STORE_TIMEOUT_S = 30.0
41 _CANCEL_DRAIN_TIMEOUT_S = 10.0
42 +# Cap on time the server holds a HIT_PENDING / RETRY key from an inbound
43 +# LookupMsg before falling back to MISS. Long enough that an in-flight
44 +# primary write or a just-started promotion typically completes; short
45 +# enough that the consumer doesn't sit idle on a stuck producer.
46 +_LOOKUP_PENDING_TIMEOUT_S = 5.0
47
48
49 class StoreResult(NamedTuple):
@@ -69,8 +75,8 @@ class _MatchResult(NamedTuple):
75 class _OutboundRequestState:
76 """Server-role state for a single peer fetch request.
77
72 - The owning ``kv_request_id`` is the dict key in
73 - ``ServerRole._outbound`` and is not duplicated on the value.
78 + The owning ``kv_request_id`` is the ``ServerRole._requests`` dict key
79 + and is not duplicated on the value.
80 """
81
82 demand_received: bool = False
@@ -90,7 +96,7 @@ class _OutboundRequestState:
96
97 def add_stored_blocks(
98 self,
93 - block_hashes: Sequence[OffloadKey],
99 + keys: Sequence[OffloadKey],
100 block_ids: Sequence[int],
101 job_id: int,
102 ) -> _MatchResult:
@@ -98,13 +104,13 @@ class _OutboundRequestState:
104 self.pending_job_ids.add(job_id)
105 local_idxs: list[int] = []
106 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)
107 + for key, local_idx in zip(keys, block_ids):
108 + remote_idx = self.demanded.pop(key, None)
109 if remote_idx is not None:
110 local_idxs.append(local_idx)
111 remote_idxs.append(remote_idx)
112 else:
107 - self.available[block_hash] = (job_id, local_idx)
113 + self.available[key] = (job_id, local_idx)
114 return _MatchResult(
115 local_idxs=local_idxs,
116 remote_idxs=remote_idxs,
@@ -113,25 +119,25 @@ class _OutboundRequestState:
119
120 def add_fetch_demand(
121 self,
116 - block_hashes: Sequence[OffloadKey],
122 + keys: Sequence[OffloadKey],
123 block_indexes: Sequence[int],
124 ) -> _MatchResult:
125 """Register the peer's fetch demand. Returns matched pairs."""
126 self.demand_received = True
121 - self.remaining = len(block_hashes)
127 + self.remaining = len(keys)
128
129 local_idxs: list[int] = []
130 remote_idxs: list[int] = []
131 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)
132 + for key, remote_idx in zip(keys, block_indexes):
133 + stored_entry = self.available.pop(key, None)
134 if stored_entry is not None:
135 stored_job_id, local_idx = stored_entry
136 local_idxs.append(local_idx)
137 remote_idxs.append(remote_idx)
138 job_ids.add(stored_job_id)
139 else:
134 - self.demanded[block_hash] = remote_idx
140 + self.demanded[key] = remote_idx
141 return _MatchResult(
142 local_idxs=local_idxs,
143 remote_idxs=remote_idxs,
@@ -139,6 +145,78 @@ class _OutboundRequestState:
145 )
146
147
148 +@dataclass
149 +class _ActiveLookup:
150 + """In-flight state for one inbound LookupMsg.
151 +
152 + Aggregates per-key HIT/MISS resolutions and defers the single
153 + outbound LookupRespMsg until every key has been resolved or the
154 + ``deadline`` fires (remaining ``pending`` keys then force-MISS).
155 + Exactly one LookupRespMsg is emitted per LookupMsg, carrying every
156 + key in the original wire order.
157 + """
158 +
159 + lookup_id: int
160 + kv_request_id: str
161 + ctx: ReqContext
162 + # Keys from the inbound LookupMsg, preserved in wire order so
163 + # the aggregated response goes back in the same order.
164 + keys: list[OffloadKey] = field(default_factory=list)
165 + # Per-key resolution: True = HIT, False = MISS. A key is present
166 + # here once definitively resolved; still-pending keys are only
167 + # in ``pending``.
168 + resolved: dict[OffloadKey, bool] = field(default_factory=dict)
169 + # Keys still awaiting resolution (HIT_PENDING / RETRY from
170 + # ``parent.lookup``); re-polled by ``_resolve_pending_lookups``.
171 + pending: set[OffloadKey] = field(default_factory=set)
172 + # Absolute deadline (``time.monotonic``). Once reached, remaining
173 + # ``pending`` keys are force-resolved to MISS so the consumer
174 + # can fall back instead of waiting on a stuck producer.
175 + deadline: float = 0.0
176 +
177 +
178 +class _PendingLookup(NamedTuple):
179 + """A raw inbound LookupMsg awaiting resolution.
180 +
181 + Enqueued by ``on_lookup`` during dispatch and drained by the next
182 + ``serve_external_requests``. The deadline for any resulting
183 + HIT_PENDING / RETRY key is measured from ``enqueued_at``.
184 + """
185 +
186 + keys: list[OffloadKey]
187 + enqueued_at: float
188 +
189 +
190 +@dataclass
191 +class _ServerRequestState:
192 + """Per-kv_request_id server-side state.
193 +
194 + Consolidates the outbound serve/transfer state, the symmetric-P2P
195 + inbound-lookup state, abort bookkeeping, and the inflight-transfer
196 + count for one kv_request_id. The owning id is the ``_requests`` dict
197 + key and is not duplicated here. An entry is dropped once every field
198 + is idle — see ``ServerRole._maybe_prune``.
199 + """
200 +
201 + # Outbound serve state (PD + symmetric producer side). None until the
202 + # first add_stored_blocks / on_fetch; reset to None on finalize/abort.
203 + outbound: _OutboundRequestState | None = None
204 + # Raw inbound LookupMsgs not yet processed against the ParentManager.
205 + pending_lookups: list[_PendingLookup] = field(default_factory=list)
206 + # Per-LookupMsg state parked with HIT_PENDING / RETRY keys, keyed by
207 + # the (globally unique) lookup_id and re-polled each serve.
208 + lookups: dict[int, _ActiveLookup] = field(default_factory=dict)
209 + # Start time of a pending abort drain (``time.monotonic``); None when
210 + # no abort is in progress.
211 + abort_started_at: float | None = None
212 + # Transfer ids in ``ServerRole._inflight`` for this id. Kept in sync
213 + # via _inflight_add / _inflight_pop so a non-empty set is an exact
214 + # "has any inflight transfer" predicate and the abort drain can
215 + # enumerate this request's transfers without scanning all of
216 + # ``_inflight``.
217 + inflight_tids: set[int] = field(default_factory=set)
218 +
219 +
220 class ServerRole:
221 """Server-side store/serve state machine for one peer session.
222
@@ -157,22 +235,55 @@ class ServerRole:
235 self._transport = transport
236 self._send = send
237
160 - self._outbound: dict[str, _OutboundRequestState] = {}
238 + # All per-kv_request_id state lives here. Entries are created
239 + # lazily and dropped by _maybe_prune once every field is idle.
240 + self._requests: dict[str, _ServerRequestState] = {}
241 + # kv_request_ids with lookup work (unprocessed pending_lookups or
242 + # parked lookups) for the next serve to visit — the work-list that
243 + # keeps serve_external_requests from scanning every request.
244 + self._serve_pending: set[str] = set()
245 # transfer_id → xfer. Mutate ONLY via _inflight_add / _inflight_pop
162 - # so the per-request count below stays in sync.
246 + # so the per-request inflight_tids stays in sync.
247 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] = {}
248 self._store_jobs: dict[int, float] = {} # job_id → submitted_at
171 - self._pending_aborts: dict[str, float] = {} # kv_request_id → start
249 # StoreResults queued by _finalize_outbound for the next poll
250 # tick to surface. Mirrors the deferred-result pattern used for
251 # load timeouts.
252 self._pending_store_results: list[StoreResult] = []
253 + # Synthetic lookup ctxs whose ``on_request_finished`` still needs to
254 + # fire but which were closed outside a serve window (FetchMsg / local
255 + # finish popped their parked lookup). Drained via
256 + # ``parent.on_request_finished`` in ``serve_external_requests``.
257 + self._finished_lookup_ctxs: list[ReqContext] = []
258 + self._lookup_id_counter: int = 0
259 + # kv_request_ids with a parked abort awaiting drain — work-list so
260 + # drain_pending_aborts doesn't scan every request each poll tick.
261 + self._parked_aborts: set[str] = set()
262 +
263 + # ------------------------------------------------------------------
264 + # State helpers
265 + # ------------------------------------------------------------------
266 +
267 + def _get_or_create_request(self, kv_request_id: str) -> _ServerRequestState:
268 + """Get or create the state entry for a kv_request_id."""
269 + st = self._requests.get(kv_request_id)
270 + if st is None:
271 + st = _ServerRequestState()
272 + self._requests[kv_request_id] = st
273 + return st
274 +
275 + def _maybe_prune(self, kv_request_id: str) -> None:
276 + """Drop the entry once it holds no live state."""
277 + st = self._requests.get(kv_request_id)
278 + if (
279 + st is not None
280 + and st.outbound is None
281 + and not st.inflight_tids
282 + and not st.lookups
283 + and not st.pending_lookups
284 + and st.abort_started_at is None
285 + ):
286 + del self._requests[kv_request_id]
287
288 # ------------------------------------------------------------------
289 # Public API
@@ -187,19 +298,41 @@ class ServerRole:
298 ) -> None:
299 """New blocks stored locally — match against pending fetch demand."""
300 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:
301 + st = self._get_or_create_request(kv_request_id)
302 + if st.outbound is None:
303 + st.outbound = _OutboundRequestState()
304 + result = st.outbound.add_stored_blocks(keys, block_ids, job_id)
305 + if result.local_idxs and st.outbound.demand_received:
306 self._submit_transfer(kv_request_id, result)
307
308 def on_fetch(
309 self,
310 kv_request_id: str,
198 - block_hashes: Sequence[OffloadKey],
311 + keys: Sequence[OffloadKey],
312 block_indexes: Sequence[int],
313 ) -> None:
314 """Handle a FetchMsg from the peer.
315
316 + In p2p mode FetchMsg is the server-side "request finished"
317 + signal for ``kv_request_id``. Three consequences flow from that:
318 +
319 + - No further ``parent.create_store_job`` will fire for this id:
320 + the request's parked ``lookups`` are popped here (via
321 + ``_finish_inbound_lookups``) before the next
322 + ``serve_external_requests`` runs ``_resolve_pending_lookups``,
323 + so any HIT_PENDING / RETRY key that would otherwise later
324 + promote to HIT and pin a slot is dropped instead. Any raw
325 + not-yet-processed LookupMsg for this id is dropped too.
326 + - All server-side lookup state for the id is cleaned up (the
327 + ``lookups`` entries themselves).
328 + - The synthetic ctx is queued for ``parent.on_request_finished``
329 + (fired by the next ``serve_external_requests``) so the
330 + TieringManager can release per-lookup bookkeeping.
331 +
332 + The client contract guarantees exactly one FetchMsg per
333 + lookup-touched request — including an empty one when no
334 + blocks end up being fetched.
335 +
336 Raises ``ValueError`` on a duplicate fetch for the same
337 ``kv_request_id``; the coordinator's dispatch loop turns that
338 into a protocol-error disconnect.
@@ -208,18 +341,28 @@ class ServerRole:
341 "P2PSession %s: fetch RECEIVED kv_request_id=%s blocks=%d",
342 self._peer_id,
343 kv_request_id,
211 - len(block_hashes),
344 + len(keys),
345 )
213 - existing = self._outbound.get(kv_request_id)
346 + st = self._requests.get(kv_request_id)
347 + existing = st.outbound if st is not None else None
348 if existing is not None and existing.demand_received:
349 # A second fetch for the same kv_request_id would overwrite
350 # `remaining` and leak inflight bookkeeping. Treat as a
351 # protocol violation.
352 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)
353 + st = self._get_or_create_request(kv_request_id)
354 + if st.outbound is None:
355 + st.outbound = _OutboundRequestState()
356 + req = st.outbound
357 + result = req.add_fetch_demand(keys, block_indexes)
358 if result.local_idxs:
359 self._submit_transfer(kv_request_id, result)
360 + # Close the peer's request as far as the server's lookup phase
361 + # is concerned: pop parked lookups so no further
362 + # ``parent.create_store_job`` fires for this id, and queue their
363 + # ctxs for ``parent.on_request_finished``. Done before the
364 + # finalize path below so bookkeeping releases in-order.
365 + self._finish_inbound_lookups(kv_request_id)
366 # Prefiller-first mode: finish_request may have run before
367 # fetch arrived. If so, finalize once we know what was
368 # demanded — fully satisfied → success, else early-fail.
@@ -230,9 +373,9 @@ class ServerRole:
373 """Handle an AbortFetchMsg from the peer."""
374 # Abort for an unknown id may be a benign race/duplicate or a
375 # 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 - ):
376 + st = self._requests.get(kv_request_id)
377 + has_outbound = st is not None and st.outbound is not None
378 + if not has_outbound and not self._has_inflight_for(kv_request_id):
379 logger.warning(
380 "P2PSession %s: abort_fetch for unknown kv_request_id=%s "
381 "(no outbound or inflight state); benign race or stale",
@@ -242,9 +385,285 @@ class ServerRole:
385 # Idempotent: receiving AbortFetchMsg again before we've sent the
386 # ack just triggers another drain attempt without resetting the
387 # deadline.
245 - self._pending_aborts.setdefault(kv_request_id, time.monotonic())
388 + st = self._get_or_create_request(kv_request_id)
389 + if st.abort_started_at is None:
390 + st.abort_started_at = time.monotonic()
391 + self._parked_aborts.add(kv_request_id)
392 self._drain_abort(kv_request_id)
393
394 + def on_lookup(
395 + self,
396 + kv_request_id: str,
397 + keys: Sequence[OffloadKey],
398 + ) -> None:
399 + """Enqueue a LookupMsg from a symmetric-P2P consumer.
400 +
401 + Dispatch runs during ``session.poll()`` where the
402 + :class:`ParentManager` handle is not available, so this only
403 + records the raw request. It is resolved — querying the tiering
404 + manager and emitting the aggregated ``LookupRespMsg`` — by the
405 + next :meth:`serve_external_requests`, the sole window in which
406 + parent calls are valid.
407 + """
408 + logger.debug(
409 + "P2P LOOKUP server %s: RECV LookupMsg kv_request_id=%s keys=%d",
410 + self._peer_id,
411 + kv_request_id,
412 + len(keys),
413 + )
414 + self._get_or_create_request(kv_request_id).pending_lookups.append(
415 + _PendingLookup(keys=list(keys), enqueued_at=time.monotonic())
416 + )
417 + self._serve_pending.add(kv_request_id)
418 +
419 + def serve_external_requests(self, parent: ParentManager) -> None:
420 + """Resolve inbound peer lookups against the tiering manager.
421 +
422 + Called once per scheduler step with a ``parent`` handle valid
423 + only for this call. Drains newly-enqueued LookupMsgs, re-polls
424 + any parked HIT_PENDING / RETRY keys, and releases the
425 + bookkeeping for lookups closed since the last serve.
426 + """
427 + for kv_request_id in list(self._serve_pending):
428 + st = self._requests.get(kv_request_id)
429 + if st is None:
430 + self._serve_pending.discard(kv_request_id)
431 + continue
432 + if st.pending_lookups:
433 + pending = st.pending_lookups
434 + st.pending_lookups = []
435 + for pl in pending:
436 + self._process_inbound_lookup(
437 + kv_request_id, pl.keys, pl.enqueued_at, parent
438 + )
439 + self._resolve_pending_lookups(kv_request_id, parent)
440 + st = self._requests.get(kv_request_id)
441 + if st is None or (not st.pending_lookups and not st.lookups):
442 + self._serve_pending.discard(kv_request_id)
443 + self._maybe_prune(kv_request_id)
444 +
445 + if self._finished_lookup_ctxs:
446 + for ctx in self._finished_lookup_ctxs:
447 + parent.on_request_finished(ctx)
448 + self._finished_lookup_ctxs = []
449 +
450 + def _poll_lookup_keys(
451 + self,
452 + lookup: _ActiveLookup,
453 + keys: Iterable[OffloadKey],
454 + parent: ParentManager,
455 + ) -> list[OffloadKey]:
456 + """Poll ``keys`` against the tiering manager and pin any HITs.
457 +
458 + For each key not already definitively resolved, query
459 + ``parent.lookup`` and record the outcome on ``lookup``: HIT / MISS
460 + land in ``resolved`` and clear ``pending``; HIT_PENDING / RETRY park
461 + in ``pending`` for a later serve. Newly-HIT keys are pinned in one
462 + batch via :meth:`_pin_and_register_hits` and also returned (for
463 + caller logging).
464 +
465 + Shared by the first-sighting pass (:meth:`_process_inbound_lookup`)
466 + and the re-poll pass (:meth:`_resolve_pending_lookups`); callers pass
467 + a de-duplicated ``keys`` collection.
468 + """
469 + new_hits: list[OffloadKey] = []
470 + for h in keys:
471 + if h in lookup.resolved:
472 + continue
473 + result = parent.lookup(h, lookup.ctx)
474 + if result is LookupResult.HIT:
475 + new_hits.append(h)
476 + lookup.resolved[h] = True
477 + lookup.pending.discard(h)
478 + elif result is LookupResult.MISS:
479 + lookup.resolved[h] = False
480 + lookup.pending.discard(h)
481 + else:
482 + lookup.pending.add(h)
483 + if new_hits:
484 + self._pin_and_register_hits(
485 + lookup.kv_request_id, new_hits, lookup.ctx, parent
486 + )
487 + return new_hits
488 +
489 + def _process_inbound_lookup(
490 + self,
491 + kv_request_id: str,
492 + keys: list[OffloadKey],
493 + enqueued_at: float,
494 + parent: ParentManager,
495 + ) -> None:
496 + """Resolve one enqueued LookupMsg against ``parent``.
497 +
498 + For each key, query the tiering manager via ``parent.lookup``
499 + and pin any HITs immediately via ``parent.create_store_job``
500 + (plumbed into the existing ``add_stored_blocks`` matching path so
501 + the eventual FetchMsg finds them). HIT_PENDING / RETRY keys park
502 + in :class:`_ActiveLookup` for re-polling by
503 + :meth:`_resolve_pending_lookups`.
504 +
505 + The outbound ``LookupRespMsg`` is deferred until every key has
506 + settled to HIT or MISS (or the batch-level ``deadline`` fires,
507 + forcing any stragglers to MISS). One LookupRespMsg goes out per
508 + LookupMsg — carrying every key in wire order — after which
509 + ``parent.on_request_finished`` fires and the entry is dropped.
510 + """
511 + self._lookup_id_counter += 1
512 + lookup_id = self._lookup_id_counter
513 + ctx = ReqContext(req_id=f"p2p:{self._peer_id}:{kv_request_id}:lu{lookup_id}")
514 + lookup = _ActiveLookup(
515 + lookup_id=lookup_id,
516 + kv_request_id=kv_request_id,
517 + ctx=ctx,
518 + keys=list(keys),
519 + deadline=enqueued_at + _LOOKUP_PENDING_TIMEOUT_S,
520 + )
521 +
522 + # Open per-request bookkeeping for this synthetic ctx before the
523 + # first lookup; released by ``on_request_finished`` once every
524 + # key has settled.
525 + parent.on_new_request(ctx)
526 +
527 + # dict.fromkeys de-duplicates keys within the LookupMsg while
528 + # preserving wire order — each unique key is polled once.
529 + hit_keys = self._poll_lookup_keys(lookup, dict.fromkeys(lookup.keys), parent)
530 +
531 + logger.debug(
532 + "P2P LOOKUP server %s: RESOLVED kv_request_id=%s hits=%d misses=%d "
533 + "pending=%d",
534 + self._peer_id,
535 + kv_request_id,
536 + len(hit_keys),
537 + sum(1 for v in lookup.resolved.values() if not v),
538 + len(lookup.pending),
539 + )
540 +
541 + if lookup.pending:
542 + self._get_or_create_request(kv_request_id).lookups[lookup_id] = lookup
543 + else:
544 + # Every key resolved on first sight — emit the aggregated
545 + # response now and close the synthetic request.
546 + self._finalize_lookup(lookup, parent)
547 +
548 + def _pin_and_register_hits(
549 + self,
550 + kv_request_id: str,
551 + keys: list[OffloadKey],
552 + ctx: ReqContext,
553 + parent: ParentManager,
554 + ) -> None:
555 + """Pin primary slots for HIT keys and feed them into the
556 + existing ``add_stored_blocks`` matching path.
557 +
558 + Caller has already confirmed every key is HIT (single-threaded
559 + scheduler ⇒ no eviction race), so the JobMetadata returned by
560 + ``parent.create_store_job`` carries parallel ``keys``/``block_ids``
561 + of length ``len(keys)``.
562 + """
563 + meta = parent.create_store_job(keys, ctx)
564 + self.add_stored_blocks(
565 + kv_request_id,
566 + list(meta.keys),
567 + list(meta.block_ids),
568 + meta.job_id,
569 + )
570 +
571 + def _resolve_pending_lookups(
572 + self, kv_request_id: str, parent: ParentManager
573 + ) -> None:
574 + """Re-poll a request's deferred LookupMsg keys; finalize when ready.
575 +
576 + Walks every parked :class:`_ActiveLookup` for ``kv_request_id`` and
577 + re-calls ``parent.lookup`` per still-pending key, moving HIT/MISS
578 + results into ``resolved``. If ``deadline`` has passed, remaining
579 + ``pending`` keys are force-resolved to MISS so the consumer
580 + can fall back instead of waiting on a stuck producer. Newly-HIT
581 + keys are pinned via ``parent.create_store_job`` in one call per
582 + affected lookup. Lookups whose ``pending`` empties out get their
583 + aggregated LookupRespMsg sent by :meth:`_finalize_lookup`.
584 + """
585 + st = self._requests.get(kv_request_id)
586 + if st is None or not st.lookups:
587 + return
588 + now = time.monotonic()
589 + finished_lookups: list[int] = []
590 + for lookup_id, lookup in st.lookups.items():
591 + self._poll_lookup_keys(lookup, list(lookup.pending), parent)
592 +
593 + if lookup.pending and now >= lookup.deadline:
594 + for h in lookup.pending:
595 + lookup.resolved[h] = False
596 + lookup.pending.clear()
597 +
598 + if not lookup.pending:
599 + finished_lookups.append(lookup_id)
600 +
601 + for lookup_id in finished_lookups:
602 + lookup = st.lookups.pop(lookup_id)
603 + self._finalize_lookup(lookup, parent)
604 +
605 + def _finalize_lookup(self, lookup: _ActiveLookup, parent: ParentManager) -> None:
606 + """Emit the aggregated LookupRespMsg and close the synthetic request.
607 +
608 + Called once per lookup when ``pending`` is empty — either every
609 + key resolved to HIT or MISS, or the deadline forced remaining
610 + stragglers to MISS. Preserves the wire order of the inbound
611 + LookupMsg so the client can zip keys and hits positionally.
612 + """
613 + if lookup.keys:
614 + hits = [lookup.resolved[h] for h in lookup.keys]
615 + n_hit = sum(1 for v in hits if v)
616 + logger.debug(
617 + "P2P LOOKUP server %s: SEND LookupRespMsg kv_request_id=%s "
618 + "keys=%d hits=%d misses=%d",
619 + self._peer_id,
620 + lookup.kv_request_id,
621 + len(lookup.keys),
622 + n_hit,
623 + len(hits) - n_hit,
624 + )
625 + self._send(
626 + {
627 + TYPE_KEY: LookupRespMsg.TYPE,
628 + LookupRespMsg.KV_REQUEST_ID: lookup.kv_request_id,
629 + LookupRespMsg.KEYS: list(lookup.keys),
630 + LookupRespMsg.HITS: hits,
631 + }
632 + )
633 + parent.on_request_finished(lookup.ctx)
634 +
635 + def _finish_inbound_lookups(self, kv_request_id: str) -> None:
636 + """Close the server-side lookup phase for ``kv_request_id``.
637 +
638 + Pops every parked ``_ActiveLookup`` for this id (so
639 + ``_resolve_pending_lookups`` cannot promote a HIT_PENDING /
640 + RETRY key into a fresh ``parent.create_store_job`` after this
641 + point) and queues each ``lookup.ctx`` for
642 + ``parent.on_request_finished`` (fired by the next
643 + ``serve_external_requests``, since no parent handle is available
644 + during dispatch) so the TieringManager can release per-lookup
645 + bookkeeping. Any still-unprocessed raw LookupMsg for this id is
646 + dropped — it never got ``on_new_request``, so nothing is owed.
647 + The aggregated LookupRespMsg is skipped — the client already
648 + knows the request is over (it just sent a terminal FetchMsg, or
649 + is finishing locally).
650 +
651 + Called on the two events that mean "no more lookup traffic for
652 + ``kv_request_id`` is expected on this session": the terminal
653 + FetchMsg from the peer (client contract: exactly one FetchMsg
654 + per lookup-touched request, even if empty), and a local
655 + ``finish``. Whichever fires second is a no-op.
656 + """
657 + st = self._requests.get(kv_request_id)
658 + if st is None:
659 + return
660 + st.pending_lookups.clear()
661 + for lookup in st.lookups.values():
662 + self._finished_lookup_ctxs.append(lookup.ctx)
663 + st.lookups.clear()
664 + self._serve_pending.discard(kv_request_id)
665 + self._maybe_prune(kv_request_id)
666 +
667 def finish(self, kv_request_id: str) -> None:
668 """Mark an outbound request finishing.
669
@@ -253,13 +672,23 @@ class ServerRole:
672 peer to stop waiting (TransferDoneMsg success=False) instead of
673 letting it hit _LOAD_TIMEOUT_S.
674
675 + Also drops any in-flight lookups for this kv_request_id and
676 + queues their ctxs for ``parent.on_request_finished`` so the
677 + TieringManager can release per-request bookkeeping. (For
678 + symmetric P2P this path is rarely hit since the producer has no
679 + local request lifecycle for the consumer's id; this cleanup is
680 + mostly active on the PD side.)
681 +
682 If the decoder hasn't sent fetch yet (no demand received),
683 defer — on_fetch will finalize once demand arrives.
684
685 If inflight transfers exist for this id, defer — the last
686 completing transfer in collect_results will fire the message.
687 """
262 - req = self._outbound.get(kv_request_id)
688 + self._finish_inbound_lookups(kv_request_id)
689 +
690 + st = self._requests.get(kv_request_id)
691 + req = st.outbound if st is not None else None
692 if req is None:
693 return
694 req.finishing = True
@@ -268,14 +697,19 @@ class ServerRole:
697 if self._has_inflight_for(kv_request_id):
698 return
699 # Remaining > 0 here: if it had hit 0, the poll-done success
271 - # branch would have already popped _outbound and we'd have
700 + # branch would have already cleared outbound and we'd have
701 # returned at `req is None` above. Helper derives success from
702 # remaining and emits StoreResult(success=False) for any
703 # leftover pending jobs.
704 self._finalize_outbound(kv_request_id)
705
706 def collect_results(self) -> list[StoreResult]:
278 - """Drain timeouts, deferred results, and transport completions."""
707 + """Drain timeouts, deferred results, and transport completions.
708 +
709 + Inbound LookupMsg resolution (including re-polling HIT_PENDING /
710 + RETRY keys) is NOT done here — it runs in
711 + ``serve_external_requests`` where the ParentManager is available.
712 + """
713 results: list[StoreResult] = self._timeout_pending_store_jobs()
714
715 if self._pending_store_results:
@@ -306,15 +740,9 @@ class ServerRole:
740 tid,
741 )
742 continue
309 - req = self._outbound.get(xfer.kv_request_id)
310 - for job_id in xfer.job_ids:
311 - if self._store_jobs.pop(job_id, None) is None:
312 - # Already reported (timeout, cancellation, etc.) —
313 - # don't double-emit a contradictory success result.
314 - continue
315 - results.append(StoreResult(job_id=job_id, success=True))
316 - if req is not None:
317 - req.pending_job_ids.discard(job_id)
743 + results.extend(self._settle_xfer_jobs(xfer, success=True))
744 + st = self._requests.get(xfer.kv_request_id)
745 + req = st.outbound if st is not None else None
746 if req is not None and req.demand_received:
747 req.remaining -= xfer.block_count
748 assert req.remaining >= 0, (
@@ -324,6 +752,7 @@ class ServerRole:
752 self._finalize_outbound(xfer.kv_request_id, success=True)
753 elif req.finishing and not self._has_inflight_for(xfer.kv_request_id):
754 self._finalize_outbound(xfer.kv_request_id, success=False)
755 + self._maybe_prune(xfer.kv_request_id)
756
757 failed_kv_request_ids: set[str] | None = None
758 for tid in poll_result.failed:
@@ -341,16 +770,11 @@ class ServerRole:
770 if failed_kv_request_ids is None:
771 failed_kv_request_ids = set()
772 failed_kv_request_ids.add(xfer.kv_request_id)
344 - req_for_xfer = self._outbound.get(xfer.kv_request_id)
345 - for job_id in xfer.job_ids:
346 - if self._store_jobs.pop(job_id, None) is None:
347 - # Already reported (timeout, cancellation, etc.) —
348 - # don't double-emit.
349 - continue
350 - results.append(StoreResult(job_id=job_id, success=False))
351 - if req_for_xfer is not None:
352 - req_for_xfer.pending_job_ids.discard(job_id)
353 - req = self._outbound.pop(xfer.kv_request_id, None)
773 + results.extend(self._settle_xfer_jobs(xfer, success=False))
774 + st = self._requests.get(xfer.kv_request_id)
775 + req = st.outbound if st is not None else None
776 + if st is not None:
777 + st.outbound = None
778 if req is not None and req.demand_received:
779 self._send(
780 {
@@ -359,6 +783,7 @@ class ServerRole:
783 TransferDoneMsg.SUCCESS: False,
784 }
785 )
786 + self._maybe_prune(xfer.kv_request_id)
787
788 # Cancel other inflight for the same failed kv_request_ids
789 if failed_kv_request_ids:
@@ -370,6 +795,8 @@ class ServerRole:
795 for tid in ids_to_cancel:
796 self._inflight_pop(tid)
797 self._transport.cancel(ids_to_cancel)
798 + for kv_request_id in failed_kv_request_ids:
799 + self._maybe_prune(kv_request_id)
800
801 return results
802
@@ -384,54 +811,90 @@ class ServerRole:
811
812 def drain_pending_aborts(self) -> None:
813 """Re-attempt every parked abort once per poll tick."""
387 - if not self._pending_aborts:
388 - return
389 - for kv_request_id in list(self._pending_aborts):
814 + for kv_request_id in list(self._parked_aborts):
815 self._drain_abort(kv_request_id)
816
392 - def close(self) -> list[int]:
393 - """Tear down. Cancels inflight, returns failed store job ids."""
817 + def close(self) -> tuple[list[int], list[ReqContext]]:
818 + """Tear down. Cancels inflight.
819 +
820 + Returns ``(failed_store_job_ids, failed_serves)`` where
821 + ``failed_serves`` are synthetic lookup ctxs still owing a
822 + ``parent.on_request_finished``. The session is going away with no
823 + parent handle in hand, so the manager flushes these in its next
824 + ``serve_external_requests``.
825 + """
826 failed_stores = list(self._store_jobs.keys())
827 self._store_jobs.clear()
828 if self._inflight:
829 self._transport.cancel(list(self._inflight.keys()))
830 self._inflight.clear()
399 - self._inflight_per_req.clear()
400 - self._outbound.clear()
401 - self._pending_aborts.clear()
831 self._pending_store_results.clear()
403 - return failed_stores
832 + # Surface every synthetic ctx still owing on_request_finished so
833 + # the manager can release the TieringManager's per-request
834 + # bookkeeping: parked lookups plus any already queued from a
835 + # FetchMsg / finish that closed them before this teardown.
836 + failed_serves = [
837 + lu.ctx for st in self._requests.values() for lu in st.lookups.values()
838 + ]
839 + failed_serves.extend(self._finished_lookup_ctxs)
840 + self._requests.clear()
841 + self._serve_pending.clear()
842 + self._parked_aborts.clear()
843 + self._finished_lookup_ctxs.clear()
844 + return failed_stores, failed_serves
845 +
846 + @property
847 + def has_inflight_transfers(self) -> bool:
848 + """True if any outbound store transfer is still in flight."""
849 + return bool(self._inflight)
850
851 # ------------------------------------------------------------------
852 # Internal — inflight bookkeeping
853 # ------------------------------------------------------------------
854
855 def _has_inflight_for(self, kv_request_id: str) -> bool:
410 - return kv_request_id in self._inflight_per_req
856 + st = self._requests.get(kv_request_id)
857 + return st is not None and bool(st.inflight_tids)
858
859 def _inflight_add(self, tid: int, xfer: _InflightXfer) -> None:
413 - """Insert an inflight transfer and bump the per-request count."""
860 + """Insert an inflight transfer and record it on the request."""
861 self._inflight[tid] = xfer
415 - self._inflight_per_req[xfer.kv_request_id] = (
416 - self._inflight_per_req.get(xfer.kv_request_id, 0) + 1
417 - )
862 + self._get_or_create_request(xfer.kv_request_id).inflight_tids.add(tid)
863
864 def _inflight_pop(self, tid: int) -> _InflightXfer | None:
420 - """Pop an inflight transfer and decrement the per-request count.
865 + """Pop an inflight transfer and drop it from the request's set.
866
422 - Removes the per-request entry once the count hits zero so the
423 - dict stays bounded and `_has_inflight_for` remains exact.
867 + Callers are responsible for the ``_maybe_prune`` that may follow once
868 + the request's other state has also cleared.
869 """
870 xfer = self._inflight.pop(tid, None)
871 if xfer is None:
872 return None
428 - new_count = self._inflight_per_req.get(xfer.kv_request_id, 0) - 1
429 - if new_count > 0:
430 - self._inflight_per_req[xfer.kv_request_id] = new_count
431 - else:
432 - self._inflight_per_req.pop(xfer.kv_request_id, None)
873 + st = self._requests.get(xfer.kv_request_id)
874 + if st is not None:
875 + st.inflight_tids.discard(tid)
876 return xfer
877
878 + def _settle_xfer_jobs(
879 + self, xfer: _InflightXfer, success: bool
880 + ) -> list[StoreResult]:
881 + """Emit StoreResults for a completed transfer's store jobs.
882 +
883 + Pops each attached job from ``_store_jobs`` and clears it from the
884 + request's pending set. A job already popped (via timeout, cancel,
885 + etc.) is skipped so we never double-emit a contradictory result.
886 + """
887 + results: list[StoreResult] = []
888 + st = self._requests.get(xfer.kv_request_id)
889 + req = st.outbound if st is not None else None
890 + for job_id in xfer.job_ids:
891 + if self._store_jobs.pop(job_id, None) is None:
892 + continue
893 + results.append(StoreResult(job_id=job_id, success=success))
894 + if req is not None:
895 + req.pending_job_ids.discard(job_id)
896 + return results
897 +
898 # ------------------------------------------------------------------
899 # Internal — finalize / abort drain
900 # ------------------------------------------------------------------
@@ -452,9 +915,10 @@ class ServerRole:
915 The same flag is used for both the peer's TransferDoneMsg and
916 the StoreResult(s) emitted for any leftover pending job_ids.
917 """
455 - req = self._outbound.pop(kv_request_id, None)
456 - if req is None:
457 - return
918 + st = self._requests[kv_request_id]
919 + assert st.outbound is not None
920 + req = st.outbound
921 + st.outbound = None
922 if success is None:
923 success = req.remaining == 0
924 for job_id in req.pending_job_ids:
@@ -469,6 +933,7 @@ class ServerRole:
933 TransferDoneMsg.SUCCESS: success,
934 }
935 )
936 + self._maybe_prune(kv_request_id)
937
938 def _drain_abort(self, kv_request_id: str) -> None:
939 """One drain attempt for a pending abort.
@@ -479,21 +944,15 @@ class ServerRole:
944 inflight, or after ``_CANCEL_DRAIN_TIMEOUT_S`` falls back to
945 ``mode="immediate"`` and acks anyway.
946 """
482 - self._outbound.pop(kv_request_id, None)
483 - ids = [
484 - tid
485 - for tid, xfer in self._inflight.items()
486 - if xfer.kv_request_id == kv_request_id
487 - ]
947 + st = self._requests[kv_request_id]
948 + st.outbound = None
949 + ids = list(st.inflight_tids)
950 if not ids:
951 self._finalize_abort(kv_request_id)
952 return
953
492 - started_at = self._pending_aborts.get(kv_request_id)
493 - expired = (
494 - started_at is not None
495 - and time.monotonic() - started_at >= _CANCEL_DRAIN_TIMEOUT_S
496 - )
954 + assert st.abort_started_at is not None
955 + expired = time.monotonic() - st.abort_started_at >= _CANCEL_DRAIN_TIMEOUT_S
956 if expired:
957 for tid in ids:
958 self._inflight_pop(tid)
@@ -521,13 +980,16 @@ class ServerRole:
980 self._finalize_abort(kv_request_id)
981
982 def _finalize_abort(self, kv_request_id: str) -> None:
524 - self._pending_aborts.pop(kv_request_id, None)
983 + st = self._requests[kv_request_id]
984 + st.abort_started_at = None
985 + self._parked_aborts.discard(kv_request_id)
986 self._send(
987 {
988 TYPE_KEY: AbortAckMsg.TYPE,
989 AbortAckMsg.KV_REQUEST_ID: kv_request_id,
990 }
991 )
992 + self._maybe_prune(kv_request_id)
993
994 # ------------------------------------------------------------------
995 # Internal — transfers and store-job timeouts
@@ -579,7 +1041,8 @@ class ServerRole:
1041 # nothing else is in flight, finalize now so the peer
1042 # and the local store jobs don't wait for finish_request
1043 # or for _STORE_TIMEOUT_S / _LOAD_TIMEOUT_S.
582 - req = self._outbound.get(kv_request_id)
1044 + st = self._requests.get(kv_request_id)
1045 + req = st.outbound if st is not None else None
1046 if req is not None:
1047 req.finishing = True
1048 if not self._has_inflight_for(kv_request_id):
vllm/v1/kv_offload/tiering/p2p/session/session.py
+120 -19
@@ -34,6 +34,8 @@ from vllm.v1.kv_offload.tiering.p2p.session.protocol import (
34 ConnectMsg,
35 DisconnectMsg,
36 FetchMsg,
37 + LookupMsg,
38 + LookupRespMsg,
39 TransferDoneMsg,
40 )
41 from vllm.v1.kv_offload.tiering.p2p.session.server import (
@@ -42,7 +44,8 @@ from vllm.v1.kv_offload.tiering.p2p.session.server import (
44 )
45
46 if TYPE_CHECKING:
45 - from vllm.v1.kv_offload.tiering.base import JobId
47 + from vllm.v1.kv_offload.base import ReqContext
48 + from vllm.v1.kv_offload.tiering.base import JobId, ParentManager
49 from vllm.v1.kv_offload.tiering.p2p.data import DataTransport
50
51 logger = init_logger(__name__)
@@ -71,6 +74,22 @@ class SessionPollResult(NamedTuple):
74 new_fetch_ids: list[str]
75
76
77 +class SessionCloseResult(NamedTuple):
78 + """Result of tearing down a P2PSession.
79 +
80 + `failed_jobs`/`failed_stores` are the in-flight jobs (client loads /
81 + server stores) the manager must mark failed. `failed_req_ids` is every
82 + client-side kv_request_id whose lookup() must fail (in-flight loads plus
83 + unresolved probes); `failed_serves` is the server-side lookup state the
84 + dead peer can no longer resolve.
85 + """
86 +
87 + failed_jobs: list[int] # client load job_ids
88 + failed_req_ids: list[str] # client kv_request_ids (loads + probes)
89 + failed_stores: list[int] # server store job_ids
90 + failed_serves: list[ReqContext] # server-side lookup ctxs needing release
91 +
92 +
93 class P2PSession:
94 """Bidirectional session — coordinator over ClientRole + ServerRole.
95
@@ -93,12 +112,14 @@ class P2PSession:
112 local_id: str,
113 transport: DataTransport,
114 local_block_len: int,
115 + local_hash_seed: str,
116 conn: ControlConnection | None = None,
117 ) -> None:
118 self.peer_id = peer_id
119 self._local_id = local_id
120 self._transport = transport
121 self._local_block_len = local_block_len
122 + self._local_hash_seed = local_hash_seed
123 self._conn: ControlConnection | None = None
124
125 self._send_ready = False # True after the peer acked our ConnectMsg
@@ -115,7 +136,11 @@ class P2PSession:
136 self._new_fetch_ids: list[str] = []
137
138 self._client = ClientRole(peer_id=peer_id, send=self._send)
118 - self._server = ServerRole(peer_id=peer_id, transport=transport, send=self._send)
139 + self._server = ServerRole(
140 + peer_id=peer_id,
141 + transport=transport,
142 + send=self._send,
143 + )
144
145 if conn is not None:
146 self.attach_connection(conn)
@@ -139,6 +164,11 @@ class P2PSession:
164 """True after the peer acked our ConnectMsg (we may send freely)."""
165 return self._send_ready
166
167 + @property
168 + def has_pending_work(self) -> bool:
169 + """True while inbound loads or outbound transfers are outstanding."""
170 + return self._client.has_active_loads or self._server.has_inflight_transfers
171 +
172 # ------------------------------------------------------------------
173 # Connection lifecycle
174 # ------------------------------------------------------------------
@@ -162,7 +192,7 @@ class P2PSession:
192 self,
193 job_id: JobId,
194 kv_request_id: str,
165 - keys: Sequence[bytes],
195 + keys: Sequence[OffloadKey],
196 block_ids: Sequence[int],
197 ) -> None:
198 """Send fetch to the peer."""
@@ -183,13 +213,40 @@ class P2PSession:
213 def finish_request(self, kv_request_id: str) -> None:
214 """Called when the request is finishing locally.
215
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.
216 + Finishes the client role (aborts any inbound load and drops any
217 + pending symmetric-P2P lookup state) and finalizes any outbound
218 + serving (server role) for this id. Roles that aren't active for
219 + this id are silent no-ops.
220 """
190 - self._client.cancel(kv_request_id)
221 + self._client.finish(kv_request_id)
222 self._server.finish(kv_request_id)
223
224 + def register_lookup(self, kv_request_id: str, key: bytes) -> bool | None:
225 + """Register or resolve one (kv_request_id, key) probe.
226 +
227 + Called from the manager's lookup() for symmetric-P2P consumers
228 + (``remote_kv_source`` sub-dict in kv_transfer_params). See
229 + ``ClientRole.register_lookup`` for the state-machine contract.
230 + """
231 + return self._client.register_lookup(kv_request_id, key)
232 +
233 + def flush_pending_lookups(self) -> None:
234 + """Flush any aggregated symmetric-P2P lookups for this peer.
235 +
236 + Called once per scheduler step from the manager's
237 + ``on_schedule_end()``. Send-gating is handled inside the
238 + client's ``_send`` callback (queues until ConnectAckMsg).
239 + """
240 + self._client.flush_pending_lookups()
241 +
242 + def serve_external_requests(self, parent: ParentManager) -> None:
243 + """Resolve inbound peer lookups against the tiering manager.
244 +
245 + Delegates to the server role; the ``parent`` handle is valid
246 + only for the duration of this call.
247 + """
248 + self._server.serve_external_requests(parent)
249 +
250 def poll(self) -> SessionPollResult:
251 """Process incoming messages, drive transfers, apply timeouts."""
252 if self._conn is None:
@@ -214,14 +271,22 @@ class P2PSession:
271 loads=loads, stores=stores, new_fetch_ids=new_fetch_ids
272 )
273
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.
274 + def close(self) -> SessionCloseResult:
275 + """Shut down.
276 +
277 + failed_jobs: client load job_ids to fail.
278 + failed_req_ids: client kv_request_ids to fail — in-flight loads plus
279 + requests with an unresolved symmetric-P2P probe toward the
280 + now-dead peer. The manager fails these so the consumer's lookup()
281 + falls back to local prefill instead of deferring forever on an
282 + answer that can never arrive.
283 + failed_stores: server store job_ids to fail.
284 + failed_serves: synthetic lookup ctxs still owing
285 + ``parent.on_request_finished`` (the manager flushes these on
286 + its next ``serve_external_requests``).
287 """
223 - failed_loads = self._client.close()
224 - failed_stores = self._server.close()
288 + client_result = self._client.close()
289 + failed_stores, failed_serves = self._server.close()
290
291 if self._conn is not None:
292 with contextlib.suppress(Exception):
@@ -229,7 +294,12 @@ class P2PSession:
294 self._conn.close()
295 self._conn = None
296
232 - return failed_loads, failed_stores
297 + return SessionCloseResult(
298 + failed_jobs=client_result.failed_jobs,
299 + failed_req_ids=client_result.failed_req_ids,
300 + failed_stores=failed_stores,
301 + failed_serves=failed_serves,
302 + )
303
304 # ------------------------------------------------------------------
305 # Message dispatch
@@ -299,9 +369,9 @@ class P2PSession:
369 elif msg_type == FetchMsg.TYPE:
370 FetchMsg.validate(msg)
371 kv_request_id = msg[FetchMsg.KV_REQUEST_ID]
302 - block_hashes = [
372 + keys = [
373 OffloadKey(bh if isinstance(bh, bytes) else bytes(bh))
304 - for bh in msg[FetchMsg.BLOCK_HASHES]
374 + for bh in msg[FetchMsg.KEYS]
375 ]
376 block_indexes = msg[FetchMsg.BLOCK_INDEXES]
377 # Run the server-role state machine inline as today —
@@ -310,7 +380,7 @@ class P2PSession:
380 # the manager (after poll() returns) can replay any parked
381 # submit_store batches; their add_stored_blocks calls hit
382 # the demand recorded here and submit transfers immediately.
313 - self._server.on_fetch(kv_request_id, block_hashes, block_indexes)
383 + self._server.on_fetch(kv_request_id, keys, block_indexes)
384 self._new_fetch_ids.append(kv_request_id)
385 elif msg_type == AbortFetchMsg.TYPE:
386 AbortFetchMsg.validate(msg)
@@ -324,6 +394,23 @@ class P2PSession:
394 elif msg_type == AbortAckMsg.TYPE:
395 AbortAckMsg.validate(msg)
396 self._client.on_abort_ack(msg[AbortAckMsg.KV_REQUEST_ID])
397 + elif msg_type == LookupMsg.TYPE:
398 + LookupMsg.validate(msg)
399 + kv_request_id = msg[LookupMsg.KV_REQUEST_ID]
400 + keys = [
401 + OffloadKey(bh if isinstance(bh, bytes) else bytes(bh))
402 + for bh in msg[LookupMsg.KEYS]
403 + ]
404 + self._server.on_lookup(kv_request_id, keys)
405 + elif msg_type == LookupRespMsg.TYPE:
406 + LookupRespMsg.validate(msg)
407 + kv_request_id = msg[LookupRespMsg.KV_REQUEST_ID]
408 + keys = [
409 + OffloadKey(bh if isinstance(bh, bytes) else bytes(bh))
410 + for bh in msg[LookupRespMsg.KEYS]
411 + ]
412 + hits = msg[LookupRespMsg.HITS]
413 + self._client.on_lookup_resp(kv_request_id, keys, hits)
414 elif msg_type == DisconnectMsg.TYPE:
415 if self._conn is not None:
416 self._conn.mark_dead()
@@ -361,6 +448,12 @@ class P2PSession:
448 f"config fingerprint mismatch from {self.peer_id}: "
449 f"remote={remote_fp!r}, local={local_fp!r}"
450 )
451 + if msg[ConnectMsg.HASH_SEED] != self._local_hash_seed:
452 + raise ValueError(
453 + f"PYTHONHASHSEED mismatch from {self.peer_id}: "
454 + f"remote={msg[ConnectMsg.HASH_SEED]!r}, "
455 + f"local={self._local_hash_seed!r}"
456 + )
457 self._transport.add_remote_peer(
458 self.peer_id,
459 agent_metadata=msg[ConnectMsg.AGENT_METADATA],
@@ -410,6 +503,7 @@ class P2PSession:
503 ConnectMsg.NUM_BLOCKS: self._transport.num_blocks,
504 ConnectMsg.BLOCK_LEN: self._transport.block_len,
505 ConnectMsg.CONFIG_FINGERPRINT: self._transport.config_fingerprint,
506 + ConnectMsg.HASH_SEED: self._local_hash_seed,
507 }
508 )
509
@@ -433,8 +527,15 @@ class P2PSession:
527 self._conn.send(msg)
528 logger.debug("P2PSession %s: sent %s", self.peer_id, msg.get(TYPE_KEY))
529 except Exception:
530 + # A send failure means the connection is broken. Swallowing it
531 + # silently strands every in-flight lookup/load toward this peer:
532 + # the session stays alive, is never reaped, and the consumer's
533 + # lookup() keeps returning RETRY until the HTTP client times out.
534 + # Mark the connection dead so the manager reaps the session on
535 + # its next poll and surfaces the stranded work as failures.
536 logger.warning(
437 - "P2PSession %s: failed to send %s",
537 + "P2PSession %s: send of %s failed — marking connection dead",
538 self.peer_id,
539 msg.get(TYPE_KEY),
540 )
541 + self._conn.mark_dead()