Skip to content

Commit faac3c6

Browse files
committed
feat(runtime): add Bulwark failover replay support
Signed-off-by: mkhadkevich <mkhadkevich@nvidia.com>
1 parent aeda23b commit faac3c6

26 files changed

Lines changed: 2873 additions & 132 deletions

File tree

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

Lines changed: 33 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,9 +17,38 @@
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+
def is_shutdown_in_progress() -> bool:
25+
"""Return whether this process is already handling graceful shutdown."""
26+
return _shutdown_started.is_set()
27+
28+
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+
2252
def get_grace_period_seconds() -> float:
2353
value = os.getenv(_GRACE_PERIOD_ENV)
2454
if value is None or value == "":
@@ -106,6 +136,9 @@ async def graceful_shutdown_with_discovery(
106136
logger.info("Received shutdown signal; unregistering endpoints from discovery")
107137
await _unregister_endpoints(list(endpoints))
108138

139+
if fast_failover_exit_enabled():
140+
_fast_exit_after_failover_unregister(shutdown_event)
141+
109142
if grace_period_s > 0:
110143
logger.info("Grace period %.2fs before stopping endpoints", grace_period_s)
111144
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()

components/src/dynamo/frontend/frontend_args.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
add_negatable_bool_argument,
2626
env_or_default,
2727
)
28+
from dynamo.common.utils.namespace import get_worker_namespace
2829

2930
from . import __version__
3031

@@ -62,6 +63,7 @@ class FrontendConfig(RouterConfigBase, KvRouterConfigBase, AicPerfConfigBase):
6263

6364
namespace: Optional[str] = None
6465
namespace_prefix: Optional[str] = None
66+
bulwark_gateway_endpoint: Optional[str] = None
6567

6668
migration_limit: int
6769
migration_max_seq_len: Optional[int]
@@ -94,6 +96,26 @@ def validate(self) -> None:
9496
self.router_mode = "kv"
9597
self.apply_load_aware_preset()
9698

99+
if self.bulwark_gateway_endpoint:
100+
if self.namespace and not self.namespace_prefix:
101+
self.namespace = get_worker_namespace(self.namespace)
102+
if not self.bulwark_gateway_endpoint.startswith("dyn://"):
103+
raise ValueError(
104+
"--bulwark-gateway-endpoint must be a dyn://namespace.component.endpoint path"
105+
)
106+
if not self.namespace and not self.namespace_prefix:
107+
raise ValueError(
108+
"--bulwark-gateway-endpoint requires --namespace or --namespace-prefix "
109+
"to select the private primary/shadow worker namespace"
110+
)
111+
if self.interactive:
112+
raise ValueError(
113+
"--bulwark-gateway-endpoint cannot be combined with --interactive"
114+
)
115+
if self.kserve_grpc_server:
116+
raise ValueError(
117+
"--bulwark-gateway-endpoint cannot be combined with --kserve-grpc-server"
118+
)
97119
if bool(self.tls_cert_path) ^ bool(self.tls_key_path): # ^ is XOR
98120
raise ValueError(
99121
"--tls-cert-path and --tls-key-path must be provided together"
@@ -265,6 +287,19 @@ def add_arguments(self, parser) -> None:
265287
),
266288
)
267289

290+
add_argument(
291+
g,
292+
flag_name="--bulwark-gateway-endpoint",
293+
env_var="DYN_BULWARK_GATEWAY_ENDPOINT",
294+
default=None,
295+
help=(
296+
"Expose this frontend as a stable request-plane worker endpoint for Bulwark "
297+
"compound workers. The value is a public dyn://namespace.component.endpoint "
298+
"path; private primary/shadow workers are discovered using --namespace or "
299+
"--namespace-prefix."
300+
),
301+
)
302+
268303
add_argument(
269304
g,
270305
flag_name="--migration-limit",

components/src/dynamo/frontend/main.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -171,14 +171,18 @@ async def async_main():
171171
Initializes the distributed runtime, configures routing, and starts
172172
the HTTP server or interactive mode based on command-line arguments.
173173
"""
174-
# The system status server port is a worker concern.
174+
# The system status server port is usually a worker concern.
175175
#
176176
# Serve tests set DYN_SYSTEM_PORT for the worker, but aggregated launch scripts
177-
# start `dynamo.frontend` first. If the frontend inherits DYN_SYSTEM_PORT, it can
178-
# bind that port before the worker, causing port conflicts and/or scraping the
179-
# wrong metrics endpoint.
177+
# start `dynamo.frontend` first. If the normal frontend inherits DYN_SYSTEM_PORT,
178+
# it can bind that port before the worker, causing port conflicts and/or scraping
179+
# the wrong metrics endpoint. Bulwark gateway endpoint mode intentionally keeps
180+
# the system server so Kubernetes readiness can gate the stable worker entity.
181+
frontend_system_port = os.environ.get("DYN_SYSTEM_PORT")
180182
os.environ.pop("DYN_SYSTEM_PORT", None)
181183
config, vllm_flags, sglang_flags = parse_args()
184+
if config.bulwark_gateway_endpoint and frontend_system_port:
185+
os.environ["DYN_SYSTEM_PORT"] = frontend_system_port
182186
dump_config(config.dump_config_to, config)
183187
max_seq_info = (
184188
f", max_seq_len: {config.migration_max_seq_len}"
@@ -189,8 +193,9 @@ async def async_main():
189193
f"Request migration {'enabled' if config.migration_limit > 0 else 'disabled'} "
190194
f"(limit: {config.migration_limit}{max_seq_info})"
191195
)
192-
# Warn if DYN_SYSTEM_PORT is set (frontend doesn't use system metrics server)
193-
if os.environ.get("DYN_SYSTEM_PORT"):
196+
# Warn if DYN_SYSTEM_PORT is set for a normal frontend. Gateway endpoint mode
197+
# uses it for Kubernetes readiness.
198+
if os.environ.get("DYN_SYSTEM_PORT") and not config.bulwark_gateway_endpoint:
194199
logger.warning(
195200
"=" * 80 + "\n"
196201
"WARNING: DYN_SYSTEM_PORT is set but NOT used by the frontend!\n"
@@ -297,7 +302,15 @@ def signal_handler():
297302
engine = await make_engine(runtime, e)
298303

299304
try:
300-
if config.interactive:
305+
if config.bulwark_gateway_endpoint:
306+
logger.info(
307+
"Starting Bulwark frontend gateway at %s; private workers selected by namespace=%s namespace_prefix=%s",
308+
config.bulwark_gateway_endpoint,
309+
config.namespace,
310+
config.namespace_prefix,
311+
)
312+
await run_input(runtime, config.bulwark_gateway_endpoint, engine)
313+
elif config.interactive:
301314
await run_input(runtime, "text", engine)
302315
elif config.kserve_grpc_server:
303316
await run_input(runtime, "grpc", engine)

0 commit comments

Comments
 (0)