[Bugfix][NIXL] Restore successful push completion reporting (#58188)
Signed-off-by: Dao Le <Dao007forever@gmail.com> Co-authored-by: Codex <noreply@openai.com>
Dao007forever committed
Sep 23, 2026 at 09:39 UTC
c4f6ce48749a092b02525af16947350305650573
2 files changed
+111
-20
tests/v1/kv_connector/unit/test_nixl_push_connector.py
+97
-13
@@ -50,7 +50,10 @@ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.push_worker import (
50
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.stats import (
51
NixlKVConnectorStats,
52
)
53
-from vllm.distributed.kv_transfer.kv_connector.v1.nixl.tp_mapping import TPMapping
53
+from vllm.distributed.kv_transfer.kv_connector.v1.nixl.tp_mapping import (
54
+ ReadSpec,
55
+ TPMapping,
56
+)
57
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.utils import (
58
get_base_request_id,
59
)
@@ -344,6 +347,7 @@ class _StubWriterWorker(NixlPushConnectorWorker):
347
)
348
349
w._sending_transfers = defaultdict[ReqId, list[TransferHandle]](list)
350
+ w._send_failures = set()
351
w._sending_transfers_lock = threading.Lock()
352
w._push_finished_blocks = {}
353
w._pending_d_registrations = {}
@@ -941,30 +945,25 @@ class TestPushWriterNotifs:
945
assert request_id not in w._sending_transfers
946
947
@pytest.mark.parametrize(("pp_size", "is_hma"), [(1, False), (2, True)])
944
- def test_completed_push_send_waits_for_consumer_notif(self, pp_size, is_hma):
945
- """P-side sends are completed by consumer notifs / lease expiry,
946
- not by local WRITE-handle completion."""
948
+ def test_completed_push_send_releases_blocks_without_consumer_notif(
949
+ self, pp_size, is_hma
950
+ ):
951
+ """All WRITEs completing lets P reclaim its blocks without a D ACK."""
952
w = self._pollable_worker()
953
w.pp_size = pp_size
954
w._is_hma_required = is_hma
955
request_id = self._make_sending_req(w)
956
w.nixl_wrapper.check_xfer_state.side_effect = ["DONE", "PROC", "DONE"]
957
953
- results = w.get_transfer_results()
954
- assert request_id not in results.finished_sending
955
- assert w._sending_transfers[request_id] == [102]
956
- assert request_id in w._reqs_to_send
957
-
958
results = w.get_transfer_results()
959
960
assert request_id not in results.finished_sending
961
assert request_id in w._reqs_to_send
962
- assert request_id not in w._sending_transfers
962
+ assert w._sending_transfers[request_id] == [102]
963
+ assert w._evict_finished_inbox.empty()
964
964
- # The consumer notif is what completes the send.
965
- w._pending_completion_notifs.put(f"{request_id}:1".encode())
965
results = w.get_transfer_results()
967
- assert request_id in results.finished_sending
966
+ assert results.finished_sending == {request_id}
967
assert request_id not in w._reqs_to_send
968
assert request_id not in w._reqs_to_process
969
assert w._evict_finished_inbox.get_nowait() == request_id
@@ -973,6 +972,91 @@ class TestPushWriterNotifs:
972
assert w.nixl_wrapper.release_xfer_handle.call_count == 2
973
w.xfer_stats.record_kv_expired_req.assert_not_called()
974
975
+ @pytest.mark.parametrize("sibling_state", ["ERR", "DONE"])
976
+ def test_push_failure_survives_until_lease_expiry(self, sibling_state):
977
+ """A later sibling completion must not turn a failed send into success."""
978
+ w = self._pollable_worker()
979
+ request_id = self._make_sending_req(w)
980
+ w.nixl_wrapper.check_xfer_state.side_effect = ["ERR", "PROC", sibling_state]
981
+
982
+ assert w.get_transfer_results().finished_sending == set()
983
+ assert w._sending_transfers[request_id] == [102]
984
+ assert w.get_transfer_results().finished_sending == set()
985
+ assert request_id not in w._sending_transfers
986
+ assert request_id in w._reqs_to_send
987
+ assert w._evict_finished_inbox.empty()
988
+
989
+ w.expected_consumer_notifications_by_req = {}
990
+ w._reqs_to_send[request_id] = time.perf_counter() - 1
991
+ assert w.get_transfer_results().finished_sending == {request_id}
992
+ assert request_id not in w._send_failures
993
+ assert w._evict_finished_inbox.get_nowait() == request_id
994
+
995
+ @pytest.mark.parametrize("release_fails", [False, True])
996
+ def test_push_submission_failure_is_remembered_after_sibling_success(
997
+ self, release_fails
998
+ ):
999
+ """A submission error remains a failure even if later polls succeed."""
1000
+ w = self._pollable_worker()
1001
+ request_id = self._make_sending_req(w)
1002
+ w.transfer_topo.block_size_ratio.return_value = 1
1003
+ w._apply_prefix_caching = MagicMock(return_value=([[1]], [[1]]))
1004
+ w._compute_desc_ids = MagicMock(return_value=[0])
1005
+ w.dst_num_blocks = {w.engine_id: 8}
1006
+ w.dst_region_num_blocks[w.engine_id] = [8]
1007
+ w.dst_region_group_ids[w.engine_id] = [0]
1008
+ w.dst_uses_region_group_mapping[w.engine_id] = False
1009
+ w.nixl_wrapper.make_prepped_xfer.return_value = 101
1010
+ w.nixl_wrapper.transfer.side_effect = RuntimeError("submit")
1011
+ if release_fails:
1012
+ w.nixl_wrapper.release_xfer_handle.side_effect = [
1013
+ RuntimeError("release"),
1014
+ None,
1015
+ None,
1016
+ ]
1017
+
1018
+ handle = w._xfer_blocks(
1019
+ read_spec=ReadSpec(0, [[1]], [[1]]),
1020
+ dst_engine_id=w.engine_id,
1021
+ request_id=request_id,
1022
+ remote_request_id="decode-request",
1023
+ local_xfer_side_handle=1,
1024
+ remote_xfer_side_handle=2,
1025
+ )
1026
+ assert handle == (101 if release_fails else None)
1027
+ w._sending_transfers[request_id] = [102]
1028
+ if handle is not None:
1029
+ w._sending_transfers[request_id].append(handle)
1030
+ w.nixl_wrapper.check_xfer_state.return_value = "DONE"
1031
+
1032
+ assert w.get_transfer_results().finished_sending == set()
1033
+ assert request_id in w._reqs_to_send
1034
+ assert request_id not in w._sending_transfers
1035
+ assert w._evict_finished_inbox.empty()
1036
+
1037
+ @pytest.mark.parametrize("first_state", ["ERR", "DONE"])
1038
+ def test_push_completion_after_expiry_is_not_reported_twice(self, first_state):
1039
+ """A WRITE finishing after lease expiry must not complete the request again."""
1040
+ w = self._pollable_worker()
1041
+ request_id = self._make_sending_req(w)
1042
+ w.nixl_wrapper.check_xfer_state.side_effect = [
1043
+ first_state,
1044
+ "PROC",
1045
+ "PROC",
1046
+ "DONE",
1047
+ ]
1048
+ assert w.get_transfer_results().finished_sending == set()
1049
+
1050
+ w.expected_consumer_notifications_by_req = {}
1051
+ w._reqs_to_send[request_id] = time.perf_counter() - 1
1052
+ assert w.get_transfer_results().finished_sending == {request_id}
1053
+ assert w._evict_finished_inbox.get_nowait() == request_id
1054
+
1055
+ assert w.get_transfer_results().finished_sending == set()
1056
+ assert request_id not in w._sending_transfers
1057
+ assert request_id not in w._send_failures
1058
+ assert w._evict_finished_inbox.empty()
1059
+
1060
1061
# ----------------------------------------------------------------- #
1062
# Negative / error-path tests #
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_worker.py
+14
-7
@@ -103,6 +103,7 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
103
# (``_pop_done_transfers``); guarded by
104
# ``_sending_transfers_lock``.
105
self._sending_transfers = defaultdict[ReqId, list[TransferHandle]](list)
106
+ self._send_failures: set[ReqId] = set()
107
self._sending_transfers_lock = threading.Lock()
108
109
# Writer-thread owned matching state.
@@ -162,6 +163,7 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
163
for handle in handles:
164
self.nixl_wrapper.release_xfer_handle(handle)
165
self._sending_transfers.clear()
166
+ self._send_failures.clear()
167
super().shutdown()
168
169
# --- Engine-main-thread entry point -------------------------------- #
@@ -728,6 +730,8 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
730
# don't have a ``_recving_metadata`` entry to invalidate, so
731
# we just release the handle and let the engine reschedule
732
# via the lease / watchdog.
733
+ with self._sending_transfers_lock:
734
+ self._send_failures.add(request_id)
735
if not self._handle_failed_transfer(request_id, handle):
736
return handle
737
return None
@@ -811,13 +815,16 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
815
done_pushing, failed_pushing = self._pop_done_transfers(
816
self._sending_transfers
817
)
814
- # A failed send must never be reported as done: its blocks
815
- # are freed via the lease / watchdog instead.
816
- done_pushing = {
817
- req_id
818
- for req_id in done_pushing - failed_pushing
819
- if req_id in self._recving_metadata
820
- }
818
+ # Remember failures until the final sibling WRITE completes.
819
+ self._send_failures.update(failed_pushing)
820
+ successful = {
821
+ req_id
822
+ for req_id in done_pushing - self._send_failures
823
+ if req_id in self._reqs_to_send or req_id in self._reqs_to_process
824
+ }
825
+ self._send_failures.difference_update(done_pushing | done_sending)
826
+ # Expired requests were already reported, even if their WRITEs finish later.
827
+ done_pushing = successful
828
for req_id in done_pushing:
829
self._reqs_to_send.pop(req_id, None)
830
self._reqs_to_process.discard(req_id)