Skip to content

Commit 3c6281d

Browse files
committed
feat(runtime): add Bulwark failover replay support
Signed-off-by: Maksim Khadkevich <mkhadkevich@nvidia.com>
1 parent 6a18746 commit 3c6281d

22 files changed

Lines changed: 2679 additions & 113 deletions

File tree

components/src/dynamo/common/utils/graceful_shutdown.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import os
77
import signal
8+
import sys
89
from typing import Any, Callable, Coroutine, Iterable, Optional
910

1011
from dynamo._core import DistributedRuntime
@@ -16,6 +17,7 @@
1617
_DEFAULT_DRAIN_TIMEOUT_SECS = 30.0
1718
_DEFAULT_CLEANUP_TIMEOUT_SECS = 30.0
1819
_GRACE_PERIOD_ENV = "DYN_GRACEFUL_SHUTDOWN_GRACE_PERIOD_SECS"
20+
_FAST_FAILOVER_EXIT_ENV = "DYN_GMS_FAILOVER_FAST_EXIT_ON_SIGTERM"
1921
_shutdown_started = asyncio.Event()
2022

2123

@@ -24,6 +26,29 @@ def is_shutdown_in_progress() -> bool:
2426
return _shutdown_started.is_set()
2527

2628

29+
def fast_failover_exit_enabled() -> bool:
30+
value = os.getenv(_FAST_FAILOVER_EXIT_ENV)
31+
if value is None:
32+
return False
33+
return value.strip().lower() not in {"", "0", "false", "no", "off"}
34+
35+
36+
def _fast_exit_after_failover_unregister(
37+
shutdown_event: Optional[asyncio.Event],
38+
) -> None:
39+
if shutdown_event is not None:
40+
shutdown_event.set()
41+
logger.warning(
42+
"GMS failover fast-exit enabled; exiting after discovery unregister "
43+
"so the kernel releases failover ownership immediately"
44+
)
45+
try:
46+
sys.stdout.flush()
47+
sys.stderr.flush()
48+
finally:
49+
os._exit(0)
50+
51+
2752
def get_grace_period_seconds() -> float:
2853
value = os.getenv(_GRACE_PERIOD_ENV)
2954
if value is None or value == "":
@@ -111,6 +136,9 @@ async def graceful_shutdown_with_discovery(
111136
logger.info("Received shutdown signal; unregistering endpoints from discovery")
112137
await _unregister_endpoints(list(endpoints))
113138

139+
if fast_failover_exit_enabled():
140+
_fast_exit_after_failover_unregister(shutdown_event)
141+
114142
if grace_period_s > 0:
115143
logger.info("Grace period %.2fs before stopping endpoints", grace_period_s)
116144
await asyncio.sleep(grace_period_s)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Compatibility helpers for forwarding OTel trace context to engines."""
5+
6+
from __future__ import annotations
7+
8+
from dynamo._core import Context
9+
10+
11+
def build_trace_headers(
12+
context: Context | None,
13+
*,
14+
enabled: bool = True,
15+
) -> dict[str, str] | None:
16+
"""Return W3C trace headers for an engine request when available."""
17+
if not enabled or context is None:
18+
return None
19+
return context.trace_headers()

components/src/dynamo/common/utils/tests/test_graceful_shutdown.py

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,20 @@
3636

3737
_GRACEFUL_SHUTDOWN_PATH = Path(__file__).parent.parent / "graceful_shutdown.py"
3838

39-
# Provide a minimal dynamo._core stub so the module can be loaded
40-
_dynamo_stub = types.ModuleType("dynamo")
39+
# Provide a minimal dynamo._core stub while loading graceful_shutdown.py without
40+
# importing the native extension. The stub is restored immediately after module
41+
# load so pytest package collection can still import the real runtime bindings.
4142
_dynamo_core_stub = types.ModuleType("dynamo._core")
4243
_dynamo_core_stub.DistributedRuntime = object
43-
sys.modules.setdefault("dynamo", _dynamo_stub)
44-
sys.modules.setdefault("dynamo._core", _dynamo_core_stub)
44+
_previous_dynamo = sys.modules.get("dynamo")
45+
_previous_dynamo_core = sys.modules.get("dynamo._core")
46+
_added_dynamo_stub = False
47+
if importlib.util.find_spec("dynamo") is None:
48+
_dynamo_stub = types.ModuleType("dynamo")
49+
_dynamo_stub.__path__ = [] # mark as package for dynamo._core imports
50+
sys.modules["dynamo"] = _dynamo_stub
51+
_added_dynamo_stub = True
52+
sys.modules["dynamo._core"] = _dynamo_core_stub
4553

4654

4755
def _load_graceful_shutdown():
@@ -55,8 +63,20 @@ def _load_graceful_shutdown():
5563

5664

5765
_gs = _load_graceful_shutdown()
66+
if _previous_dynamo_core is None:
67+
sys.modules.pop("dynamo._core", None)
68+
else:
69+
sys.modules["dynamo._core"] = _previous_dynamo_core
70+
if _added_dynamo_stub:
71+
if _previous_dynamo is None:
72+
sys.modules.pop("dynamo", None)
73+
else:
74+
sys.modules["dynamo"] = _previous_dynamo
75+
5876
graceful_shutdown_with_discovery = _gs.graceful_shutdown_with_discovery
5977
install_signal_handlers = _gs.install_signal_handlers
78+
is_shutdown_in_progress = _gs.is_shutdown_in_progress
79+
fast_failover_exit_enabled = _gs.fast_failover_exit_enabled
6080

6181

6282
# ---------------------------------------------------------------------------
@@ -309,3 +329,75 @@ async def _run():
309329

310330
asyncio.run(_run())
311331
mock_runtime.shutdown.assert_called_once()
332+
333+
334+
def test_shutdown_in_progress_reflects_active_shutdown():
335+
"""The shutdown-in-progress flag is visible before shutdown_event is set."""
336+
assert is_shutdown_in_progress() is False
337+
338+
async def _run():
339+
mock_runtime = MagicMock()
340+
mock_endpoint = AsyncMock()
341+
mock_endpoint.unregister_endpoint_instance = AsyncMock(return_value=None)
342+
await graceful_shutdown_with_discovery(
343+
runtime=mock_runtime,
344+
endpoints=[mock_endpoint],
345+
shutdown_event=None,
346+
grace_period_s=0,
347+
)
348+
349+
asyncio.run(_run())
350+
351+
assert is_shutdown_in_progress() is True
352+
353+
354+
def test_fast_failover_exit_env_parsing(monkeypatch):
355+
monkeypatch.delenv("DYN_GMS_FAILOVER_FAST_EXIT_ON_SIGTERM", raising=False)
356+
assert fast_failover_exit_enabled() is False
357+
358+
for value in ("1", "true", "yes", "on", "enabled"):
359+
monkeypatch.setenv("DYN_GMS_FAILOVER_FAST_EXIT_ON_SIGTERM", value)
360+
assert fast_failover_exit_enabled() is True
361+
362+
for value in ("", "0", "false", "no", "off"):
363+
monkeypatch.setenv("DYN_GMS_FAILOVER_FAST_EXIT_ON_SIGTERM", value)
364+
assert fast_failover_exit_enabled() is False
365+
366+
367+
def test_fast_failover_exit_runs_after_unregister_before_runtime_shutdown(monkeypatch):
368+
class FastExit(Exception):
369+
pass
370+
371+
call_order = []
372+
shutdown_event = asyncio.Event()
373+
mock_runtime = MagicMock()
374+
mock_runtime.shutdown = MagicMock(side_effect=lambda: call_order.append("shutdown"))
375+
376+
async def _run():
377+
mock_endpoint = AsyncMock()
378+
mock_endpoint.unregister_endpoint_instance = AsyncMock(
379+
side_effect=lambda: call_order.append("unregister")
380+
)
381+
382+
def fake_fast_exit(event):
383+
assert event is shutdown_event
384+
event.set()
385+
call_order.append("fast_exit")
386+
raise FastExit
387+
388+
monkeypatch.setenv("DYN_GMS_FAILOVER_FAST_EXIT_ON_SIGTERM", "1")
389+
monkeypatch.setattr(_gs, "_fast_exit_after_failover_unregister", fake_fast_exit)
390+
391+
await graceful_shutdown_with_discovery(
392+
runtime=mock_runtime,
393+
endpoints=[mock_endpoint],
394+
shutdown_event=shutdown_event,
395+
grace_period_s=0,
396+
)
397+
398+
with pytest.raises(FastExit):
399+
asyncio.run(_run())
400+
401+
assert call_order == ["unregister", "fast_exit"]
402+
assert shutdown_event.is_set()
403+
mock_runtime.shutdown.assert_not_called()

docs/fault-tolerance/request-migration.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ The migration limit is configured at the **frontend** level and applies globally
3434
- Set via `--migration-limit` flag on the frontend
3535
- Applies to all models served by the frontend
3636

37+
### Retry Concurrency Configuration
38+
39+
During worker failover, many in-flight streams can disconnect at the same time and retry against the newly active worker. `DYN_MIGRATION_RETRY_CONCURRENCY` optionally caps the number of migrated retry streams that can run concurrently in a frontend process:
40+
41+
- Default behavior: unlimited retry concurrency (`DYN_MIGRATION_RETRY_CONCURRENCY=0` or unset)
42+
- Set via `DYN_MIGRATION_RETRY_CONCURRENCY` on the frontend or Bulwark gateway
43+
- The cap applies only after a request has entered migration/retry; first-attempt traffic is not throttled
44+
- A migrated stream holds a permit until that stream completes, which prevents a failover replay burst from overwhelming a warm shadow worker
45+
3746
### Max Sequence Length Configuration
3847

3948
The max sequence length setting controls how long the migration system will cache token state for a request. Once the total sequence length (prompt + generated tokens) exceeds this limit, migration is disabled for that request and token tracking stops:

0 commit comments

Comments
 (0)