[Bugfix][KV Connector] Retry Mooncake bootstrap registration on timeout (reopens #55763) (#58919)

Chao Lei committed Sep 28, 2026 at 03:47 UTC 386ac2573e334a670a0406c9a2bca3a9981ac63d
5 files changed +254 -14
docs/features/mooncake_connector_usage.md
+7
@@ -46,6 +46,13 @@ Now you can send requests to the proxy server through port 8000.
46 - For headless instances, must be the same as the master instance
47 - Each instance needs a unique port on its host; using the same port number across different hosts is fine
48
49 +- `VLLM_MOONCAKE_CONNECTOR_TIMEOUT`: Per-request timeout (in seconds) for MooncakeConnector HTTP calls to the bootstrap server, including prefiller worker registration at startup. (Optional)
50 + - Default: 30.0
51 + - Global rank 0 hosts the bootstrap server in the same process that mounts the Mooncake transfer engine's host segment, so a large `MOONCAKE_GLOBAL_SEGMENT_SIZE` can delay responses for several seconds during startup
52 + - Raise this if bootstrap server timeout warnings appear on hosts with very large host-memory segments
53 + - Bootstrap registration uses up to 3 attempts internally, with exponential backoff capped at 10 seconds; connection errors and timeouts are both retried
54 + - Once the attempts are exhausted the caller raises instead of blocking, so failures surface rather than hanging
55 +
56 - `WITH_NVIDIA_PEERMEM`: Selects how mooncake registers GPU memory for RDMA. Read by mooncake, not vLLM.
57 - Default: 1, which uses `ibv_reg_mr()` and requires the `nvidia-peermem` kernel module to be loaded
58 - Set to 0 to use the DMA-BUF path, which does not need that module. Required on hosts where `nvidia-peermem` is not loaded, such as GB200
tests/v1/kv_connector/unit/test_mooncake_connector.py
+183 -1
@@ -3,10 +3,12 @@
3
4 import asyncio
5 import contextlib
6 +import threading
7 import time
8 from types import SimpleNamespace
9 from unittest.mock import AsyncMock, MagicMock, patch
10
11 +import httpx
12 import pytest
13 import torch
14 import zmq.asyncio
@@ -14,6 +16,7 @@ import zmq.asyncio
16 from tests.v1.attention.utils import dense_kv_cache_views
17 from vllm import envs
18 from vllm.config import set_current_vllm_config
19 +from vllm.distributed.kv_transfer.kv_connector.v1.mooncake import mooncake_connector
20 from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_connector import (
21 KVConnectorRole,
22 MooncakeConnector,
@@ -33,6 +36,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_connector im
36 )
37 from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_utils import (
38 MooncakeBootstrapServer,
39 + RegisterWorkerPayload,
40 )
41 from vllm.utils.network_utils import get_open_port
42 from vllm.v1.kv_cache_interface import (
@@ -538,6 +542,46 @@ def bootstrap_server():
542 server.shutdown()
543
544
545 +@pytest.mark.asyncio
546 +async def test_register_worker_recovers_from_slow_bootstrap_server(monkeypatch):
547 + """End-to-end: a real HTTP server that responds slower than the configured
548 + timeout must be retried on the wire, not treated as fatal."""
549 + original = MooncakeBootstrapServer.register_worker
550 + state = {"done": False, "calls": 0}
551 +
552 + async def slow_register(self, payload: RegisterWorkerPayload):
553 + state["calls"] += 1
554 + if not state["done"]:
555 + state["done"] = True
556 + # Record the worker first, then stall past the client timeout, so
557 + # the retry exercises the duplicate-registration path.
558 + response = await original(self, payload)
559 + await asyncio.sleep(1.5)
560 + return response
561 + return await original(self, payload)
562 +
563 + monkeypatch.setattr(MooncakeBootstrapServer, "register_worker", slow_register)
564 +
565 + port = get_open_port()
566 + monkeypatch.setenv("VLLM_MOONCAKE_BOOTSTRAP_PORT", str(port))
567 + monkeypatch.setenv("VLLM_MOONCAKE_CONNECTOR_TIMEOUT", "0.5")
568 + monkeypatch.setattr(mooncake_connector, "_BOOTSTRAP_MAX_ATTEMPTS", 5)
569 +
570 + server = MooncakeBootstrapServer("127.0.0.1", port)
571 + server.start()
572 + try:
573 + worker = _make_local_register_worker_stub()
574 + await MooncakeConnectorWorker.register_worker_with_bootstrap(worker)
575 + assert state["calls"] == 2
576 +
577 + async with httpx.AsyncClient() as client:
578 + response = await client.get(f"http://127.0.0.1:{port}/query")
579 + assert response.status_code == 200
580 + assert response.json()["0"]["engine_id"] == "eng-1"
581 + finally:
582 + server.shutdown()
583 +
584 +
585 @pytest.mark.asyncio
586 async def test_bootstrap_server(bootstrap_server: MooncakeBootstrapServer):
587 """Tests the bootstrap server's api for worker registration and querying.
@@ -589,9 +633,16 @@ async def test_bootstrap_server(bootstrap_server: MooncakeBootstrapServer):
633 assert data["0"]["worker_addr"]["0"]["0"] == "tcp://1.1.1.1:1111"
634 assert data["0"]["worker_addr"]["0"]["1"] == "tcp://2.2.2.2:2222"
635
592 - # Test failure: re-registering the same worker
636 + # Re-registering the identical payload is idempotent.
637 async with httpx.AsyncClient() as client:
638 response = await client.post(f"{base_url}/register", json=payload1)
639 + assert response.status_code == 200
640 + assert response.json() == {"status": "ok"}
641 +
642 + # Test failure: same ranks, conflicting address
643 + payload1_conflict = dict(payload1, addr="tcp://9.9.9.9:9999")
644 + async with httpx.AsyncClient() as client:
645 + response = await client.post(f"{base_url}/register", json=payload1_conflict)
646 assert response.status_code == 400
647 assert "is already registered" in response.text
648
@@ -628,6 +679,137 @@ def _make_bootstrap_vllm_config(
679 )
680
681
682 +def _make_local_register_worker_stub():
683 + return SimpleNamespace(
684 + vllm_config=SimpleNamespace(
685 + parallel_config=SimpleNamespace(
686 + local_engines_only=False,
687 + data_parallel_rank_local=0,
688 + data_parallel_index=0,
689 + nnodes_within_dp=1,
690 + master_addr="127.0.0.1",
691 + data_parallel_master_ip="127.0.0.1",
692 + )
693 + ),
694 + hostname="127.0.0.1",
695 + side_channel_port=1234,
696 + engine_id="eng-1",
697 + dp_rank=0,
698 + tp_rank=0,
699 + pp_rank=0,
700 + )
701 +
702 +
703 +def _make_register_worker_stub():
704 + return SimpleNamespace(
705 + vllm_config=_make_bootstrap_vllm_config(),
706 + hostname="127.0.0.1",
707 + side_channel_port=1234,
708 + engine_id="eng-1",
709 + dp_rank=0,
710 + tp_rank=0,
711 + pp_rank=0,
712 + )
713 +
714 +
715 +class _FlakyAsyncClient:
716 + """httpx.AsyncClient stub that read-times-out `failures` times first."""
717 +
718 + def __init__(self, failures: int, calls: list[int], **kwargs):
719 + self.failures = failures
720 + self.calls = calls
721 +
722 + async def __aenter__(self):
723 + return self
724 +
725 + async def __aexit__(self, *exc_info):
726 + return False
727 +
728 + async def post(self, url, json=None):
729 + self.calls.append(1)
730 + if len(self.calls) <= self.failures:
731 + raise httpx.ReadTimeout("simulated slow bootstrap server")
732 + response = MagicMock()
733 + response.raise_for_status = MagicMock()
734 + return response
735 +
736 +
737 +@pytest.mark.asyncio
738 +async def test_register_worker_retries_on_read_timeout(monkeypatch):
739 + """A slow rank-0 bootstrap response must be retried, not treated as fatal."""
740 + monkeypatch.setattr(mooncake_connector, "_BOOTSTRAP_MAX_ATTEMPTS", 5)
741 + monkeypatch.setattr(asyncio, "sleep", AsyncMock())
742 +
743 + calls: list[int] = []
744 + monkeypatch.setattr(
745 + httpx,
746 + "AsyncClient",
747 + lambda **kwargs: _FlakyAsyncClient(2, calls),
748 + )
749 +
750 + worker = _make_register_worker_stub()
751 + await MooncakeConnectorWorker.register_worker_with_bootstrap(worker)
752 +
753 + assert len(calls) == 3
754 +
755 +
756 +@pytest.mark.asyncio
757 +async def test_register_worker_raises_after_max_attempts(monkeypatch):
758 + """Terminal registration failure must raise, not loop forever."""
759 + monkeypatch.setattr(mooncake_connector, "_BOOTSTRAP_MAX_ATTEMPTS", 3)
760 + monkeypatch.setattr(asyncio, "sleep", AsyncMock())
761 +
762 + calls: list[int] = []
763 + monkeypatch.setattr(
764 + httpx,
765 + "AsyncClient",
766 + lambda **kwargs: _FlakyAsyncClient(99, calls),
767 + )
768 +
769 + worker = _make_register_worker_stub()
770 + with pytest.raises(RuntimeError, match="after 3 attempts"):
771 + await MooncakeConnectorWorker.register_worker_with_bootstrap(worker)
772 +
773 + assert len(calls) == 3
774 +
775 +
776 +@pytest.mark.asyncio
777 +async def test_sender_listener_failure_propagates_to_caller(monkeypatch):
778 + """A terminal registration failure must surface via the Future rather than
779 + leaving register_kv_caches blocked on its ready event."""
780 + monkeypatch.setenv("VLLM_MOONCAKE_CONNECTOR_TIMEOUT", "0.1")
781 + monkeypatch.setattr(mooncake_connector, "_BOOTSTRAP_MAX_ATTEMPTS", 1)
782 +
783 + loop = asyncio.new_event_loop()
784 + thread = threading.Thread(target=loop.run_forever, daemon=True)
785 + thread.start()
786 +
787 + async def failing_listener(ready_event):
788 + raise RuntimeError("simulated terminal registration failure")
789 +
790 + try:
791 + ready_event = threading.Event()
792 + fut = asyncio.run_coroutine_threadsafe(failing_listener(ready_event), loop)
793 +
794 + deadline = time.monotonic() + 10.0
795 + raised = None
796 + while not ready_event.wait(timeout=0.1):
797 + if fut.done():
798 + try:
799 + fut.result()
800 + except RuntimeError as e:
801 + raised = e
802 + break
803 + assert time.monotonic() < deadline, "caller blocked past its deadline"
804 +
805 + assert raised is not None
806 + assert "simulated terminal registration failure" in str(raised)
807 + finally:
808 + loop.call_soon_threadsafe(loop.stop)
809 + thread.join(timeout=5)
810 + loop.close()
811 +
812 +
813 @pytest.mark.parametrize(
814 (
815 "tp_rank",
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py
+51 -11
@@ -10,7 +10,7 @@ from collections.abc import Collection
10 from concurrent.futures import ThreadPoolExecutor
11 from dataclasses import dataclass
12 from enum import IntEnum
13 -from typing import TYPE_CHECKING, Any
13 +from typing import TYPE_CHECKING, Any, Final
14
15 import msgspec
16 import numpy as np
@@ -70,6 +70,8 @@ from vllm.v1.worker.utils import select_common_block_size
70
71 logger = init_logger(__name__)
72
73 +_BOOTSTRAP_MAX_ATTEMPTS: Final[int] = 3
74 +
75 try:
76 from mooncake.engine import TransferEngine
77 except ImportError:
@@ -1147,24 +1149,44 @@ class MooncakeConnectorWorker:
1149 pp_rank=self.pp_rank,
1150 addr=worker_addr,
1151 )
1150 - while True:
1152 + timeout = envs.VLLM_MOONCAKE_CONNECTOR_TIMEOUT
1153 + max_attempts = _BOOTSTRAP_MAX_ATTEMPTS
1154 + backoff = 1.0
1155 + for attempt in range(1, max_attempts + 1):
1156 try:
1152 - async with httpx.AsyncClient() as client:
1157 + async with httpx.AsyncClient(timeout=timeout) as client:
1158 response = await client.post(url, json=payload.model_dump())
1159 response.raise_for_status()
1160 logger.debug("Successfully registered with bootstrap server at %s", url)
1156 - break
1157 - except httpx.ConnectError:
1158 - # Bootstrap server not ready, wait for a while and retry.
1159 - await asyncio.sleep(1)
1161 + return
1162 + except (httpx.ConnectError, httpx.TimeoutException) as e:
1163 + # Rank 0 may be busy registering a large memory segment.
1164 + if attempt == max_attempts:
1165 + break
1166 + logger.warning(
1167 + "Bootstrap registration attempt %d/%d for %s failed with %s: %s. "
1168 + "Retrying in %.1fs.",
1169 + attempt,
1170 + max_attempts,
1171 + payload,
1172 + type(e).__name__,
1173 + e,
1174 + backoff,
1175 + )
1176 + await asyncio.sleep(backoff)
1177 + backoff = min(backoff * 2, 10.0)
1178 except Exception as e:
1179 err_msg = (
1180 e.response.text if isinstance(e, httpx.HTTPStatusError) else str(e)
1181 )
1164 - logger.error(
1182 + logger.exception(
1183 "Error registering %s with bootstrap server: %s", payload, err_msg
1184 )
1167 - raise e
1185 + raise
1186 + raise RuntimeError(
1187 + f"Failed to register {payload} with the Mooncake bootstrap server at "
1188 + f"{url} after {max_attempts} attempts."
1189 + )
1190
1191 async def _mooncake_sender_listener(self, ready_event: threading.Event):
1192 """Background thread that listens for Mooncake requests, dispatches them
@@ -1800,11 +1822,29 @@ class MooncakeConnectorWorker:
1822 if self.is_kv_consumer:
1823 return
1824
1825 + # httpx applies the timeout to each phase (connect, read, write, pool)
1826 + # rather than to the whole request, so budget for all four.
1827 + register_timeout = envs.VLLM_MOONCAKE_CONNECTOR_TIMEOUT
1828 + max_attempts = _BOOTSTRAP_MAX_ATTEMPTS
1829 + backoff_total = sum(min(2.0**i, 10.0) for i in range(max_attempts - 1))
1830 + ready_timeout = max_attempts * 4 * register_timeout + backoff_total + 30.0
1831 ready_event = threading.Event()
1804 - asyncio.run_coroutine_threadsafe(
1832 + fut = asyncio.run_coroutine_threadsafe(
1833 self._mooncake_sender_listener(ready_event), self.sender_loop
1834 )
1807 - ready_event.wait() # Wait for listener ZMQ socket to be ready.
1835 + deadline = time.monotonic() + ready_timeout
1836 + while not ready_event.wait(timeout=1.0):
1837 + if fut.done():
1838 + fut.result()
1839 + raise RuntimeError(
1840 + "Mooncake sender listener exited before becoming ready."
1841 + )
1842 + if time.monotonic() > deadline:
1843 + fut.cancel()
1844 + raise RuntimeError(
1845 + "Mooncake sender listener did not become ready within "
1846 + f"{ready_timeout:.0f}s."
1847 + )
1848
1849 async def fetch_finished_recving_reqs(self) -> set[ReqId]:
1850 finished_recving_reqs = self.finished_recving_reqs
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_utils.py
+7 -2
@@ -97,14 +97,19 @@ class MooncakeBootstrapServer:
97 dp_entry.worker_addr[payload.tp_rank] = {}
98
99 tp_entry = dp_entry.worker_addr[payload.tp_rank]
100 - if payload.pp_rank in tp_entry:
100 + existing = tp_entry.get(payload.pp_rank)
101 + if existing is not None:
102 + # A client timeout can fire after the server recorded the
103 + # registration so an identical retry must not be an error.
104 + if existing == payload.addr:
105 + return {"status": "ok"}
106 raise HTTPException(
107 status_code=400,
108 detail=(
109 f"Worker with dp_rank={payload.dp_rank}, "
110 f"tp_rank={payload.tp_rank}, pp_rank={payload.pp_rank} "
111 f"is already registered at "
107 - f"{tp_entry[payload.pp_rank]}, "
112 + f"{existing}, "
113 f"but still want to register at {payload.addr}"
114 ),
115 )
vllm/envs.py
+6
@@ -260,6 +260,7 @@ if TYPE_CHECKING:
260 VLLM_ROCM_QUICK_REDUCE_MIN_SIZE_BYTES_MB: int | None = None
261 VLLM_ROCM_QUICK_REDUCE_QUANTIZATION_MIN_SIZE_KB: int | None = None
262 VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT: int = 480
263 + VLLM_MOONCAKE_CONNECTOR_TIMEOUT: float = 30.0
264 VLLM_ENABLE_CUDAGRAPH_GC: bool = False
265 VLLM_LOOPBACK_IP: str = ""
266 VLLM_ALLOW_CHUNKED_LOCAL_ATTN_WITH_HYBRID_KV_CACHE: bool = True
@@ -1713,6 +1714,11 @@ environment_variables: dict[str, Callable[[], Any]] = {
1714 "VLLM_EC_SIDE_CHANNEL_PORT": lambda: int(
1715 os.getenv("VLLM_EC_SIDE_CHANNEL_PORT", "5601")
1716 ),
1717 + # Per-request timeout (seconds) when a prefiller worker registers with
1718 + # the Mooncake bootstrap server.
1719 + "VLLM_MOONCAKE_CONNECTOR_TIMEOUT": lambda: float(
1720 + os.getenv("VLLM_MOONCAKE_CONNECTOR_TIMEOUT", "30.0")
1721 + ),
1722 # Port used for Mooncake handshake between remote agents.
1723 "VLLM_MOONCAKE_BOOTSTRAP_PORT": lambda: int(
1724 os.getenv("VLLM_MOONCAKE_BOOTSTRAP_PORT", "8998")