[Bugfix][KV Offload][P2P] Scope serve state to fetch rounds (#49877)

Signed-off-by: Itay Etelis <itay.etelis@ibm.com> Co-authored-by: Itay Etelis <itay.etelis@ibm.com> Co-authored-by: Itay Etelis <Itay.etelis@gmail.com>

Itay Etelis committed Jul 28, 2026 at 18:01 UTC 30217b0e809ef045b3b4aa7c011d6c6d2b0b0362
6 files changed +573 -393
tests/v1/kv_offload/tiering/p2p/test_manager.py
+7 -6
@@ -1612,16 +1612,17 @@ class TestBindHostPortDefaults:
1612 monkeypatch.setattr(
1613 manager_module,
1614 "NixlTransport",
1615 - lambda agent_name, *a, **k: calls.update(nixl_name=agent_name)
1616 - or SimpleNamespace(),
1615 + lambda agent_name, *a, **k: (
1616 + calls.update(nixl_name=agent_name) or SimpleNamespace()
1617 + ),
1618 )
1619 monkeypatch.setattr(
1620 manager_module,
1621 "ZmqTransport",
1621 - lambda local_id, host, port, *a, **k: calls.update(
1622 - zmq_id=local_id, zmq_host=host, zmq_port=port
1623 - )
1624 - or SimpleNamespace(),
1622 + lambda local_id, host, port, *a, **k: (
1623 + calls.update(zmq_id=local_id, zmq_host=host, zmq_port=port)
1624 + or SimpleNamespace()
1625 + ),
1626 )
1627 spec = SimpleNamespace(
1628 blocks_per_chunk=1,
tests/v1/kv_offload/tiering/p2p/test_sessions.py
+100 -16
@@ -49,6 +49,7 @@ from vllm.v1.kv_offload.tiering.p2p.session.protocol import (
49 from vllm.v1.kv_offload.tiering.p2p.session.server import (
50 _CANCEL_DRAIN_TIMEOUT_S,
51 _InflightXfer,
52 + _OutboundRequestState,
53 )
54 from vllm.v1.kv_offload.tiering.p2p.session.session import (
55 _MAX_CONSECUTIVE_DISPATCH_ERRORS,
@@ -315,10 +316,26 @@ def _activate(
316 # either a missing entry or a None field. These helpers paper over that.
317
318
319 +def _client_load(session: P2PSession, kv_request_id: str):
320 + """The single in-flight load of a kv_request_id (loads are per-round)."""
321 + loads = session._client._requests[kv_request_id].loads
322 + assert len(loads) == 1
323 + return next(iter(loads.values()))
324 +
325 +
326 def _srv_outbound(session: P2PSession, kv_request_id: str):
319 - """Outbound serve state for a kv_request_id, or None (idle / GC'd)."""
327 + """Serve-side round for a kv_request_id, or None (idle / GC'd).
328 +
329 + Rounds are keyed by wire round_seq; surfaces the demanded round when
330 + a fetch has bound one, else any parked supply round.
331 + """
332 st = session._server._requests.get(kv_request_id)
321 - return st.outbound if st is not None else None
333 + if st is None or not st.outbound:
334 + return None
335 + for rnd in st.outbound.values():
336 + if rnd.demand_received:
337 + return rnd
338 + return next(iter(st.outbound.values()))
339
340
341 def _srv_lookups(session: P2PSession) -> list:
@@ -330,8 +347,10 @@ def _srv_lookups(session: P2PSession) -> list:
347
348 def _srv_abort_started(session: P2PSession, kv_request_id: str) -> float | None:
349 """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
350 + for (kv, _), started in session._server._pending_aborts.items():
351 + if kv == kv_request_id:
352 + return started
353 + return None
354
355
356 def _srv_inflight_count(session: P2PSession, kv_request_id: str) -> int:
@@ -479,6 +498,7 @@ class TestClientFlows:
498 conn.enqueue(
499 {
500 TYPE_KEY: TransferDoneMsg.TYPE,
501 + TransferDoneMsg.ROUND_SEQ: 0,
502 TransferDoneMsg.KV_REQUEST_ID: "req-1",
503 TransferDoneMsg.SUCCESS: True,
504 }
@@ -495,6 +515,7 @@ class TestClientFlows:
515 conn.enqueue(
516 {
517 TYPE_KEY: TransferDoneMsg.TYPE,
518 + TransferDoneMsg.ROUND_SEQ: 0,
519 TransferDoneMsg.KV_REQUEST_ID: "req-1",
520 TransferDoneMsg.SUCCESS: False,
521 }
@@ -538,6 +559,7 @@ class TestClientFlows:
559 conn.enqueue(
560 {
561 TYPE_KEY: TransferDoneMsg.TYPE,
562 + TransferDoneMsg.ROUND_SEQ: 0,
563 TransferDoneMsg.KV_REQUEST_ID: "req-1",
564 TransferDoneMsg.SUCCESS: True,
565 }
@@ -552,7 +574,7 @@ class TestClientFlows:
574 session.request_blocks(
575 job_id=1, kv_request_id="req-1", keys=[b"k"], block_ids=[0]
576 )
555 - session._client._requests["req-1"].load.submitted_at = time.monotonic() - 60.0
577 + _client_load(session, "req-1").submitted_at = time.monotonic() - 60.0
578 session.poll()
579 abort = conn._sent[-1]
580 assert abort[TYPE_KEY] == AbortFetchMsg.TYPE
@@ -569,7 +591,7 @@ class TestClientFlows:
591 job_id=7, kv_request_id="req-7", keys=[b"k"], block_ids=[0]
592 )
593 # 1) Trip the load timeout to send AbortFetch and stamp aborted_at.
572 - session._client._requests["req-7"].load.submitted_at = (
594 + _client_load(session, "req-7").submitted_at = (
595 time.monotonic() - _LOAD_TIMEOUT_S - 1.0
596 )
597 loads = session.poll().loads
@@ -579,11 +601,11 @@ class TestClientFlows:
601 and m[AbortFetchMsg.KV_REQUEST_ID] == "req-7"
602 for m in conn._sent
603 )
582 - assert session._client._requests["req-7"].load.aborted_at is not None
604 + assert _client_load(session, "req-7").aborted_at is not None
605
606 # 2) Now backdate aborted_at past the abort-ack timeout. No ack ever
607 # arrived from the peer.
586 - session._client._requests["req-7"].load.aborted_at = (
608 + _client_load(session, "req-7").aborted_at = (
609 time.monotonic() - _ABORT_ACK_TIMEOUT_S - 1.0
610 )
611 loads = session.poll().loads
@@ -599,17 +621,18 @@ class TestClientFlows:
621 session.request_blocks(
622 job_id=8, kv_request_id="req-8", keys=[b"k"], block_ids=[0]
623 )
602 - session._client._requests["req-8"].load.submitted_at = (
624 + _client_load(session, "req-8").submitted_at = (
625 time.monotonic() - _LOAD_TIMEOUT_S - 1.0
626 )
627 # First poll: AbortFetch goes out.
628 session.poll()
607 - assert session._client._requests["req-8"].load.aborted_at is not None
629 + assert _client_load(session, "req-8").aborted_at is not None
630
631 # Peer acks the abort.
632 conn.enqueue(
633 {
634 TYPE_KEY: AbortAckMsg.TYPE,
635 + AbortAckMsg.ROUND_SEQ: 0,
636 AbortAckMsg.KV_REQUEST_ID: "req-8",
637 }
638 )
@@ -915,6 +938,7 @@ class TestLookupFlow:
938 conn.enqueue(
939 {
940 TYPE_KEY: LookupMsg.TYPE,
941 + LookupMsg.ROUND_SEQ: 0,
942 LookupMsg.KV_REQUEST_ID: "req-1",
943 LookupMsg.KEYS: [b"hX", b"hY", b"hZ"],
944 }
@@ -951,6 +975,7 @@ def _send_lookup(conn: FakeConnection, kv_request_id: str, keys: list[bytes]):
975 conn.enqueue(
976 {
977 TYPE_KEY: LookupMsg.TYPE,
978 + LookupMsg.ROUND_SEQ: 0,
979 LookupMsg.KV_REQUEST_ID: kv_request_id,
980 LookupMsg.KEYS: list(keys),
981 }
@@ -1201,6 +1226,7 @@ class TestServerLookupHandling:
1226 conn.enqueue(
1227 {
1228 TYPE_KEY: FetchMsg.TYPE,
1229 + FetchMsg.ROUND_SEQ: 0,
1230 FetchMsg.KV_REQUEST_ID: "req-1",
1231 FetchMsg.KEYS: [],
1232 FetchMsg.BLOCK_INDEXES: [],
@@ -1240,6 +1266,7 @@ class TestServerLookupHandling:
1266 conn.enqueue(
1267 {
1268 TYPE_KEY: FetchMsg.TYPE,
1269 + FetchMsg.ROUND_SEQ: 0,
1270 FetchMsg.KV_REQUEST_ID: "req-1",
1271 FetchMsg.KEYS: [b"hA", b"hB"],
1272 FetchMsg.BLOCK_INDEXES: [20, 21],
@@ -1277,6 +1304,7 @@ class TestServerFlows:
1304 conn.enqueue(
1305 {
1306 TYPE_KEY: FetchMsg.TYPE,
1307 + FetchMsg.ROUND_SEQ: 0,
1308 FetchMsg.KV_REQUEST_ID: "req-1",
1309 FetchMsg.KEYS: [b"k1", b"k2"],
1310 FetchMsg.BLOCK_INDEXES: [10, 11],
@@ -1295,6 +1323,7 @@ class TestServerFlows:
1323 conn.enqueue(
1324 {
1325 TYPE_KEY: FetchMsg.TYPE,
1326 + FetchMsg.ROUND_SEQ: 0,
1327 FetchMsg.KV_REQUEST_ID: "req-1",
1328 FetchMsg.KEYS: [b"k1"],
1329 FetchMsg.BLOCK_INDEXES: [5],
@@ -1313,6 +1342,7 @@ class TestServerFlows:
1342 conn.enqueue(
1343 {
1344 TYPE_KEY: FetchMsg.TYPE,
1345 + FetchMsg.ROUND_SEQ: 0,
1346 FetchMsg.KV_REQUEST_ID: "req-1",
1347 FetchMsg.KEYS: [b"k1"],
1348 FetchMsg.BLOCK_INDEXES: [5],
@@ -1331,6 +1361,7 @@ class TestServerFlows:
1361 conn.enqueue(
1362 {
1363 TYPE_KEY: AbortFetchMsg.TYPE,
1364 + AbortFetchMsg.ROUND_SEQ: 0,
1365 AbortFetchMsg.KV_REQUEST_ID: "req-1",
1366 }
1367 )
@@ -1349,13 +1380,19 @@ class TestServerFlows:
1380 tid = 42
1381 session._server._inflight_add(
1382 tid,
1352 - _InflightXfer(kv_request_id="req-1", block_count=1, job_ids={1}),
1383 + _InflightXfer(
1384 + kv_request_id="req-1",
1385 + block_count=1,
1386 + job_ids={1},
1387 + round=_OutboundRequestState(inflight=1),
1388 + ),
1389 )
1390 transport._cancel_still_inflight.add(tid)
1391
1392 conn.enqueue(
1393 {
1394 TYPE_KEY: AbortFetchMsg.TYPE,
1395 + AbortFetchMsg.ROUND_SEQ: 0,
1396 AbortFetchMsg.KV_REQUEST_ID: "req-1",
1397 }
1398 )
@@ -1376,13 +1413,19 @@ class TestServerFlows:
1413 tid = 42
1414 session._server._inflight_add(
1415 tid,
1379 - _InflightXfer(kv_request_id="req-1", block_count=1, job_ids={1}),
1416 + _InflightXfer(
1417 + kv_request_id="req-1",
1418 + block_count=1,
1419 + job_ids={1},
1420 + round=_OutboundRequestState(inflight=1),
1421 + ),
1422 )
1423 transport._cancel_still_inflight.add(tid)
1424
1425 conn.enqueue(
1426 {
1427 TYPE_KEY: AbortFetchMsg.TYPE,
1428 + AbortFetchMsg.ROUND_SEQ: 0,
1429 AbortFetchMsg.KV_REQUEST_ID: "req-1",
1430 }
1431 )
@@ -1409,20 +1452,26 @@ class TestServerFlows:
1452 tid = 42
1453 session._server._inflight_add(
1454 tid,
1412 - _InflightXfer(kv_request_id="req-1", block_count=1, job_ids={1}),
1455 + _InflightXfer(
1456 + kv_request_id="req-1",
1457 + block_count=1,
1458 + job_ids={1},
1459 + round=_OutboundRequestState(inflight=1),
1460 + ),
1461 )
1462 transport._cancel_still_inflight.add(tid)
1463
1464 conn.enqueue(
1465 {
1466 TYPE_KEY: AbortFetchMsg.TYPE,
1467 + AbortFetchMsg.ROUND_SEQ: 0,
1468 AbortFetchMsg.KV_REQUEST_ID: "req-1",
1469 }
1470 )
1471 session.poll()
1472 assert _srv_abort_started(session, "req-1") is not None
1473 # Backdate past the drain deadline.
1425 - session._server._requests["req-1"].abort_started_at = (
1474 + session._server._pending_aborts[("req-1", 0)] = (
1475 time.monotonic() - _CANCEL_DRAIN_TIMEOUT_S - 1.0
1476 )
1477 # Even if the transport still claims it can't cancel, the
@@ -1445,13 +1494,19 @@ class TestServerFlows:
1494 tid = 42
1495 session._server._inflight_add(
1496 tid,
1448 - _InflightXfer(kv_request_id="req-1", block_count=1, job_ids={1}),
1497 + _InflightXfer(
1498 + kv_request_id="req-1",
1499 + block_count=1,
1500 + job_ids={1},
1501 + round=_OutboundRequestState(inflight=1),
1502 + ),
1503 )
1504 transport._cancel_still_inflight.add(tid)
1505
1506 conn.enqueue(
1507 {
1508 TYPE_KEY: AbortFetchMsg.TYPE,
1509 + AbortFetchMsg.ROUND_SEQ: 0,
1510 AbortFetchMsg.KV_REQUEST_ID: "req-1",
1511 }
1512 )
@@ -1463,6 +1518,7 @@ class TestServerFlows:
1518 conn.enqueue(
1519 {
1520 TYPE_KEY: AbortFetchMsg.TYPE,
1521 + AbortFetchMsg.ROUND_SEQ: 0,
1522 AbortFetchMsg.KV_REQUEST_ID: "req-1",
1523 }
1524 )
@@ -1497,6 +1553,7 @@ class TestServerFlows:
1553 conn.enqueue(
1554 {
1555 TYPE_KEY: FetchMsg.TYPE,
1556 + FetchMsg.ROUND_SEQ: 0,
1557 FetchMsg.KV_REQUEST_ID: "req-1",
1558 FetchMsg.KEYS: [b"k1"],
1559 FetchMsg.BLOCK_INDEXES: [5],
@@ -1529,6 +1586,7 @@ class TestServerFlows:
1586 conn.enqueue(
1587 {
1588 TYPE_KEY: FetchMsg.TYPE,
1589 + FetchMsg.ROUND_SEQ: 0,
1590 FetchMsg.KV_REQUEST_ID: "req-1",
1591 FetchMsg.KEYS: [b"k1"],
1592 FetchMsg.BLOCK_INDEXES: [5],
@@ -1571,6 +1629,7 @@ class TestFinishRequestServerSide:
1629 conn.enqueue(
1630 {
1631 TYPE_KEY: FetchMsg.TYPE,
1632 + FetchMsg.ROUND_SEQ: 0,
1633 FetchMsg.KV_REQUEST_ID: "req-1",
1634 FetchMsg.KEYS: [b"k1"],
1635 FetchMsg.BLOCK_INDEXES: [5],
@@ -1596,6 +1655,7 @@ class TestFinishRequestServerSide:
1655 conn.enqueue(
1656 {
1657 TYPE_KEY: FetchMsg.TYPE,
1658 + FetchMsg.ROUND_SEQ: 0,
1659 FetchMsg.KV_REQUEST_ID: "req-1",
1660 FetchMsg.KEYS: [b"k1", b"k2"],
1661 FetchMsg.BLOCK_INDEXES: [10, 11],
@@ -1631,6 +1691,7 @@ class TestFinishRequestServerSide:
1691 conn.enqueue(
1692 {
1693 TYPE_KEY: FetchMsg.TYPE,
1694 + FetchMsg.ROUND_SEQ: 0,
1695 FetchMsg.KV_REQUEST_ID: "req-1",
1696 FetchMsg.KEYS: [b"k1"],
1697 FetchMsg.BLOCK_INDEXES: [10],
@@ -1665,6 +1726,7 @@ class TestFinishRequestServerSide:
1726 conn.enqueue(
1727 {
1728 TYPE_KEY: FetchMsg.TYPE,
1729 + FetchMsg.ROUND_SEQ: 0,
1730 FetchMsg.KV_REQUEST_ID: "req-1",
1731 FetchMsg.KEYS: [b"k1"],
1732 FetchMsg.BLOCK_INDEXES: [10],
@@ -1686,6 +1748,7 @@ class TestFinishRequestServerSide:
1748 conn.enqueue(
1749 {
1750 TYPE_KEY: FetchMsg.TYPE,
1751 + FetchMsg.ROUND_SEQ: 0,
1752 FetchMsg.KV_REQUEST_ID: "req-2",
1753 FetchMsg.KEYS: [b"k1", b"k2"],
1754 FetchMsg.BLOCK_INDEXES: [10, 11],
@@ -1719,6 +1782,7 @@ class TestFinishRequestServerSide:
1782 conn.enqueue(
1783 {
1784 TYPE_KEY: FetchMsg.TYPE,
1785 + FetchMsg.ROUND_SEQ: 0,
1786 FetchMsg.KV_REQUEST_ID: "req-1",
1787 FetchMsg.KEYS: [b"demand"],
1788 FetchMsg.BLOCK_INDEXES: [5],
@@ -1753,6 +1817,7 @@ class TestFinishRequestServerSide:
1817 conn.enqueue(
1818 {
1819 TYPE_KEY: FetchMsg.TYPE,
1820 + FetchMsg.ROUND_SEQ: 0,
1821 FetchMsg.KV_REQUEST_ID: "req-1",
1822 FetchMsg.KEYS: [b"k1"],
1823 FetchMsg.BLOCK_INDEXES: [10],
@@ -1790,6 +1855,7 @@ class TestFinishRequestServerSide:
1855 conn.enqueue(
1856 {
1857 TYPE_KEY: FetchMsg.TYPE,
1858 + FetchMsg.ROUND_SEQ: 0,
1859 FetchMsg.KV_REQUEST_ID: "req-1",
1860 FetchMsg.KEYS: [b"k1"],
1861 FetchMsg.BLOCK_INDEXES: [10],
@@ -1824,6 +1890,7 @@ class TestFinishRequestServerSide:
1890 conn.enqueue(
1891 {
1892 TYPE_KEY: FetchMsg.TYPE,
1893 + FetchMsg.ROUND_SEQ: 0,
1894 FetchMsg.KV_REQUEST_ID: "req-1",
1895 FetchMsg.KEYS: [b"k1", b"k2", b"k3"],
1896 FetchMsg.BLOCK_INDEXES: [10, 11, 12],
@@ -1885,6 +1952,7 @@ class TestFinishRequestServerSide:
1952 conn.enqueue(
1953 {
1954 TYPE_KEY: FetchMsg.TYPE,
1955 + FetchMsg.ROUND_SEQ: 0,
1956 FetchMsg.KV_REQUEST_ID: "req-1",
1957 FetchMsg.KEYS: [b"k1", b"k2"],
1958 FetchMsg.BLOCK_INDEXES: [10, 11],
@@ -1951,6 +2019,7 @@ class TestBidirectional:
2019 conn.enqueue(
2020 {
2021 TYPE_KEY: FetchMsg.TYPE,
2022 + FetchMsg.ROUND_SEQ: 0,
2023 FetchMsg.KV_REQUEST_ID: "req-srv",
2024 FetchMsg.KEYS: [b"served"],
2025 FetchMsg.BLOCK_INDEXES: [7],
@@ -1981,6 +2050,7 @@ class TestBidirectional:
2050 conn.enqueue(
2051 {
2052 TYPE_KEY: TransferDoneMsg.TYPE,
2053 + TransferDoneMsg.ROUND_SEQ: 0,
2054 TransferDoneMsg.KV_REQUEST_ID: "req-cli",
2055 TransferDoneMsg.SUCCESS: True,
2056 }
@@ -2168,6 +2238,7 @@ class TestAdversarial:
2238 conn.enqueue(
2239 {
2240 TYPE_KEY: FetchMsg.TYPE,
2241 + FetchMsg.ROUND_SEQ: 0,
2242 FetchMsg.KV_REQUEST_ID: "req-bad",
2243 FetchMsg.KEYS: [b"k1", b"k2"],
2244 FetchMsg.BLOCK_INDEXES: [1],
@@ -2216,6 +2287,7 @@ class TestDispatchErrorHandling:
2287 conn.enqueue(
2288 {
2289 TYPE_KEY: FetchMsg.TYPE,
2290 + FetchMsg.ROUND_SEQ: 0,
2291 FetchMsg.KV_REQUEST_ID: "req-bad",
2292 FetchMsg.KEYS: [b"k1", b"k2"],
2293 FetchMsg.BLOCK_INDEXES: [1],
@@ -2246,6 +2318,7 @@ class TestDispatchErrorHandling:
2318 conn.enqueue(
2319 {
2320 TYPE_KEY: FetchMsg.TYPE,
2321 + FetchMsg.ROUND_SEQ: 0,
2322 FetchMsg.KV_REQUEST_ID: "req-1",
2323 FetchMsg.KEYS: [b"k1"],
2324 FetchMsg.BLOCK_INDEXES: [0],
@@ -2269,6 +2342,7 @@ class TestDispatchErrorHandling:
2342 conn.enqueue(
2343 {
2344 TYPE_KEY: FetchMsg.TYPE,
2345 + FetchMsg.ROUND_SEQ: 0,
2346 FetchMsg.KV_REQUEST_ID: "req-1",
2347 FetchMsg.KEYS: [b"k1"],
2348 FetchMsg.BLOCK_INDEXES: [0],
@@ -2296,6 +2370,7 @@ class TestDispatchErrorHandling:
2370 conn.enqueue(
2371 {
2372 TYPE_KEY: FetchMsg.TYPE,
2373 + FetchMsg.ROUND_SEQ: 0,
2374 FetchMsg.KV_REQUEST_ID: "req-1",
2375 FetchMsg.KEYS: [b"k1"],
2376 FetchMsg.BLOCK_INDEXES: [0],
@@ -2341,6 +2416,7 @@ class TestInflightPerReqInvariant:
2416 conn.enqueue(
2417 {
2418 TYPE_KEY: FetchMsg.TYPE,
2419 + FetchMsg.ROUND_SEQ: 0,
2420 FetchMsg.KV_REQUEST_ID: kv_id,
2421 FetchMsg.KEYS: keys,
2422 FetchMsg.BLOCK_INDEXES: indexes,
@@ -2394,7 +2470,12 @@ class TestInflightPerReqInvariant:
2470 tid = kv_id_idx * 10 + j
2471 session._server._inflight_add(
2472 tid,
2397 - _InflightXfer(kv_request_id=kv_id, block_count=1, job_ids={tid}),
2473 + _InflightXfer(
2474 + kv_request_id=kv_id,
2475 + block_count=1,
2476 + job_ids={tid},
2477 + round=_OutboundRequestState(inflight=1),
2478 + ),
2479 )
2480 assert _srv_total_inflight(session) == len(session._server._inflight)
2481 assert session._server._has_inflight_for("req-0")
@@ -2488,6 +2569,7 @@ class TestFetchMsgValidation:
2569 def _valid_msg(self) -> dict:
2570 return {
2571 TYPE_KEY: FetchMsg.TYPE,
2572 + FetchMsg.ROUND_SEQ: 0,
2573 FetchMsg.KV_REQUEST_ID: "req-1",
2574 FetchMsg.KEYS: [b"k1", b"k2"],
2575 FetchMsg.BLOCK_INDEXES: [0, 1],
@@ -2513,6 +2595,7 @@ class TestTransferDoneMsgValidation:
2595 def test_valid_message_passes(self):
2596 msg = {
2597 TYPE_KEY: TransferDoneMsg.TYPE,
2598 + TransferDoneMsg.ROUND_SEQ: 0,
2599 TransferDoneMsg.KV_REQUEST_ID: "req-1",
2600 TransferDoneMsg.SUCCESS: True,
2601 }
@@ -2521,6 +2604,7 @@ class TestTransferDoneMsgValidation:
2604 def test_success_wrong_type(self):
2605 msg = {
2606 TYPE_KEY: TransferDoneMsg.TYPE,
2607 + TransferDoneMsg.ROUND_SEQ: 0,
2608 TransferDoneMsg.KV_REQUEST_ID: "req-1",
2609 TransferDoneMsg.SUCCESS: 1,
2610 }
vllm/v1/kv_offload/tiering/p2p/session/client.py
+151 -146
@@ -11,7 +11,6 @@ callback injected by the coordinator (which gates on ConnectAck).
11
12 from __future__ import annotations
13
14 -import enum
14 import time
15 from collections.abc import Callable, Sequence
16 from dataclasses import dataclass, field
@@ -35,26 +34,12 @@ _LOAD_TIMEOUT_S = 30.0
34 _ABORT_ACK_TIMEOUT_S = 10.0
35
36
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 -
37 @dataclass
38 class _InboundLoadState:
39 """Client-role state for a single in-flight load request.
40
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.
41 + Lives in ``_ClientRequestState.loads`` keyed by round_seq for the
42 + duration of a fetch; the owning kv_request_id is the outer dict key.
43 """
44
45 job_id: int # opaque ID assigned by the manager to this load request
@@ -68,7 +53,7 @@ class _ClientRequestState:
53
54 One entry per kv_request_id we're driving. Lookup-phase fields are
55 used only by symmetric P2P (``do_p2p_fetch``); PD-only loads leave
71 - ``probes``/``unsent`` empty and drive just ``phase`` and ``load``. An
56 + ``probes``/``unsent`` empty and drive just ``phase`` and ``loads``. An
57 entry is dropped once every field is idle — see ``ClientRole._maybe_prune``.
58 """
59
@@ -82,11 +67,23 @@ class _ClientRequestState:
67 # OffloadKeys registered but not yet flushed onto the wire. Drained and
68 # cleared by the next flush_pending_lookups.
69 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
70 + # Current lookup round. LookupMsgs carry it, each fetch closes it and
71 + # advances it, so every round's supply/demand/completion is isolated
72 + # on the wire. PD clients never probe and stay on round 0.
73 + round_seq: int = 0
74 + # This id ran the symmetric lookup phase (register_lookup); a fetch
75 + # with keys then requires every key to be a confirmed probe. PD
76 + # loads never probe.
77 + probed: bool = False
78 +
79 + # The peer holds lookup state no FetchMsg has closed: a LookupMsg
80 + # was flushed since the last fetch. finish owes a terminal empty
81 + # FetchMsg while set, so the peer releases parked supply.
82 + peer_lookup_open: bool = False
83 + # In-flight loads keyed by the round their fetch carried. The
84 + # scheduler submits loads incrementally as chunks resolve, so several
85 + # can be in flight at once; TransferDone/AbortAck match by round.
86 + loads: dict[int, _InboundLoadState] = field(default_factory=dict)
87
88
89 class LoadResult(NamedTuple):
@@ -133,11 +130,10 @@ class ClientRole:
130 # _serve_pending. Populated by register_lookup, drained by
131 # flush_pending_lookups, and discarded on finish/close.
132 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.
133 + # kv_request_ids with at least one fetch in flight — the work-list
134 + # collect_results walks for timeouts, and the has_active_loads
135 + # predicate, instead of scanning every request. Kept in exact sync
136 + # with ``st.loads``.
137 self._active_loads: set[str] = set()
138 self._completed_loads: list[LoadResult] = []
139
@@ -156,17 +152,21 @@ class ClientRole:
152 def _maybe_prune(self, kv_request_id: str) -> None:
153 """Drop the entry once it holds no live load or lookup state.
154
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.
155 + ``peer_lookup_open`` is only read by ``finish``, and every path
156 + that clears the last probe (fetch / finish / close) also settles
157 + it, so dropping on emptiness never loses a flag still in use.
158 """
159 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:
160 + if st is not None and not st.loads and not st.probes and not st.unsent:
161 del self._requests[kv_request_id]
162
163 + def _on_load_terminal(self, kv_request_id: str, st: _ClientRequestState) -> None:
164 + """Wind down id-level state once no load remains in flight."""
165 + if st.loads:
166 + return
167 + self._active_loads.discard(kv_request_id)
168 + self._maybe_prune(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."""
@@ -184,7 +184,12 @@ class ClientRole:
184 block_ids: Sequence[int],
185 send_ready: bool,
186 ) -> None:
187 - """Register a load request and send the FetchMsg."""
187 + """Send the FetchMsg closing the current lookup round.
188 +
189 + The scheduler may submit several loads per kv_request_id as its
190 + matched prefix resolves incrementally; each fetch carries the
191 + round it closes so the loads stay independent on the wire.
192 + """
193 logger.debug(
194 "P2PSession %s: request_blocks job_id=%d kv_request_id=%s "
195 "blocks=%d ready=%s",
@@ -195,77 +200,74 @@ class ClientRole:
200 send_ready,
201 )
202 st = self._get_or_create_request(kv_request_id)
198 - st.load = _InboundLoadState(
203 + round_seq = st.round_seq
204 + st.round_seq += 1
205 + st.loads[round_seq] = _InboundLoadState(
206 job_id=job_id,
207 submitted_at=time.monotonic(),
208 )
209 self._active_loads.add(kv_request_id)
203 - st.phase = ClientPhase.FETCH_SENT
210 + st.peer_lookup_open = False
211 self._send(
212 {
213 TYPE_KEY: FetchMsg.TYPE,
214 FetchMsg.KV_REQUEST_ID: kv_request_id,
215 FetchMsg.KEYS: list(keys),
216 FetchMsg.BLOCK_INDEXES: [int(idx) for idx in block_ids],
217 + FetchMsg.ROUND_SEQ: round_seq,
218 }
219 )
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:
220 + # Issuing the fetch closes this lookup round, so drop all probe
221 + # state. Once the peer serves the fetch both sides unpin, so a
222 + # stale cached True would let a later lookup() return HIT for a
223 + # block the producer may have evicted; clearing forces a fresh
224 + # probe under the next round.
225 + if st.probed and keys:
226 + assert st.probes, (
227 + f"symmetric fetch for {kv_request_id} has keys but no probes"
228 + )
229 assert all(st.probes.get(key) is True for key in keys)
230 st.probes.clear()
231
232 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 -
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.
233 + """Finish a request: abort in-flight loads and release lookup state.
234 +
235 + Called from the session's ``finish_request``. Sends an
236 + AbortFetchMsg per load not already aborting, and — independently —
237 + the terminal empty FetchMsg when the peer still holds lookup
238 + state no fetch has closed (its "request finished" signal: it
239 + releases lookup state, drains parked supply, and fires
240 + ``cb.finish_request``). A later round's supply can be parked
241 + while an earlier round's load is still in flight, so both can be
242 + owed at once.
243
244 Then drop all probe/lookup state and prune the entry.
245 """
246 st = self._requests.get(kv_request_id)
247 if st is None:
248 return
251 - if st.load is not None:
252 - if st.load.aborted_at is None:
249 + if st.loads:
250 + for round_seq, load in st.loads.items():
251 + if load.aborted_at is not None:
252 + continue
253 self._send(
254 {
255 TYPE_KEY: AbortFetchMsg.TYPE,
256 AbortFetchMsg.KV_REQUEST_ID: kv_request_id,
257 + AbortFetchMsg.ROUND_SEQ: round_seq,
258 }
259 )
259 - st.load = None
260 + st.loads.clear()
261 self._active_loads.discard(kv_request_id)
261 - elif st.phase is ClientPhase.PROBING:
262 - st.phase = ClientPhase.FETCH_SENT
262 + if st.peer_lookup_open:
263 + st.peer_lookup_open = False
264 self._send(
265 {
266 TYPE_KEY: FetchMsg.TYPE,
267 FetchMsg.KV_REQUEST_ID: kv_request_id,
268 FetchMsg.KEYS: [],
269 FetchMsg.BLOCK_INDEXES: [],
270 + FetchMsg.ROUND_SEQ: st.round_seq,
271 }
272 )
273 st.probes.clear()
@@ -273,20 +275,21 @@ class ClientRole:
275 self._flush_pending.discard(kv_request_id)
276 self._maybe_prune(kv_request_id)
277
276 - def on_transfer_done(self, kv_request_id: str, success: bool) -> None:
278 + def on_transfer_done(
279 + self, kv_request_id: str, success: bool, round_seq: int
280 + ) -> None:
281 """Handle a TransferDoneMsg from the peer."""
282 st = self._requests.get(kv_request_id)
279 - if st is not None and st.load is not None:
283 + load = st.loads.pop(round_seq, None) if st is not None else None
284 + if st is not None and load is not None:
285 self._completed_loads.append(
286 LoadResult(
282 - job_id=st.load.job_id,
287 + job_id=load.job_id,
288 kv_request_id=kv_request_id,
289 success=success,
290 )
291 )
287 - st.load = None
288 - self._active_loads.discard(kv_request_id)
289 - self._maybe_prune(kv_request_id)
292 + self._on_load_terminal(kv_request_id, st)
293 else:
294 # No matching in-flight load: either a duplicate
295 # transfer_done from the peer (protocol violation) or a
@@ -295,41 +298,44 @@ class ClientRole:
298 # so we can't tell — log so it's findable.
299 logger.warning(
300 "P2PSession %s: transfer_done for unknown kv_request_id=%s "
298 - "(duplicate from peer, or raced with local cancel/timeout)",
301 + "round=%s (duplicate from peer, or raced with local "
302 + "cancel/timeout)",
303 self._peer_id,
304 kv_request_id,
305 + round_seq,
306 )
307
303 - def on_abort_ack(self, kv_request_id: str) -> None:
308 + def on_abort_ack(self, kv_request_id: str, round_seq: int) -> None:
309 """Handle an AbortAckMsg from the peer."""
310 st = self._requests.get(kv_request_id)
306 - if st is not None and st.load is not None:
311 + load = st.loads.pop(round_seq, None) if st is not None else None
312 + if st is not None and load is not None:
313 logger.warning(
314 "P2PSession %s: load request %s (job_id=%d) timed out; "
315 "load job completed with failure. If this recurs, ensure "
316 "PYTHONHASHSEED is set to the same value on all nodes.",
317 self._peer_id,
318 kv_request_id,
313 - st.load.job_id,
319 + load.job_id,
320 )
321 self._completed_loads.append(
322 LoadResult(
317 - job_id=st.load.job_id,
323 + job_id=load.job_id,
324 kv_request_id=kv_request_id,
325 success=False,
326 )
327 )
322 - st.load = None
323 - self._active_loads.discard(kv_request_id)
324 - self._maybe_prune(kv_request_id)
328 + self._on_load_terminal(kv_request_id, st)
329 else:
330 # See on_transfer_done: same ambiguity (duplicate ack
331 # vs. raced with local cancel/timeout that already popped).
332 logger.warning(
333 "P2PSession %s: abort_ack for unknown kv_request_id=%s "
330 - "(duplicate from peer, or raced with local cancel/timeout)",
334 + "round=%s (duplicate from peer, or raced with local "
335 + "cancel/timeout)",
336 self._peer_id,
337 kv_request_id,
338 + round_seq,
339 )
340
341 # ------------------------------------------------------------------
@@ -345,17 +351,18 @@ class ClientRole:
351 - Once a LookupRespMsg has resolved the entry: returns the cached
352 bool result on every call without popping it.
353
348 - A resolved entry is retained until its fetch is issued
349 - (``request_blocks`` pops it) or the request finishes
354 + A resolved entry is retained until a fetch closes the round
355 + (``request_blocks`` clears all probes) or the request finishes
356 (``finish`` clears all entries for the id). A request's
357 block set can be re-probed across steps, so popping on read would
358 make a repeat probe of an already-resolved key look brand-new and
359 re-queue it, emitting a redundant LookupMsg for an answer we
360 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.
361 + free; clearing at fetch forces a fresh probe under the next
362 + round, since the block is unpinned once served.
363 """
364 st = self._get_or_create_request(kv_request_id)
365 + st.probed = True
366 okey = OffloadKey(key)
367 if okey in st.probes:
368 return st.probes[okey]
@@ -378,14 +385,11 @@ class ClientRole:
385 ``on_schedule_end()``. A request's block set may be discovered
386 across several scheduler steps, so more than one LookupMsg can
387 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).
388 + keys, all tagged with the current round. register_lookup()
389 + de-dups in-flight and already-resolved (req_id, key) pairs, so
390 + each LookupMsg carries only the keys first probed in that step.
391 + Send-gating is handled by the injected ``_send`` callback
392 + (queues until ConnectAckMsg if needed).
393
394 Only requests that registered new keys since the last flush are
395 visited — the ``_flush_pending`` work-list avoids scanning every
@@ -395,13 +399,9 @@ class ClientRole:
399 st = self._requests.get(req_id)
400 if st is None or not st.unsent:
401 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
402 + # The peer now holds lookup state for this id; finish owes a
403 + # terminal empty FetchMsg until a fetch closes it.
404 + st.peer_lookup_open = True
405 logger.debug(
406 "P2P LOOKUP client %s: SEND LookupMsg kv_request_id=%s keys=%d",
407 self._peer_id,
@@ -413,6 +413,7 @@ class ClientRole:
413 TYPE_KEY: LookupMsg.TYPE,
414 LookupMsg.KV_REQUEST_ID: req_id,
415 LookupMsg.KEYS: list(st.unsent),
416 + LookupMsg.ROUND_SEQ: st.round_seq,
417 }
418 )
419 st.unsent = []
@@ -451,52 +452,56 @@ class ClientRole:
452 def collect_results(self) -> list[LoadResult]:
453 """Walk load timeouts and drain completed loads.
454
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.
455 + Loads past ``_LOAD_TIMEOUT_S`` get an AbortFetchMsg sent and
456 + enter the aborting phase. Aborting loads past
457 + ``_ABORT_ACK_TIMEOUT_S`` are surfaced as failed.
458
459 Lookups have no timeout: an unanswered probe stays None (RETRY)
460 until finish_request clears it — see ``_ClientRequestState.probes``.
461 """
462 now = time.monotonic()
462 - to_remove: list[str] = []
463 + to_remove: list[tuple[str, int]] = []
464 for req_id in self._active_loads:
465 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,
473 - req_id,
474 - )
475 - self._send(
476 - {
477 - TYPE_KEY: AbortFetchMsg.TYPE,
478 - AbortFetchMsg.KV_REQUEST_ID: req_id,
479 - }
480 - )
481 - else:
482 - if now - load.aborted_at >= _ABORT_ACK_TIMEOUT_S:
483 - to_remove.append(req_id)
484 - self._completed_loads.append(
485 - LoadResult(
486 - job_id=load.job_id,
487 - kv_request_id=req_id,
488 - success=False,
466 + assert st.loads
467 + for round_seq, load in st.loads.items():
468 + if load.aborted_at is None:
469 + if now - load.submitted_at >= _LOAD_TIMEOUT_S:
470 + load.aborted_at = now
471 + logger.warning(
472 + "P2PSession %s: %s round=%s timed out, sending abort",
473 + self._peer_id,
474 + req_id,
475 + round_seq,
476 + )
477 + self._send(
478 + {
479 + TYPE_KEY: AbortFetchMsg.TYPE,
480 + AbortFetchMsg.KV_REQUEST_ID: req_id,
481 + AbortFetchMsg.ROUND_SEQ: round_seq,
482 + }
483 + )
484 + else:
485 + if now - load.aborted_at >= _ABORT_ACK_TIMEOUT_S:
486 + to_remove.append((req_id, round_seq))
487 + self._completed_loads.append(
488 + LoadResult(
489 + job_id=load.job_id,
490 + kv_request_id=req_id,
491 + success=False,
492 + )
493 )
490 - )
491 - logger.warning(
492 - "P2PSession %s: abort_ack timed out for kv_request_id=%s",
493 - self._peer_id,
494 - req_id,
495 - )
496 - for req_id in to_remove:
497 - self._requests[req_id].load = None
498 - self._active_loads.discard(req_id)
499 - self._maybe_prune(req_id)
494 + logger.warning(
495 + "P2PSession %s: abort_ack timed out for "
496 + "kv_request_id=%s round=%s",
497 + self._peer_id,
498 + req_id,
499 + round_seq,
500 + )
501 + for req_id, round_seq in to_remove:
502 + st = self._requests[req_id]
503 + st.loads.pop(round_seq)
504 + self._on_load_terminal(req_id, st)
505
506 results = self._completed_loads
507 self._completed_loads = []
@@ -510,12 +515,12 @@ class ClientRole:
515 forever on an answer that can never arrive). See ``ClientCloseResult``.
516 """
517 failed_jobs = [
513 - st.load.job_id for st in self._requests.values() if st.load is not None
518 + load.job_id for st in self._requests.values() for load in st.loads.values()
519 ]
520 failed_req_ids = [
521 req_id
522 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())
523 + if st.loads or any(hit is None for hit in st.probes.values())
524 ]
525 self._requests.clear()
526 self._flush_pending.clear()
vllm/v1/kv_offload/tiering/p2p/session/protocol.py
+31 -19
@@ -26,14 +26,12 @@ 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.
29 + A request may run several lookup→fetch rounds; symmetric-P2P
30 + messages carry ROUND_SEQ so each round's supply, demand, and
31 + completion stay isolated. The terminal empty FetchMsg is the
32 + server-side "request finished" signal for the id: parked
33 + LookupMsg batches are popped and ``cb.finish_request`` fires on
34 + each.
35 2. Server matches requested blocks against locally stored blocks:
36 - Blocks already available are transferred immediately via RDMA.
37 - Blocks not yet available are recorded as "demanded" and
@@ -178,33 +176,32 @@ class DisconnectMsg:
176
177
178 class FetchMsg:
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.
179 + """Client → Server: request blocks for one lookup round.
180 +
181 + A non-empty fetch closes only its round. The terminal empty FetchMsg
182 + (``KEYS`` and ``BLOCK_INDEXES`` both empty) is the "request
183 + finished" signal: the server pops parked LookupMsg batches, calls
184 + ``cb.finish_request`` on each, and drains any leftover supply.
185
186 Fields:
187 KV_REQUEST_ID: Identifies this block transfer request.
188 KEYS: List of block keys (OffloadKey bytes). May be empty.
189 BLOCK_INDEXES: List of remote block indexes (same length as KEYS).
190 + ROUND_SEQ: Lookup round this fetch closes. PD clients never probe
191 + and stay on their single round 0.
192 """
193
194 TYPE = "fetch"
195 KV_REQUEST_ID = "kv_request_id"
196 KEYS = "keys"
197 BLOCK_INDEXES = "block_indexes"
198 + ROUND_SEQ = "round_seq"
199
200 @staticmethod
201 def validate(msg: dict) -> None:
202 """Raise ValueError if any field has an invalid type or value."""
203 _require(msg, FetchMsg.KV_REQUEST_ID, str)
204 + _require_non_neg_int(msg, FetchMsg.ROUND_SEQ)
205 _require_list(msg, FetchMsg.KEYS)
206 _require_list(msg, FetchMsg.BLOCK_INDEXES)
207 keys = msg[FetchMsg.KEYS]
@@ -229,17 +226,21 @@ class LookupMsg:
226 Fields:
227 KV_REQUEST_ID: Identifies this lookup transaction.
228 KEYS: List of block keys (OffloadKey bytes) to probe.
229 + ROUND_SEQ: Lookup round these probes belong to; pinned supply is
230 + parked under it for that round's fetch.
231 """
232
233 TYPE = "lookup"
234 KV_REQUEST_ID = "kv_request_id"
235 KEYS = "keys"
236 + ROUND_SEQ = "round_seq"
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 + _require_non_neg_int(msg, LookupMsg.ROUND_SEQ)
244
245
246 class LookupRespMsg:
@@ -284,17 +285,22 @@ class TransferDoneMsg:
285 Fields:
286 KV_REQUEST_ID: The request that completed.
287 SUCCESS: Whether the transfer completed successfully.
288 + ROUND_SEQ: The fetch round that completed. Several loads can be
289 + in flight per id (the scheduler submits loads incrementally),
290 + so completions are matched by round.
291 """
292
293 TYPE = "transfer_done"
294 KV_REQUEST_ID = "kv_request_id"
295 SUCCESS = "success"
296 + ROUND_SEQ = "round_seq"
297
298 @staticmethod
299 def validate(msg: dict) -> None:
300 """Raise ValueError if any field has an invalid type or value."""
301 _require(msg, TransferDoneMsg.KV_REQUEST_ID, str)
302 _require(msg, TransferDoneMsg.SUCCESS, bool)
303 + _require_non_neg_int(msg, TransferDoneMsg.ROUND_SEQ)
304
305
306 class AbortFetchMsg:
@@ -302,15 +308,18 @@ class AbortFetchMsg:
308
309 Fields:
310 KV_REQUEST_ID: The request to cancel.
311 + ROUND_SEQ: The fetch round to cancel.
312 """
313
314 TYPE = "abort_fetch"
315 KV_REQUEST_ID = "kv_request_id"
316 + ROUND_SEQ = "round_seq"
317
318 @staticmethod
319 def validate(msg: dict) -> None:
320 """Raise ValueError if any field has an invalid type or value."""
321 _require(msg, AbortFetchMsg.KV_REQUEST_ID, str)
322 + _require_non_neg_int(msg, AbortFetchMsg.ROUND_SEQ)
323
324
325 class AbortAckMsg:
@@ -318,12 +327,15 @@ class AbortAckMsg:
327
328 Fields:
329 KV_REQUEST_ID: The request that was cancelled.
330 + ROUND_SEQ: The round that was cancelled; echoes AbortFetchMsg.
331 """
332
333 TYPE = "abort_ack"
334 KV_REQUEST_ID = "kv_request_id"
335 + ROUND_SEQ = "round_seq"
336
337 @staticmethod
338 def validate(msg: dict) -> None:
339 """Raise ValueError if any field has an invalid type or value."""
340 _require(msg, AbortAckMsg.KV_REQUEST_ID, str)
341 + _require_non_neg_int(msg, AbortAckMsg.ROUND_SEQ)
vllm/v1/kv_offload/tiering/p2p/session/server.py
+272 -202
@@ -53,15 +53,6 @@ class StoreResult(NamedTuple):
53 success: bool
54
55
56 -class _InflightXfer(NamedTuple):
57 - """Metadata for a single inflight RDMA transfer, keyed by transfer_id."""
58 -
59 - kv_request_id: str
60 - block_count: int
61 - # The set of store job IDs that contributed blocks to this transfer.
62 - job_ids: set[int]
63 -
64 -
56 class _MatchResult(NamedTuple):
57 """Result of block matching: pairs ready for transfer."""
58
@@ -73,12 +64,17 @@ class _MatchResult(NamedTuple):
64
65 @dataclass
66 class _OutboundRequestState:
76 - """Server-role state for a single peer fetch request.
67 + """Server-role state for a single fetch round of a peer request.
68
78 - The owning ``kv_request_id`` is the ``ServerRole._requests`` dict key
79 - and is not duplicated on the value.
69 + A kv_request_id may run several lookup→fetch rounds; rounds live in
70 + ``_ServerRequestState.outbound`` keyed by the wire ``round_seq``, so
71 + terminals touch only their own round.
72 """
73
74 + # Supply came from inbound lookup pins (symmetric): no late
75 + # submit_store can arrive, so unmatched fetch demand fails fast. PD
76 + # rounds park demand for stores instead.
77 + lookup_supplied: bool = False
78 demand_received: bool = False
79 available: dict[OffloadKey, tuple[int, int]] = field(
80 default_factory=dict
@@ -88,7 +84,8 @@ class _OutboundRequestState:
84 ) # key → remote_block_idx: blocks peer wants, awaiting supply
85 remaining: int = 0 # blocks that need to be transferred to client
86 finishing: bool = False # Signal finish request ASAP
91 - # Job IDs that submit_store'd blocks for this request and have not
87 + inflight: int = 0 # transfers submitted for this round, not yet polled
88 + # Job IDs that submit_store'd blocks for this round and have not
89 # yet emitted a StoreResult. The terminal-finalize helper drains
90 # this set; poll-done and poll-failed discard entries as their
91 # StoreResults fire.
@@ -145,6 +142,21 @@ class _OutboundRequestState:
142 )
143
144
145 +@dataclass
146 +class _InflightXfer:
147 + """Metadata for a single inflight RDMA transfer, keyed by transfer_id."""
148 +
149 + kv_request_id: str
150 + block_count: int
151 + # The set of store job IDs that contributed blocks to this transfer.
152 + job_ids: set[int]
153 + # Round this transfer serves and its key in ``st.outbound``;
154 + # remaining/finalize apply only while the round is still registered.
155 + # Dummy default for test-seeded entries.
156 + round: _OutboundRequestState = field(default_factory=_OutboundRequestState)
157 + round_key: int = 0
158 +
159 +
160 @dataclass
161 class _ActiveLookup:
162 """In-flight state for one inbound LookupMsg.
@@ -159,6 +171,8 @@ class _ActiveLookup:
171 lookup_id: int
172 kv_request_id: str
173 ctx: ReqContext
174 + # Wire round these probes belong to; pins park under it.
175 + round_seq: int = 0
176 # Keys from the inbound LookupMsg, preserved in wire order so
177 # the aggregated response goes back in the same order.
178 keys: list[OffloadKey] = field(default_factory=list)
@@ -185,6 +199,7 @@ class _PendingLookup(NamedTuple):
199
200 keys: list[OffloadKey]
201 enqueued_at: float
202 + round_seq: int = 0
203
204
205 @dataclass
@@ -198,17 +213,15 @@ class _ServerRequestState:
213 is idle — see ``ServerRole._maybe_prune``.
214 """
215
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
216 + # Fetch rounds keyed by wire round_seq. A round is created by its
217 + # first supply or its fetch and removed at its terminal (finalize /
218 + # failure / abort).
219 + outbound: dict[int, _OutboundRequestState] = field(default_factory=dict)
220 # Raw inbound LookupMsgs not yet processed against the ParentManager.
221 pending_lookups: list[_PendingLookup] = field(default_factory=list)
222 # Per-LookupMsg state parked with HIT_PENDING / RETRY keys, keyed by
223 # the (globally unique) lookup_id and re-polled each serve.
224 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
225 # Transfer ids in ``ServerRole._inflight`` for this id. Kept in sync
226 # via _inflight_add / _inflight_pop so a non-empty set is an exact
227 # "has any inflight transfer" predicate and the abort drain can
@@ -256,9 +269,9 @@ class ServerRole:
269 # ``parent.on_request_finished`` in ``serve_external_requests``.
270 self._finished_lookup_ctxs: list[ReqContext] = []
271 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()
272 + # Parked aborts awaiting drain, keyed by (kv_request_id, round)
273 + # with the abort start time.
274 + self._pending_aborts: dict[tuple[str, int], float] = {}
275
276 # ------------------------------------------------------------------
277 # State helpers
@@ -277,11 +290,11 @@ class ServerRole:
290 st = self._requests.get(kv_request_id)
291 if (
292 st is not None
280 - and st.outbound is None
293 + and not st.outbound
294 and not st.inflight_tids
295 and not st.lookups
296 and not st.pending_lookups
284 - and st.abort_started_at is None
297 + and not any(kv == kv_request_id for kv, _ in self._pending_aborts)
298 ):
299 del self._requests[kv_request_id]
300
@@ -295,87 +308,100 @@ class ServerRole:
308 keys: Sequence[OffloadKey],
309 block_ids: Sequence[int],
310 job_id: JobId,
311 + round_seq: int = 0,
312 + *,
313 + from_lookup: bool = False,
314 ) -> None:
299 - """New blocks stored locally — match against pending fetch demand."""
315 + """New blocks stored locally — match within their fetch round.
316 +
317 + Lookup pins carry the round they were probed under; PD
318 + submit_store batches share PD's single round 0.
319 + """
320 self._store_jobs[job_id] = time.monotonic()
321 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)
322 + rnd = st.outbound.get(round_seq)
323 + if rnd is None:
324 + rnd = st.outbound[round_seq] = _OutboundRequestState()
325 + if from_lookup:
326 + rnd.lookup_supplied = True
327 + result = rnd.add_stored_blocks(keys, block_ids, job_id)
328 + if result.local_idxs and rnd.demand_received:
329 + self._submit_transfer(kv_request_id, result, rnd, round_seq)
330
331 def on_fetch(
332 self,
333 kv_request_id: str,
334 keys: Sequence[OffloadKey],
335 block_indexes: Sequence[int],
336 + round_seq: int = 0,
337 ) -> None:
338 """Handle a FetchMsg from the peer.
339
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.
340 + A non-empty fetch binds and closes its round, leaving lookup
341 + state alone (the next round's LookupMsg may already be in
342 + flight). The terminal empty fetch closes the id: parked lookups
343 + are popped and every remaining round drained. A second fetch for
344 + a round already holding demand raises ValueError
345 + (protocol-error disconnect).
346 """
347 logger.debug(
341 - "P2PSession %s: fetch RECEIVED kv_request_id=%s blocks=%d",
348 + "P2PSession %s: fetch RECEIVED kv_request_id=%s round=%s blocks=%d",
349 self._peer_id,
350 kv_request_id,
351 + round_seq,
352 len(keys),
353 )
354 st = self._requests.get(kv_request_id)
347 - existing = st.outbound if st is not None else None
355 + existing = st.outbound.get(round_seq) if st is not None else None
356 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}")
357 + raise ValueError(
358 + f"duplicate fetch for kv_request_id={kv_request_id} round={round_seq}"
359 + )
360 st = self._get_or_create_request(kv_request_id)
354 - if st.outbound is None:
355 - st.outbound = _OutboundRequestState()
356 - req = st.outbound
361 + req = st.outbound.get(round_seq)
362 + if req is None:
363 + req = st.outbound[round_seq] = _OutboundRequestState()
364 result = req.add_fetch_demand(keys, block_indexes)
365 + if not keys:
366 + # Terminal empty fetch: close the lookup phase and drain
367 + # every round with no TransferDoneMsg (nothing waits on it).
368 + self._finish_inbound_lookups(kv_request_id)
369 + for key in list(st.outbound):
370 + self._finalize_outbound(kv_request_id, key, send_done=False)
371 + return
372 + if req.lookup_supplied and req.demanded:
373 + # A symmetric round's supply always precedes its fetch, so
374 + # unmatched demand is unservable — fail now, not at the load
375 + # timeout. PD rounds keep parking demand for stores that
376 + # arrive later.
377 + logger.warning(
378 + "P2PSession %s: fetch kv_request_id=%s round=%s demanded %d "
379 + "blocks but %d have no pinned supply; failing fetch "
380 + "immediately",
381 + self._peer_id,
382 + kv_request_id,
383 + round_seq,
384 + len(keys),
385 + len(req.demanded),
386 + )
387 + self._finalize_outbound(kv_request_id, round_seq, success=False)
388 + return
389 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)
390 + self._submit_transfer(kv_request_id, result, req, round_seq)
391 # Prefiller-first mode: finish_request may have run before
392 # fetch arrived. If so, finalize once we know what was
393 # demanded — fully satisfied → success, else early-fail.
369 - if req.finishing and not self._has_inflight_for(kv_request_id):
370 - self._finalize_outbound(kv_request_id)
394 + if req.finishing and req.inflight == 0:
395 + self._finalize_outbound(kv_request_id, round_seq)
396
372 - def on_abort_fetch(self, kv_request_id: str) -> None:
373 - """Handle an AbortFetchMsg from the peer."""
397 + def on_abort_fetch(self, kv_request_id: str, round_seq: int = 0) -> None:
398 + """Handle an AbortFetchMsg from the peer, cancelling one round."""
399 # Abort for an unknown id may be a benign race/duplicate or a
400 # real protocol violation; we don't track completed ids, so warn.
401 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):
402 + if (st is None or not st.outbound) and not self._has_inflight_for(
403 + kv_request_id
404 + ):
405 logger.warning(
406 "P2PSession %s: abort_fetch for unknown kv_request_id=%s "
407 "(no outbound or inflight state); benign race or stale",
@@ -385,16 +411,15 @@ class ServerRole:
411 # Idempotent: receiving AbortFetchMsg again before we've sent the
412 # ack just triggers another drain attempt without resetting the
413 # deadline.
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)
414 + self._get_or_create_request(kv_request_id)
415 + self._pending_aborts.setdefault((kv_request_id, round_seq), time.monotonic())
416 + self._drain_abort(kv_request_id, round_seq)
417
418 def on_lookup(
419 self,
420 kv_request_id: str,
421 keys: Sequence[OffloadKey],
422 + round_seq: int = 0,
423 ) -> None:
424 """Enqueue a LookupMsg from a symmetric-P2P consumer.
425
@@ -406,13 +431,18 @@ class ServerRole:
431 parent calls are valid.
432 """
433 logger.debug(
409 - "P2P LOOKUP server %s: RECV LookupMsg kv_request_id=%s keys=%d",
434 + "P2P LOOKUP server %s: RECV LookupMsg kv_request_id=%s round=%s keys=%d",
435 self._peer_id,
436 kv_request_id,
437 + round_seq,
438 len(keys),
439 )
440 self._get_or_create_request(kv_request_id).pending_lookups.append(
415 - _PendingLookup(keys=list(keys), enqueued_at=time.monotonic())
441 + _PendingLookup(
442 + keys=list(keys),
443 + enqueued_at=time.monotonic(),
444 + round_seq=round_seq,
445 + )
446 )
447 self._serve_pending.add(kv_request_id)
448
@@ -434,7 +464,7 @@ class ServerRole:
464 st.pending_lookups = []
465 for pl in pending:
466 self._process_inbound_lookup(
437 - kv_request_id, pl.keys, pl.enqueued_at, parent
467 + kv_request_id, pl.keys, pl.enqueued_at, pl.round_seq, parent
468 )
469 self._resolve_pending_lookups(kv_request_id, parent)
470 st = self._requests.get(kv_request_id)
@@ -481,9 +511,7 @@ class ServerRole:
511 else:
512 lookup.pending.add(h)
513 if new_hits:
484 - self._pin_and_register_hits(
485 - lookup.kv_request_id, new_hits, lookup.ctx, parent
486 - )
514 + self._pin_and_register_hits(lookup, new_hits, parent)
515 return new_hits
516
517 def _process_inbound_lookup(
@@ -491,6 +519,7 @@ class ServerRole:
519 kv_request_id: str,
520 keys: list[OffloadKey],
521 enqueued_at: float,
522 + round_seq: int,
523 parent: ParentManager,
524 ) -> None:
525 """Resolve one enqueued LookupMsg against ``parent``.
@@ -515,6 +544,7 @@ class ServerRole:
544 lookup_id=lookup_id,
545 kv_request_id=kv_request_id,
546 ctx=ctx,
547 + round_seq=round_seq,
548 keys=list(keys),
549 deadline=enqueued_at + _LOOKUP_PENDING_TIMEOUT_S,
550 )
@@ -547,25 +577,26 @@ class ServerRole:
577
578 def _pin_and_register_hits(
579 self,
550 - kv_request_id: str,
580 + lookup: _ActiveLookup,
581 keys: list[OffloadKey],
552 - ctx: ReqContext,
582 parent: ParentManager,
583 ) -> None:
555 - """Pin primary slots for HIT keys and feed them into the
556 - existing ``add_stored_blocks`` matching path.
584 + """Pin primary slots for HIT keys and park them as the lookup's
585 + round supply via ``add_stored_blocks``.
586
587 Caller has already confirmed every key is HIT (single-threaded
588 scheduler ⇒ no eviction race), so the JobMetadata returned by
589 ``parent.create_store_job`` carries parallel ``keys``/``block_ids``
590 of length ``len(keys)``.
591 """
563 - meta = parent.create_store_job(keys, ctx)
592 + meta = parent.create_store_job(keys, lookup.ctx)
593 self.add_stored_blocks(
565 - kv_request_id,
594 + lookup.kv_request_id,
595 list(meta.keys),
596 list(meta.block_ids),
597 meta.job_id,
598 + round_seq=lookup.round_seq,
599 + from_lookup=True,
600 )
601
602 def _resolve_pending_lookups(
@@ -650,9 +681,8 @@ class ServerRole:
681
682 Called on the two events that mean "no more lookup traffic for
683 ``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.
684 + empty FetchMsg from the peer and a local ``finish``. Whichever
685 + fires second is a no-op.
686 """
687 st = self._requests.get(kv_request_id)
688 if st is None:
@@ -688,20 +718,15 @@ class ServerRole:
718 self._finish_inbound_lookups(kv_request_id)
719
720 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
695 - if not req.demand_received:
696 - return
697 - if self._has_inflight_for(kv_request_id):
721 + if st is None:
722 return
699 - # Remaining > 0 here: if it had hit 0, the poll-done success
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)
723 + for key, req in list(st.outbound.items()):
724 + req.finishing = True
725 + if not req.demand_received or req.inflight:
726 + # No demand yet (prefiller-first): on_fetch finalizes via
727 + # `finishing`. Inflight: the last completion finalizes.
728 + continue
729 + self._finalize_outbound(kv_request_id, key)
730
731 def collect_results(self) -> list[StoreResult]:
732 """Drain timeouts, deferred results, and transport completions.
@@ -741,20 +766,24 @@ class ServerRole:
766 )
767 continue
768 results.extend(self._settle_xfer_jobs(xfer, success=True))
769 + rnd = xfer.round
770 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, (
771 + if st is not None and st.outbound.get(xfer.round_key) is rnd:
772 + rnd.remaining -= xfer.block_count
773 + assert rnd.remaining >= 0, (
774 f"remaining went negative for kv_request_id={xfer.kv_request_id}"
775 )
751 - if req.remaining == 0:
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)
776 + if rnd.remaining == 0:
777 + self._finalize_outbound(
778 + xfer.kv_request_id, xfer.round_key, success=True
779 + )
780 + elif rnd.finishing and rnd.inflight == 0:
781 + self._finalize_outbound(
782 + xfer.kv_request_id, xfer.round_key, success=False
783 + )
784 self._maybe_prune(xfer.kv_request_id)
785
757 - failed_kv_request_ids: set[str] | None = None
786 + failed_rounds: list[tuple[str, _OutboundRequestState]] | None = None
787 for tid in poll_result.failed:
788 xfer = self._inflight_pop(tid)
789 if xfer is None:
@@ -767,35 +796,36 @@ class ServerRole:
796 tid,
797 )
798 continue
770 - if failed_kv_request_ids is None:
771 - failed_kv_request_ids = set()
772 - failed_kv_request_ids.add(xfer.kv_request_id)
799 results.extend(self._settle_xfer_jobs(xfer, success=False))
800 + rnd = xfer.round
801 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:
802 + if st is not None and st.outbound.get(xfer.round_key) is rnd:
803 + del st.outbound[xfer.round_key]
804 + if failed_rounds is None:
805 + failed_rounds = []
806 + failed_rounds.append((xfer.kv_request_id, rnd))
807 self._send(
808 {
809 TYPE_KEY: TransferDoneMsg.TYPE,
810 TransferDoneMsg.KV_REQUEST_ID: xfer.kv_request_id,
811 TransferDoneMsg.SUCCESS: False,
812 + TransferDoneMsg.ROUND_SEQ: xfer.round_key,
813 }
814 )
815 self._maybe_prune(xfer.kv_request_id)
816
788 - # Cancel other inflight for the same failed kv_request_ids
789 - if failed_kv_request_ids:
790 - ids_to_cancel = [
791 - tid
792 - for tid, xfer in self._inflight.items()
793 - if xfer.kv_request_id in failed_kv_request_ids
794 - ]
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:
817 + # Cancel each failed round's other inflight and fail its
818 + # remaining store jobs — nothing else will settle them.
819 + if failed_rounds:
820 + for kv_request_id, rnd in failed_rounds:
821 + ids_to_cancel = [
822 + tid for tid, x in self._inflight.items() if x.round is rnd
823 + ]
824 + for tid in ids_to_cancel:
825 + self._inflight_pop(tid)
826 + if ids_to_cancel:
827 + self._transport.cancel(ids_to_cancel)
828 + results.extend(self._fail_round_jobs(rnd))
829 self._maybe_prune(kv_request_id)
830
831 return results
@@ -811,8 +841,8 @@ class ServerRole:
841
842 def drain_pending_aborts(self) -> None:
843 """Re-attempt every parked abort once per poll tick."""
814 - for kv_request_id in list(self._parked_aborts):
815 - self._drain_abort(kv_request_id)
844 + for kv_request_id, round_seq in list(self._pending_aborts):
845 + self._drain_abort(kv_request_id, round_seq)
846
847 def close(self) -> tuple[list[int], list[ReqContext]]:
848 """Tear down. Cancels inflight.
@@ -839,7 +869,7 @@ class ServerRole:
869 failed_serves.extend(self._finished_lookup_ctxs)
870 self._requests.clear()
871 self._serve_pending.clear()
842 - self._parked_aborts.clear()
872 + self._pending_aborts.clear()
873 self._finished_lookup_ctxs.clear()
874 return failed_stores, failed_serves
875
@@ -870,6 +900,8 @@ class ServerRole:
900 xfer = self._inflight.pop(tid, None)
901 if xfer is None:
902 return None
903 + xfer.round.inflight -= 1
904 + assert xfer.round.inflight >= 0
905 st = self._requests.get(xfer.kv_request_id)
906 if st is not None:
907 st.inflight_tids.discard(tid)
@@ -880,80 +912,108 @@ class ServerRole:
912 ) -> list[StoreResult]:
913 """Emit StoreResults for a completed transfer's store jobs.
914
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.
915 + Pops each attached job from ``_store_jobs`` and clears it from
916 + its round's pending set. A job already popped (via timeout,
917 + cancel, etc.) is skipped so we never double-emit a contradictory
918 + result.
919 """
920 results: list[StoreResult] = []
888 - st = self._requests.get(xfer.kv_request_id)
889 - req = st.outbound if st is not None else None
921 for job_id in xfer.job_ids:
922 if self._store_jobs.pop(job_id, None) is None:
923 continue
924 results.append(StoreResult(job_id=job_id, success=success))
894 - if req is not None:
895 - req.pending_job_ids.discard(job_id)
925 + xfer.round.pending_job_ids.discard(job_id)
926 return results
927
928 # ------------------------------------------------------------------
929 # Internal — finalize / abort drain
930 # ------------------------------------------------------------------
931
932 + def _fail_round_jobs(self, rnd: _OutboundRequestState) -> list[StoreResult]:
933 + """Fail a terminated round's still-pending store jobs (idempotent)."""
934 + results: list[StoreResult] = []
935 + for job_id in rnd.pending_job_ids:
936 + if self._store_jobs.pop(job_id, None) is None:
937 + continue
938 + results.append(StoreResult(job_id=job_id, success=False))
939 + rnd.pending_job_ids.clear()
940 + return results
941 +
942 def _finalize_outbound(
943 self,
944 kv_request_id: str,
945 + round_key: int,
946 success: bool | None = None,
947 + send_done: bool = True,
948 ) -> None:
907 - """Pop the outbound state and emit terminal results.
908 -
909 - Called when no further work will happen for this kv_request_id
910 - on the server side: either request_finish has fired and there
911 - are no inflight transfers, or the last inflight just completed
912 - while finishing.
949 + """Pop one round and emit its terminal results.
950
951 If ``success`` is None, derive it from ``req.remaining == 0``.
915 - The same flag is used for both the peer's TransferDoneMsg and
916 - the StoreResult(s) emitted for any leftover pending job_ids.
952 + ``send_done=False`` skips the TransferDoneMsg (terminal empty
953 + fetch). Other rounds of the id are untouched.
954 """
955 st = self._requests[kv_request_id]
919 - assert st.outbound is not None
920 - req = st.outbound
921 - st.outbound = None
956 + req = st.outbound.pop(round_key)
957 if success is None:
923 - success = req.remaining == 0
924 - for job_id in req.pending_job_ids:
925 - self._store_jobs.pop(job_id, None)
926 - self._pending_store_results.append(
927 - StoreResult(job_id=job_id, success=success)
928 - )
929 - self._send(
930 - {
931 - TYPE_KEY: TransferDoneMsg.TYPE,
932 - TransferDoneMsg.KV_REQUEST_ID: kv_request_id,
933 - TransferDoneMsg.SUCCESS: success,
934 - }
958 + success = req.demand_received and req.remaining == 0
959 + settled = self._fail_round_jobs(req) if not success else None
960 + if settled is not None:
961 + self._pending_store_results.extend(settled)
962 + else:
963 + for job_id in req.pending_job_ids:
964 + if self._store_jobs.pop(job_id, None) is None:
965 + continue
966 + self._pending_store_results.append(
967 + StoreResult(job_id=job_id, success=True)
968 + )
969 + req.pending_job_ids.clear()
970 + logger.debug(
971 + "P2PSession %s: finalize kv_request_id=%s round=%s success=%s "
972 + "remaining=%d leftover_available=%d send_done=%s",
973 + self._peer_id,
974 + kv_request_id,
975 + round_key,
976 + success,
977 + req.remaining,
978 + len(req.available),
979 + send_done,
980 )
981 + if send_done and req.demand_received:
982 + self._send(
983 + {
984 + TYPE_KEY: TransferDoneMsg.TYPE,
985 + TransferDoneMsg.KV_REQUEST_ID: kv_request_id,
986 + TransferDoneMsg.SUCCESS: success,
987 + TransferDoneMsg.ROUND_SEQ: round_key,
988 + }
989 + )
990 self._maybe_prune(kv_request_id)
991
938 - def _drain_abort(self, kv_request_id: str) -> None:
992 + def _drain_abort(self, kv_request_id: str, round_seq: int) -> None:
993 """One drain attempt for a pending abort.
994
941 - Stops accepting more blocks for ``kv_request_id``, then asks the
942 - transport to cancel any matching inflight transfers in
943 - ``mode="wait"``. Sends ``AbortAckMsg`` once nothing remains
944 - inflight, or after ``_CANCEL_DRAIN_TIMEOUT_S`` falls back to
945 - ``mode="immediate"`` and acks anyway.
995 + Detaches the aborted round, then asks the transport to cancel its
996 + inflight transfers in ``mode="wait"``. Sends ``AbortAckMsg`` once
997 + nothing remains inflight, or after ``_CANCEL_DRAIN_TIMEOUT_S``
998 + falls back to ``mode="immediate"`` and acks anyway.
999 """
1000 st = self._requests[kv_request_id]
948 - st.outbound = None
949 - ids = list(st.inflight_tids)
1001 + rnd = st.outbound.pop(round_seq, None)
1002 + if rnd is not None:
1003 + # Its transfers are being cancelled; fail its jobs now
1004 + # instead of leaking them to the store timeout.
1005 + self._pending_store_results.extend(self._fail_round_jobs(rnd))
1006 + ids = [
1007 + tid
1008 + for tid, x in self._inflight.items()
1009 + if x.kv_request_id == kv_request_id and x.round_key == round_seq
1010 + ]
1011 if not ids:
951 - self._finalize_abort(kv_request_id)
1012 + self._finalize_abort(kv_request_id, round_seq)
1013 return
1014
954 - assert st.abort_started_at is not None
955 - expired = time.monotonic() - st.abort_started_at >= _CANCEL_DRAIN_TIMEOUT_S
956 - if expired:
1015 + started_at = self._pending_aborts[(kv_request_id, round_seq)]
1016 + if time.monotonic() - started_at >= _CANCEL_DRAIN_TIMEOUT_S:
1017 for tid in ids:
1018 self._inflight_pop(tid)
1019 self._transport.cancel(ids, mode="immediate")
@@ -964,7 +1024,7 @@ class ServerRole:
1024 kv_request_id,
1025 len(ids),
1026 )
967 - self._finalize_abort(kv_request_id)
1027 + self._finalize_abort(kv_request_id, round_seq)
1028 return
1029
1030 still = self._transport.cancel(ids, mode="wait")
@@ -977,16 +1037,15 @@ class ServerRole:
1037 if tid not in still_set:
1038 self._inflight_pop(tid)
1039 if not still:
980 - self._finalize_abort(kv_request_id)
1040 + self._finalize_abort(kv_request_id, round_seq)
1041
982 - def _finalize_abort(self, kv_request_id: str) -> None:
983 - st = self._requests[kv_request_id]
984 - st.abort_started_at = None
985 - self._parked_aborts.discard(kv_request_id)
1042 + def _finalize_abort(self, kv_request_id: str, round_seq: int) -> None:
1043 + self._pending_aborts.pop((kv_request_id, round_seq), None)
1044 self._send(
1045 {
1046 TYPE_KEY: AbortAckMsg.TYPE,
1047 AbortAckMsg.KV_REQUEST_ID: kv_request_id,
1048 + AbortAckMsg.ROUND_SEQ: round_seq,
1049 }
1050 )
1051 self._maybe_prune(kv_request_id)
@@ -995,7 +1054,13 @@ class ServerRole:
1054 # Internal — transfers and store-job timeouts
1055 # ------------------------------------------------------------------
1056
998 - def _submit_transfer(self, kv_request_id: str, result: _MatchResult) -> None:
1057 + def _submit_transfer(
1058 + self,
1059 + kv_request_id: str,
1060 + result: _MatchResult,
1061 + rnd: _OutboundRequestState,
1062 + round_key: int,
1063 + ) -> None:
1064 logger.debug(
1065 "P2PSession %s: NIXL write_blocks CALL kv_request_id=%s "
1066 "local_idxs=%d remote_idxs=%d",
@@ -1016,12 +1081,15 @@ class ServerRole:
1081 transfer_id,
1082 len(result.local_idxs),
1083 )
1084 + rnd.inflight += 1
1085 self._inflight_add(
1086 transfer_id,
1087 _InflightXfer(
1088 kv_request_id=kv_request_id,
1089 block_count=len(result.local_idxs),
1090 job_ids=result.job_ids,
1091 + round=rnd,
1092 + round_key=round_key,
1093 ),
1094 )
1095 else:
@@ -1031,22 +1099,24 @@ class ServerRole:
1099 kv_request_id,
1100 len(result.local_idxs),
1101 )
1034 - # The matched blocks were popped from req.demanded /
1035 - # req.available, but no inflight will satisfy them, so
1036 - # remaining will never reach 0 on its own. Mark the
1037 - # request as finishing so the existing terminal paths
1038 - # clean up: if other inflight is in flight, the last one
1039 - # to drain will fire _finalize_outbound(success=False)
1040 - # via the elif branch in collect_results. If
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.
1102 + # The matched blocks were popped from rnd.demanded /
1103 + # rnd.available, but no inflight will satisfy them, so
1104 + # remaining will never reach 0 on its own. Mark the round
1105 + # as finishing so the existing terminal paths clean up: if
1106 + # other transfers of this round are in flight, the last one
1107 + # to drain will fire _finalize_outbound(success=False) via
1108 + # the elif branch in collect_results. If nothing else is in
1109 + # flight, finalize now so the peer and the local store jobs
1110 + # don't wait for finish_request or for _STORE_TIMEOUT_S /
1111 + # _LOAD_TIMEOUT_S.
1112 + rnd.finishing = True
1113 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):
1049 - self._finalize_outbound(kv_request_id, success=False)
1114 + if (
1115 + st is not None
1116 + and st.outbound.get(round_key) is rnd
1117 + and rnd.inflight == 0
1118 + ):
1119 + self._finalize_outbound(kv_request_id, round_key, success=False)
1120
1121 def _timeout_pending_store_jobs(self) -> list[StoreResult]:
1122 if not self._store_jobs:
vllm/v1/kv_offload/tiering/p2p/session/session.py
+12 -4
@@ -374,26 +374,34 @@ class P2PSession:
374 for bh in msg[FetchMsg.KEYS]
375 ]
376 block_indexes = msg[FetchMsg.BLOCK_INDEXES]
377 + round_seq = msg[FetchMsg.ROUND_SEQ]
378 # Run the server-role state machine inline as today —
379 # add_fetch_demand records demand against any blocks we've
380 # already seen in `available`. Report the kv_request_id so
381 # the manager (after poll() returns) can replay any parked
382 # submit_store batches; their add_stored_blocks calls hit
383 # the demand recorded here and submit transfers immediately.
383 - self._server.on_fetch(kv_request_id, keys, block_indexes)
384 + self._server.on_fetch(kv_request_id, keys, block_indexes, round_seq)
385 self._new_fetch_ids.append(kv_request_id)
386 elif msg_type == AbortFetchMsg.TYPE:
387 AbortFetchMsg.validate(msg)
387 - self._server.on_abort_fetch(msg[AbortFetchMsg.KV_REQUEST_ID])
388 + self._server.on_abort_fetch(
389 + msg[AbortFetchMsg.KV_REQUEST_ID],
390 + msg[AbortFetchMsg.ROUND_SEQ],
391 + )
392 elif msg_type == TransferDoneMsg.TYPE:
393 TransferDoneMsg.validate(msg)
394 self._client.on_transfer_done(
395 msg[TransferDoneMsg.KV_REQUEST_ID],
396 msg[TransferDoneMsg.SUCCESS],
397 + msg[TransferDoneMsg.ROUND_SEQ],
398 )
399 elif msg_type == AbortAckMsg.TYPE:
400 AbortAckMsg.validate(msg)
396 - self._client.on_abort_ack(msg[AbortAckMsg.KV_REQUEST_ID])
401 + self._client.on_abort_ack(
402 + msg[AbortAckMsg.KV_REQUEST_ID],
403 + msg[AbortAckMsg.ROUND_SEQ],
404 + )
405 elif msg_type == LookupMsg.TYPE:
406 LookupMsg.validate(msg)
407 kv_request_id = msg[LookupMsg.KV_REQUEST_ID]
@@ -401,7 +409,7 @@ class P2PSession:
409 OffloadKey(bh if isinstance(bh, bytes) else bytes(bh))
410 for bh in msg[LookupMsg.KEYS]
411 ]
404 - self._server.on_lookup(kv_request_id, keys)
412 + self._server.on_lookup(kv_request_id, keys, msg[LookupMsg.ROUND_SEQ])
413 elif msg_type == LookupRespMsg.TYPE:
414 LookupRespMsg.validate(msg)
415 kv_request_id = msg[LookupRespMsg.KV_REQUEST_ID]