1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""End-to-end tests for the elastic fault-tolerance framework.
4
+
5
+Requires nixl_ep FT hardware; gated behind ``has_nixl_ep()``.
6
+"""
7
+
8
+import contextlib
9
+import os
10
+import threading
11
+import time
12
+from concurrent.futures import ThreadPoolExecutor
13
+from typing import Any
14
+
15
+import psutil
16
+import pytest
17
+import requests
18
+
19
+from tests.utils import RemoteOpenAIServer, multi_gpu_test
20
+from vllm.utils.import_utils import has_nixl_ep
21
+
22
+MODEL_NAME = os.getenv("MODEL_NAME", "ibm-research/PowerMoE-3b")
23
+DP_SIZE = 2
24
+
25
+# Fault-detection timeout budget:
26
+# - CPU: Gloo DP allreduce timeout (30s) detects the dead peer.
27
+# - nixl_ep: kernel masks the dead rank after Buffer's default timeout_ms=30000 (30s).
28
+# - Deadline (45s): slowest fallback (30s) + margin.
29
+CPU_DISTRIBUTED_TIMEOUT_S = 30
30
+FAULT_DETECTION_DEADLINE_S = 45
31
+
32
+
33
+# Patches ``gpu.dp_utils.sync_cudagraph_and_dp_padding`` to raise on ``rank`` at
34
+# a chosen step. Gated on VLLM_FT_TEST_INJECT_FAULT.
35
+_FAULT_INJECT_SITECUSTOMIZE = """\
36
+import builtins
37
+import os
38
+import sys
39
+
40
+_SPEC = os.environ.get("VLLM_FT_TEST_INJECT_FAULT")
41
+_MODULE = "vllm.v1.worker.gpu.dp_utils"
42
+_ATTR = "sync_cudagraph_and_dp_padding"
43
+
44
+if _SPEC:
45
+ _f = dict(kv.split("=", 1) for kv in _SPEC.split(","))
46
+ _RANK, _STEP = int(_f["rank"]), int(_f["step"])
47
+ _steps = [0]
48
+
49
+ def _patch(m):
50
+ import inspect
51
+ _orig = getattr(m, _ATTR)
52
+ _sig = inspect.signature(_orig)
53
+ def _wrapped(*args, **kwargs):
54
+ result = _orig(*args, **kwargs)
55
+ bound = _sig.bind(*args, **kwargs)
56
+ bound.apply_defaults()
57
+ dp_rank = bound.arguments.get("dp_rank")
58
+ if dp_rank == _RANK:
59
+ _steps[0] += 1
60
+ if _steps[0] == _STEP:
61
+ raise RuntimeError(
62
+ "FT test fault injection (rank=%d step=%d)" % (_RANK, _STEP)
63
+ )
64
+ return result
65
+
66
+ setattr(m, _ATTR, _wrapped)
67
+
68
+ _real_import = builtins.__import__
69
+
70
+ def _hook(name, *a, **k):
71
+ module = _real_import(name, *a, **k)
72
+ m = sys.modules.get(_MODULE)
73
+ # During vLLM's circular import the module lands in sys.modules before
74
+ # its functions are defined; hasattr guards against patching too early.
75
+ if (
76
+ m is not None
77
+ and hasattr(m, _ATTR)
78
+ and not getattr(m, "_ft_patched", False)
79
+ ):
80
+ m._ft_patched = True
81
+ _patch(m)
82
+ return module
83
+
84
+ builtins.__import__ = _hook
85
+"""
86
+
87
+
88
+def _install_fault_injection(monkeypatch, tmp_path, rank: int, step: int) -> None:
89
+ """Arrange for the DP-sync fn to raise on ``rank`` at serving ``step``.
90
+
91
+ Writes a ``sitecustomize.py`` and prepends its dir to PYTHONPATH so every
92
+ vLLM subprocess picks it up; the fault spec is read from the environment.
93
+ """
94
+ site_dir = tmp_path / "ft_inject"
95
+ site_dir.mkdir()
96
+ (site_dir / "sitecustomize.py").write_text(_FAULT_INJECT_SITECUSTOMIZE)
97
+ existing = os.environ.get("PYTHONPATH", "")
98
+ monkeypatch.setenv(
99
+ "PYTHONPATH",
100
+ str(site_dir) + (os.pathsep + existing if existing else ""),
101
+ )
102
+ monkeypatch.setenv("VLLM_FT_TEST_INJECT_FAULT", f"rank={rank},step={step}")
103
+
104
+
105
+def _ft_server_args() -> list[str]:
106
+ return [
107
+ "--enforce-eager",
108
+ "--dtype",
109
+ "bfloat16",
110
+ "--max-model-len",
111
+ "2048",
112
+ "--max-num-seqs",
113
+ "128",
114
+ "--enable-expert-parallel",
115
+ "--all2all-backend",
116
+ "nixl_ep",
117
+ "--enable-fault-tolerance",
118
+ "--cpu-distributed-timeout-seconds",
119
+ str(CPU_DISTRIBUTED_TIMEOUT_S),
120
+ "--fault-tolerance-config",
121
+ '{"engine_recovery_timeout_sec": 120}',
122
+ ]
123
+
124
+
125
+def _ft_manager():
126
+ """Build the shared DP+EP fault-tolerant server topology (one engine/server)."""
127
+ from tests.v1.distributed.test_external_lb_dp import ExternalLBServerManager
128
+
129
+ return ExternalLBServerManager(
130
+ MODEL_NAME,
131
+ DP_SIZE,
132
+ api_server_count=1, # FT requires a single API server per engine
133
+ base_server_args=_ft_server_args(),
134
+ tp_size=1,
135
+ )
136
+
137
+
138
+def _server_for_rank(servers, rank: int):
139
+ """Locate the server for a DP rank."""
140
+ for server, sargs in servers:
141
+ if "--data-parallel-rank" in sargs:
142
+ idx = sargs.index("--data-parallel-rank")
143
+ if int(sargs[idx + 1]) == rank:
144
+ return server
145
+ raise AssertionError(f"no server found for DP rank {rank}")
146
+
147
+
148
+def _complete(client):
149
+ """Issue the one standard completion the tests use everywhere."""
150
+ return client.completions.create(
151
+ model=MODEL_NAME,
152
+ prompt="Hello, my name is",
153
+ max_tokens=5,
154
+ temperature=0.0,
155
+ timeout=10.0,
156
+ )
157
+
158
+
159
+def _in_parallel(fn, servers) -> list:
160
+ """Run ``fn(server)`` for all servers concurrently; return results in order."""
161
+ with ThreadPoolExecutor(max_workers=len(servers)) as ex:
162
+ return list(ex.map(fn, servers))
163
+
164
+
165
+def _get_ft_status(server) -> dict:
166
+ resp = requests.get(server.url_for("fault_tolerance/status"), timeout=10)
167
+ resp.raise_for_status()
168
+ return resp.json()
169
+
170
+
171
+def _assert_serving_and_healthy(servers) -> None:
172
+ """Wait until every engine is healthy, then serve one request per server."""
173
+ healthy = _wait_for_engines(
174
+ list(servers), match_key="status", match_values={"healthy"}
175
+ )
176
+ assert all(healthy), healthy
177
+ _in_parallel(lambda s: _complete(s.get_client()), servers)
178
+
179
+
180
+def _apply_ft(server, instruction: str, params: dict | None = None) -> dict:
181
+ """POST an FT instruction; assert it is accepted (202) and return the body."""
182
+ resp = requests.post(
183
+ server.url_for("fault_tolerance/apply"),
184
+ json={"instruction": instruction, "params": params or {}},
185
+ timeout=10,
186
+ )
187
+ assert resp.status_code == 202, resp.text
188
+ return resp.json()
189
+
190
+
191
+def _kill_worker_process(server) -> None:
192
+ """SIGKILL only the worker proc, leaving EngineCore and API server alive."""
193
+ workers = [
194
+ p
195
+ for p in psutil.Process(server.proc.pid).children(recursive=True)
196
+ if "Worker" in " ".join(p.cmdline())
197
+ ]
198
+ assert len(workers) == 1, f"expected 1 worker proc, found: {workers}"
199
+ workers[0].kill()
200
+
201
+
202
+def _wait_for_engines(
203
+ servers: list[RemoteOpenAIServer],
204
+ match_key: str,
205
+ match_values: set[str],
206
+ deadline_s: int = FAULT_DETECTION_DEADLINE_S,
207
+) -> list[dict[str, Any] | None]:
208
+ """Poll ``/fault_tolerance/status`` until each server's engine status matches.
209
+
210
+ A server matches when its engine-status dict has ``match_key`` equal to
211
+ one of ``match_values``. Returns one engine-status dict per server. Servers still
212
+ unmatched after ``deadline_s`` get None.
213
+ """
214
+ results: dict[int, dict[str, Any]] = {}
215
+ pending = dict(enumerate(servers))
216
+ start = time.time()
217
+ while pending and time.time() - start < deadline_s:
218
+ for i, server in list(pending.items()):
219
+ with contextlib.suppress(Exception):
220
+ for engine_status in _get_ft_status(server)["engines"]:
221
+ if engine_status.get(match_key) in match_values:
222
+ results[i] = engine_status
223
+ del pending[i]
224
+ break
225
+ if pending:
226
+ time.sleep(1.0)
227
+ return [results.get(i) for i in range(len(servers))]
228
+
229
+
230
+@contextlib.contextmanager
231
+def _driving(*servers):
232
+ """Pump completions at each server in the background for the block's duration.
233
+
234
+ Keeps every engine stepping into its failed component so a fault surfaces.
235
+ Errors are expected once faulted and are ignored.
236
+ """
237
+ stop = threading.Event()
238
+
239
+ def _drive(server):
240
+ client = server.get_client()
241
+ while not stop.is_set():
242
+ with contextlib.suppress(Exception):
243
+ _complete(client)
244
+ time.sleep(0.2)
245
+
246
+ threads = [threading.Thread(target=_drive, args=(s,), daemon=True) for s in servers]
247
+ for t in threads:
248
+ t.start()
249
+ try:
250
+ yield
251
+ finally:
252
+ stop.set()
253
+ for t in threads:
254
+ t.join(timeout=2)
255
+
256
+
257
+def _wait_for_ft_apply_outcome(server, request_id: str, deadline_s: int) -> str | None:
258
+ """Wait until ``/fault_tolerance/status`` records the FT apply outcome."""
259
+ engine_status = _wait_for_engines(
260
+ [server],
261
+ match_key="last_ft_request_id",
262
+ match_values={request_id},
263
+ deadline_s=deadline_s,
264
+ )[0]
265
+ return engine_status.get("ft_error") if engine_status else None
266
+
267
+
268
+@pytest.mark.skipif(not has_nixl_ep(), reason="Requires nixl_ep all2all backend")
269
+@multi_gpu_test(num_gpus=2)
270
+def test_injected_fault_retry_recovers_all_ranks(monkeypatch, tmp_path):
271
+ """An exception injected into the inference path drives full retry recovery.
272
+
273
+ Injecting an exception into ``sync_cudagraph_and_dp_padding`` at a chosen
274
+ step on rank 1.
275
+
276
+ - Rank 1 raises inside the busy loop and goes UNHEALTHY.
277
+ - Rank 0 detects the now-absent peer via the communication timeout and also
278
+ goes UNHEALTHY.
279
+
280
+ Both being UNHEALTHY is the precondition for ``retry``. The fault is patched
281
+ into the DP-sync fn from the test (via a generated ``sitecustomize``).
282
+ """
283
+ fault_step = int(os.getenv("FT_FAULT_STEP", "50"))
284
+ _install_fault_injection(monkeypatch, tmp_path, rank=1, step=fault_step)
285
+
286
+ with _ft_manager() as servers:
287
+ assert len(servers) == DP_SIZE
288
+ rank0 = _server_for_rank(servers, 0)
289
+ rank1 = _server_for_rank(servers, 1)
290
+
291
+ # 1. Both engines healthy and serving.
292
+ _assert_serving_and_healthy((rank0, rank1))
293
+
294
+ # 2. Drive both ranks so rank 1 accumulates execute_model steps and trips
295
+ # the injected fault; rank 0 then times out on the DP allreduce.
296
+ with _driving(rank0, rank1):
297
+ faulted = _wait_for_engines(
298
+ [rank0, rank1], match_key="status", match_values={"unhealthy"}
299
+ )
300
+
301
+ for rank, engine_status in enumerate(faulted):
302
+ assert engine_status is not None, (
303
+ f"rank {rank} did not report UNHEALTHY within "
304
+ f"{FAULT_DETECTION_DEADLINE_S}s -- it likely hung"
305
+ )
306
+ # The rank that raised carries the fault info from its own exception.
307
+ assert faulted[1] is not None
308
+ assert faulted[1].get("fault_info"), faulted[1]
309
+
310
+ # 3. retry both engines.
311
+ for server in (rank0, rank1):
312
+ _apply_ft(server, "retry")
313
+
314
+ # 4. Recovery completes: both engines return to healthy and serve again.
315
+ _assert_serving_and_healthy((rank0, rank1))
316
+
317
+
318
+@pytest.mark.skipif(not has_nixl_ep(), reason="Requires nixl_ep all2all backend")
319
+@multi_gpu_test(num_gpus=2)
320
+def test_worker_kill_survivor_unhealthy_and_dead_rejects_retry():
321
+ """One worker kill surfaces two status transitions at once.
322
+
323
+ SIGKILLing only rank 1's worker leaves both EngineCores alive, so the same
324
+ fault is seen two ways:
325
+
326
+ - Survivor (rank 0): detects the dead peer via Gloo allreduce / nixl_ep
327
+ kernel timeout. Its own executor is fine, so ``on_fault`` marks it
328
+ UNHEALTHY with a ``fault_info``.
329
+ - Victim (rank 1): detects its own executor failure and marks itself DEAD.
330
+
331
+ Recovery is gated on UNHEALTHY: the DEAD engine accepts ``retry`` at the
332
+ HTTP layer (202 = background dispatch) but rejects it in the engine,
333
+ recording the reason as ``ft_error``.
334
+ """
335
+ with _ft_manager() as servers:
336
+ assert len(servers) == DP_SIZE
337
+ survivor = _server_for_rank(servers, 0)
338
+ victim = _server_for_rank(servers, 1)
339
+
340
+ # 1. Confirm both engines are healthy and serving.
341
+ _assert_serving_and_healthy((survivor, victim))
342
+
343
+ # 2. Kill only the victim's worker; both EngineCores stay alive.
344
+ _kill_worker_process(victim)
345
+
346
+ # 3. Drive both engines so each keeps stepping into the failed component.
347
+ with _driving(survivor, victim):
348
+ survivor_faulted, victim_faulted = _wait_for_engines(
349
+ [survivor, victim],
350
+ match_key="status",
351
+ match_values={"dead", "unhealthy"},
352
+ )
353
+
354
+ assert survivor_faulted is not None, (
355
+ "survivor did not report the peer fault within "
356
+ f"{FAULT_DETECTION_DEADLINE_S}s -- it likely hung"
357
+ )
358
+ # The survivor's own executor is fine, so it must be UNHEALTHY, not DEAD.
359
+ assert survivor_faulted["status"] == "unhealthy", survivor_faulted
360
+ assert survivor_faulted.get("fault_info"), survivor_faulted
361
+
362
+ assert victim_faulted is not None, (
363
+ "victim did not report its worker's death within "
364
+ f"{FAULT_DETECTION_DEADLINE_S}s"
365
+ )
366
+ assert victim_faulted["status"] == "dead", victim_faulted
367
+
368
+ # 4. retry is accepted at the HTTP layer (202 = background dispatch)...
369
+ request_id = _apply_ft(victim, "retry")["request_id"]
370
+
371
+ # 5. ...but the DEAD engine must reject it: recovery requires UNHEALTHY.
372
+ ft_error = _wait_for_ft_apply_outcome(
373
+ victim, request_id, FAULT_DETECTION_DEADLINE_S
374
+ )
375
+ assert ft_error is not None, (
376
+ "rejection was never recorded in /fault_tolerance/status"
377
+ )
378
+ assert "status is DEAD" in ft_error, ft_error