forked from ai-dynamo/dynamo
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathworker.rs
More file actions
2294 lines (2090 loc) · 89.2 KB
/
Copy pathworker.rs
File metadata and controls
2294 lines (2090 loc) · 89.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! `Worker` — runtime lifecycle driver for an [`LLMEngine`].
//!
//! Creates the `DistributedRuntime`, starts the engine, registers the
//! model, serves the endpoint, and runs cleanup on shutdown. Non-generic
//! over the engine type so a PyO3-wrapped engine can feed in through the
//! same `Arc<dyn LLMEngine>` path.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use dynamo_llm::local_model::LocalModel;
use dynamo_llm::local_model::LocalModelBuilder;
use dynamo_llm::local_model::runtime_config::{
DisaggregatedEndpoint, ModelRuntimeConfig, StructuralTagMode, StructuralTagSchemaMode,
StructuralTagScope,
};
use dynamo_llm::model_type::{ModelInput, ModelType};
use dynamo_llm::worker_type::WorkerType;
use dynamo_runtime::engine_routes::EngineRouteCallback;
use dynamo_runtime::pipeline::network::Ingress;
use dynamo_runtime::traits::DistributedRuntimeProvider;
use dynamo_runtime::{DistributedRuntime, Runtime};
use tokio_util::sync::CancellationToken;
use crate::adapter::{EngineAdapter, RawEngineAdapter};
use crate::disagg::DisaggregationMode;
use crate::engine::{
EngineConfig, KvEventSource, LLMEngine, MetricsBindings, MetricsCtx, RawEngine,
};
use crate::error::{BackendError, DynamoError, ErrorType};
use crate::publisher::{PublisherHandles, setup_publishers};
/// Default grace-period in seconds between discovery unregister and engine drain.
/// Mirrors the Python `_DEFAULT_GRACE_PERIOD_SECS` constant.
const DEFAULT_GRACE_PERIOD_SECS: f64 = 5.0;
/// Environment variable name for overriding the grace-period.
/// Shared with the Python helper so a single env var controls both.
const GRACE_PERIOD_ENV: &str = "DYN_GRACEFUL_SHUTDOWN_GRACE_PERIOD_SECS";
/// Operator override for the health-check canary, mirrors the Python helper
/// in `lib/bindings/python/src/dynamo/health_check.py`.
const HEALTH_CHECK_PAYLOAD_ENV: &str = "DYN_HEALTH_CHECK_PAYLOAD";
/// Runtime / transport configuration applied to the process before the
/// distributed runtime is constructed.
///
/// `dynamo-runtime` reads these from environment variables in
/// [`DistributedConfig::from_settings`]. We mirror that by setting them
/// here before [`Runtime::from_settings`] runs, so a programmatic caller
/// can override per-process values without poking `std::env::set_var`
/// from user code.
#[derive(Clone, Debug, Default)]
pub struct RuntimeConfig {
/// Discovery backend selector — e.g. `"etcd"`, `"kubernetes"`, `"file"`,
/// `"mem"`. Maps to `DYN_DISCOVERY_BACKEND`.
pub discovery_backend: Option<String>,
/// Request-plane transport — e.g. `"tcp"`, `"nats"`. Maps to `DYN_REQUEST_PLANE`.
pub request_plane: Option<String>,
/// Event-plane transport — `"nats"` or `"zmq"`. When `None` the runtime
/// derives a default from the discovery backend. Maps to `DYN_EVENT_PLANE`.
pub event_plane: Option<String>,
}
impl RuntimeConfig {
/// `true` if any field is set. Used by the PyO3 binding to decide
/// whether to warn that overrides will be dropped when reusing a
/// runtime constructed by another caller.
pub fn has_overrides(&self) -> bool {
self.discovery_backend.is_some()
|| self.request_plane.is_some()
|| self.event_plane.is_some()
}
/// Apply each set field to the corresponding environment variable.
/// Unset fields leave the existing environment value untouched.
pub fn apply_to_env(&self) {
// SAFETY: set_var is unsafe in edition 2024 because it can race with
// other threads reading the environment. We call it before any
// runtime threads spawn, matching the convention used by
// `dynamo-runtime` itself in DistributedConfig::from_settings.
unsafe {
self.apply_with(|key, value| std::env::set_var(key, value));
}
}
fn apply_with(&self, mut set: impl FnMut(&str, &str)) {
if let Some(ref value) = self.discovery_backend {
set("DYN_DISCOVERY_BACKEND", value);
}
if let Some(ref value) = self.request_plane {
set("DYN_REQUEST_PLANE", value);
}
if let Some(ref value) = self.event_plane {
set("DYN_EVENT_PLANE", value);
}
}
}
/// Per-worker runtime configuration.
#[derive(Clone, Debug)]
pub struct WorkerConfig {
/// Dynamo namespace for discovery routing.
pub namespace: String,
/// Component name within the namespace.
pub component: String,
/// Endpoint name exposed by this worker (e.g. `"generate"`).
pub endpoint: String,
/// HF repo name or local model path. Empty means name-only registration
/// (no tokenizer / chat-template on the card).
pub model_name: String,
/// Public-facing model name (operator CLI override). When unset, the
/// served name falls back to `EngineConfig.served_model_name`, then to
/// `EngineConfig.model`.
pub served_model_name: Option<String>,
/// Whether the engine consumes tokens (`Tokens`) or raw text (`Text`).
pub model_input: ModelInput,
/// Comma-separated list, e.g. `"chat,completions"`.
/// Accepted values: `chat`, `completions`, `embedding`/`embeddings`,
/// `tensor`, `prefill` (see `parse_endpoint_types`).
pub endpoint_types: String,
/// Optional path to a custom Jinja chat template. When `None`, the
/// template shipped with `model_name` is used.
pub custom_jinja_template: Option<PathBuf>,
/// Optional tool-call parser name written to model runtime metadata.
pub tool_call_parser: Option<String>,
/// Optional reasoning parser name written to model runtime metadata.
pub reasoning_parser: Option<String>,
/// Whether templates should omit tools when `tool_choice` is `none`.
pub exclude_tools_when_tool_choice_none: bool,
/// Whether this worker should keep an in-process KV indexer.
pub enable_local_indexer: bool,
/// Kill switch for KV-aware-routing publishers. When `false`, skip
/// `engine.kv_event_sources()` / `metrics_sources()` entirely.
pub enable_kv_routing: bool,
/// Per-endpoint Prometheus metric labels appended to every metric.
/// Common labels: `("model", "<served-name>")`.
pub metrics_labels: Vec<(String, String)>,
/// Disaggregation role for this worker.
///
/// `Aggregated` (default) registers the model with the parsed
/// `endpoint_types`. `Prefill` registers with the legacy `ModelType::Prefill`
/// marker bit (no OpenAI surface — dual-emitted for cross-version compat)
/// and `WorkerType::Prefill`, so the frontend's prefill router targets it
/// via `worker_type`. `Decode` keeps `endpoint_types` but force-disables the
/// local KV indexer because decode workers do not host the indexer
/// endpoint.
pub disaggregation_mode: DisaggregationMode,
/// Operator override. `Worker` resolves precedence: this field >
/// `DYN_HEALTH_CHECK_PAYLOAD` env > `engine.health_check_payload()`.
/// Python sets this via `--health-check-payload` / env; Rust-only
/// engines leave it `None` and let `Worker` read the env directly.
pub health_check_payload: Option<serde_json::Value>,
/// Structural tag guided decoding mode.
pub structural_tag_mode: StructuralTagMode,
/// Structural tag activation scope.
pub structural_tag_scope: StructuralTagScope,
/// Structural tag schema strictness.
pub structural_tag_schema: StructuralTagSchemaMode,
/// Runtime / transport overrides applied via env vars before the
/// `DistributedRuntime` is constructed.
pub runtime: RuntimeConfig,
}
impl WorkerConfig {
/// Effective `enable_local_indexer`, accounting for disaggregation
/// mode. Decode workers force this off because they don't host the
/// in-process KV indexer endpoint and must not advertise it.
pub(crate) fn effective_enable_local_indexer(&self) -> bool {
self.enable_local_indexer && !self.disaggregation_mode.is_decode()
}
}
impl Default for WorkerConfig {
fn default() -> Self {
Self {
namespace: "dynamo".to_string(),
component: "backend".to_string(),
endpoint: "generate".to_string(),
model_name: String::new(),
served_model_name: None,
model_input: ModelInput::Tokens,
endpoint_types: "chat,completions".to_string(),
custom_jinja_template: None,
tool_call_parser: None,
reasoning_parser: None,
exclude_tools_when_tool_choice_none: true,
enable_local_indexer: true,
enable_kv_routing: true,
metrics_labels: Vec::new(),
disaggregation_mode: DisaggregationMode::Aggregated,
health_check_payload: None,
structural_tag_mode: StructuralTagMode::Off,
structural_tag_scope: StructuralTagScope::Auto,
structural_tag_schema: StructuralTagSchemaMode::Auto,
runtime: RuntimeConfig::default(),
}
}
}
/// Lifecycle state for [`Worker`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum LifecycleState {
/// `start_engine` has not been called (or shutdown arrived first and
/// flipped us straight to `Stopped`).
Init,
/// `engine.start()` returned successfully; `engine.cleanup()` is owed.
Running,
/// `engine.start()` raised. The engine may have allocated partial
/// state (inner LLM, sockets, background tasks) before failing, so
/// `engine.cleanup()` is still owed exactly once.
StartFailed,
/// Cleanup done. `engine.cleanup()` will not be called again.
Stopped,
}
/// The engine a [`Worker`] drives, tagged by request modality. Both variants
/// share the lifecycle (driven via the forwarders below); they differ only in
/// the serve-loop adapter: `Llm` → token pipeline ([`EngineAdapter`]), `Raw` →
/// JSON passthrough ([`RawEngineAdapter`]) for media. A new media modality is
/// a new `Raw` engine, not a new variant.
#[derive(Clone)]
pub(crate) enum EngineKind {
Llm(Arc<dyn LLMEngine>),
Raw(Arc<dyn RawEngine>),
}
impl EngineKind {
async fn start(&self, worker_id: u64) -> Result<EngineConfig, DynamoError> {
match self {
EngineKind::Llm(e) => e.start(worker_id).await,
EngineKind::Raw(e) => e.start(worker_id).await,
}
}
async fn cleanup(&self) -> Result<(), DynamoError> {
match self {
EngineKind::Llm(e) => e.cleanup().await,
EngineKind::Raw(e) => e.cleanup().await,
}
}
async fn drain(&self) -> Result<(), DynamoError> {
match self {
EngineKind::Llm(e) => e.drain().await,
EngineKind::Raw(e) => e.drain().await,
}
}
async fn setup_metrics(&self, ctx: MetricsCtx<'_>) -> Result<MetricsBindings, DynamoError> {
match self {
EngineKind::Llm(e) => e.setup_metrics(ctx).await,
EngineKind::Raw(e) => e.setup_metrics(ctx).await,
}
}
async fn kv_event_sources(&self) -> Result<Vec<KvEventSource>, DynamoError> {
match self {
EngineKind::Llm(e) => e.kv_event_sources().await,
// Raw media engines have no block-structured KV cache to route on.
EngineKind::Raw(_) => Ok(Vec::new()),
}
}
async fn health_check_payload(&self) -> Result<Option<serde_json::Value>, DynamoError> {
match self {
EngineKind::Llm(e) => e.health_check_payload().await,
EngineKind::Raw(e) => e.health_check_payload().await,
}
}
async fn supported_controls(&self) -> Result<Vec<String>, DynamoError> {
match self {
EngineKind::Llm(e) => e.supported_controls().await,
// Raw media engines advertise no semantic engine controls.
EngineKind::Raw(_) => Ok(Vec::new()),
}
}
async fn engine_control(
&self,
control: String,
body: serde_json::Value,
) -> Result<serde_json::Value, DynamoError> {
match self {
EngineKind::Llm(e) => e.engine_control(control, body).await,
EngineKind::Raw(_) => Ok(serde_json::json!({
"status": "error",
"message": format!("unsupported engine control: {control}"),
})),
}
}
/// Raw media engines (image/video/audio) register name-only — the engine
/// loads the model itself and the model has no LLM artifacts (tokenizer /
/// chat template / config.json) for Dynamo to fetch.
fn is_raw(&self) -> bool {
matches!(self, EngineKind::Raw(_))
}
}
/// Runtime host for an engine (an [`LLMEngine`] or a [`RawEngine`]).
///
/// `run()` creates the distributed runtime, calls `engine.start()`,
/// registers the model, serves the endpoint, and calls
/// `engine.cleanup()` on shutdown (guaranteed once `start()` succeeded).
pub struct Worker {
engine: EngineKind,
config: WorkerConfig,
state: LifecycleState,
/// KV-aware-routing publisher handles. Drained in `cleanup_once` while NATS is alive.
publishers: Option<PublisherHandles>,
/// Framework-owned lifecycle gauges. Set in `setup_publishing` after
/// `engine.start()` succeeds; observed in `cleanup_once` and the drain
/// step. Always present once `start()` returns Ok, independent of
/// whether the engine returned a component publisher.
lifecycle: Option<crate::metrics::LifecycleGauges>,
}
impl Worker {
/// Build a `Worker` for a token-pipeline [`LLMEngine`].
pub fn new(engine: Arc<dyn LLMEngine>, config: WorkerConfig) -> Self {
Self::with_engine(EngineKind::Llm(engine), config)
}
/// Build a `Worker` for a raw media-pipeline [`RawEngine`]
/// (image/video/audio generation).
pub fn new_raw(engine: Arc<dyn RawEngine>, config: WorkerConfig) -> Self {
Self::with_engine(EngineKind::Raw(engine), config)
}
fn with_engine(engine: EngineKind, config: WorkerConfig) -> Self {
Self {
engine,
config,
state: LifecycleState::Init,
publishers: None,
lifecycle: None,
}
}
/// Lifecycle driver. Takes owned `self` — `Worker` is single-shot and
/// cannot be reused after `run()` returns.
///
/// Shutdown sequence (mirrors `graceful_shutdown_with_discovery` in
/// `components/src/dynamo/common/utils/graceful_shutdown.py`):
/// 1. `endpoint.unregister_endpoint_instance()` — router stops routing.
/// 2. Sleep `DYN_GRACEFUL_SHUTDOWN_GRACE_PERIOD_SECS` (default 5s) to
/// let in-flight router decisions complete.
/// 3. `engine.drain()` — backend-side drain (e.g. NIXL prefill).
/// 4. `engine.cleanup()` — release engine resources while NATS / etcd
/// are still reachable.
/// 5. Return — caller (`run.rs`) drives `runtime.shutdown()` for
/// request-plane drain and transport teardown.
///
/// A SIGTERM/SIGINT listener is installed at the top of `run` and
/// shared via a [`CancellationToken`]:
/// * Pre-start signal (during `DistributedRuntime` construction):
/// the post-DRT cancellation check returns `Ok(())` cleanly and
/// `engine.start()` is never called.
/// * Mid-start signal: `engine.start()` is allowed to complete (we
/// never cancel a partially-initialized engine mid-flight); the
/// post-start cancellation check then runs the orchestrator
/// directly without entering the serve loop.
/// * Mid-serve signal: the serve loop's [`tokio::select`] picks up
/// the same token and runs the orchestrator.
///
/// `engine.cleanup()` is guaranteed to run exactly once if
/// `engine.start()` succeeded, regardless of which path led to shutdown.
pub async fn run(mut self, runtime: Runtime) -> Result<(), DynamoError> {
// Validate the worker config up front so misconfiguration surfaces
// before any signal handlers, tokio tasks, or runtime construction.
// The same validation is also reachable via `run_inner`, but doing
// it here means a user who passes an unsupported `model_input`
// doesn't pay the cost of installing signal handlers and spawning
// a listener task just to get an InvalidArgument error.
validate_model_input(self.config.model_input, &self.engine)?;
// Install the OS signal handlers synchronously, before spawning
// anything, so a SIGTERM delivered between this point and the
// task's first poll is captured by the kernel-side handler rather
// than the OS default (which would terminate the process abruptly).
// `Signal::recv` then drives the shared cancellation token.
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.map_err(|e| {
err(
ErrorType::Backend(BackendError::Unknown),
format!("install SIGTERM handler: {e}"),
)
})?;
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
.map_err(|e| {
err(
ErrorType::Backend(BackendError::Unknown),
format!("install SIGINT handler: {e}"),
)
})?;
// Single shared shutdown signal observed across all phases. The
// background task only flips the token; lifecycle transitions stay
// on this owned Worker instance.
let shutdown_token = CancellationToken::new();
let signal_token = shutdown_token.clone();
let signal_handle = tokio::spawn(async move {
tokio::select! {
_ = sigterm.recv() => tracing::info!("SIGTERM received"),
_ = sigint.recv() => tracing::info!("SIGINT received"),
}
signal_token.cancel();
});
// Mirror `dynamo_runtime::Worker::execute`'s shutdown deadline:
// once a signal arrives, the orchestrator + cleanup must finish
// within `DYN_WORKER_GRACEFUL_SHUTDOWN_TIMEOUT` seconds (plus the
// grace-period sleep, which is a fixed wait rather than a hang
// risk), otherwise we exit(911). Healthy long-running workers
// never hit this — the timer only starts after `shutdown_token`
// is cancelled.
let outcome = {
let inner_fut = self.run_inner(runtime, &shutdown_token);
tokio::pin!(inner_fut);
tokio::select! {
result = &mut inner_fut => result,
_ = shutdown_token.cancelled() => {
let timeout = graceful_shutdown_timeout();
let grace = grace_period_secs();
let deadline = shutdown_deadline(timeout, grace);
tracing::debug!(
"graceful shutdown started; deadline {}s ({}s timeout + {:.2}s grace)",
deadline.as_secs(),
timeout.as_secs(),
grace,
);
match tokio::time::timeout(deadline, &mut inner_fut).await {
Ok(result) => result,
Err(_) => {
tracing::error!(
"Graceful shutdown exceeded {}s; force-exiting with code 911. \
Set DYN_WORKER_GRACEFUL_SHUTDOWN_TIMEOUT to override.",
deadline.as_secs()
);
std::process::exit(911);
}
}
}
}
};
signal_handle.abort();
let _ = signal_handle.await;
// Final safety net: guarantee engine.cleanup() runs if start()
// succeeded. No-op if cleanup already ran via the orchestrator.
self.cleanup_once().await;
outcome
}
async fn run_inner(
&mut self,
runtime: Runtime,
shutdown: &CancellationToken,
) -> Result<(), DynamoError> {
// model_input was already validated at the top of `run`; re-checking
// here would double-error on misconfig.
let drt = DistributedRuntime::from_settings(runtime)
.await
.map_err(|e| {
err(
ErrorType::Backend(BackendError::CannotConnect),
format!("distributed runtime: {e}"),
)
})?;
tracing::debug!("distributed runtime connected");
let component = drt
.namespace(&self.config.namespace)
.and_then(|ns| ns.component(&self.config.component))
.map_err(|e| {
err(
ErrorType::Backend(BackendError::CannotConnect),
format!("component: {e}"),
)
})?;
let endpoint = component.endpoint(&self.config.endpoint);
tracing::debug!(
namespace = %self.config.namespace,
component = %self.config.component,
endpoint = %self.config.endpoint,
"component and endpoint resolved"
);
// Shutdown arrived during DRT construction; engine never started,
// nothing to clean up.
if shutdown.is_cancelled() {
tracing::info!("Shutdown signal observed before engine.start(); exiting cleanly");
return Ok(());
}
// Pull the worker's unique runtime ID from the DRT before handing it
// to the engine. Backed by `discovery_client.instance_id()` so it is
// unique-per-replica by construction; engines see only an opaque
// `worker_id`.
let worker_id = drt.connection_id();
let engine_start = std::time::Instant::now();
let engine_config = self.start_engine(worker_id).await?;
let model_load_time_seconds = engine_start.elapsed().as_secs_f64();
tracing::debug!(
model = %engine_config.model,
worker_id,
model_load_time_seconds,
"engine.start() complete"
);
// Engine builds its EngineMetrics once. `setup_metrics` is the
// single hook for both foreign-registry expfmt callbacks (side-
// effect on engine_metrics) and the structured component publisher
// (returned in MetricsBindings).
let engine_metrics =
crate::metrics::EngineMetrics::with_engine_config(endpoint.clone(), &engine_config);
// Framework-owned lifecycle gauges (cleanup_time, drain_time,
// model_load_time) — always emitted, regardless of engine opt-in.
let lifecycle =
crate::metrics::LifecycleGauges::new(&engine_metrics, model_load_time_seconds)?;
self.setup_publishing(
&component,
&engine_config,
&engine_metrics,
model_load_time_seconds,
lifecycle,
)
.await?;
// Mid-start signal: engine.start() ran to completion but a signal
// arrived during it. Skip the serve loop and run the orchestrator
// directly so `engine.cleanup()` still runs while the runtime is
// alive.
if shutdown.is_cancelled() {
tracing::info!("Shutdown signal observed during engine.start(); running orchestrator");
self.orchestrator_steps(&endpoint).await;
return Ok(());
}
self.serve_with_orchestrator(&engine_config, endpoint, shutdown.clone())
.await
}
/// Build KV-event publishers and the `SnapshotPublisher` from the
/// engine's declarations. KV events flow on the engine's own threads
/// (via Push or ZMQ); snapshot writes flow through the publisher
/// inline (no polling, no GIL on the framework side). No-op if
/// `enable_kv_routing` is off, the engine returned no sources +
/// no dp_ranks, or `engine_config.kv_cache_block_size` is unset for
/// KV events.
async fn setup_publishing(
&mut self,
component: &dynamo_runtime::component::Component,
engine_config: &EngineConfig,
engine_metrics: &crate::metrics::EngineMetrics,
model_load_time_seconds: f64,
lifecycle: crate::metrics::LifecycleGauges,
) -> Result<(), DynamoError> {
let ctx = crate::engine::MetricsCtx {
model: &engine_config.model,
component: &self.config.component,
model_load_time_seconds,
metrics: engine_metrics,
};
let bindings = self.engine.setup_metrics(ctx).await?;
if !self.config.enable_kv_routing {
tracing::debug!("enable_kv_routing=false; skipping kv/snapshot publishers");
self.lifecycle = Some(lifecycle);
return Ok(());
}
let kv_sources = self.engine.kv_event_sources().await?;
if kv_sources.is_empty() && bindings.dp_ranks.is_empty() {
tracing::debug!("engine returned no KV sources / dp_ranks; KV-aware routing disabled");
self.lifecycle = Some(lifecycle);
return Ok(());
}
let enable_local_indexer = self.config.effective_enable_local_indexer();
// None for raw engines (no block-structured KV cache).
let kv_cache_block_size = engine_config
.llm
.as_ref()
.and_then(|l| l.kv_cache_block_size);
tracing::debug!(
kv_sources = kv_sources.len(),
snapshot_dp_ranks = bindings.dp_ranks.len(),
enable_local_indexer,
kv_cache_block_size = ?kv_cache_block_size,
"Starting KV-aware-routing publishers"
);
let handles = setup_publishers(
component,
engine_metrics,
kv_sources,
bindings.dp_ranks,
bindings.on_publisher_ready,
kv_cache_block_size,
enable_local_indexer,
)
.await?;
self.publishers = Some(handles);
self.lifecycle = Some(lifecycle);
Ok(())
}
/// Register advertised engine controls on the runtime system server.
async fn register_engine_controls(
&self,
endpoint: &dynamo_runtime::component::Endpoint,
) -> Result<(), DynamoError> {
let controls = self.engine.supported_controls().await?;
if controls.is_empty() {
tracing::debug!("engine returned no management controls");
return Ok(());
}
let registry = endpoint.drt().engine_routes();
let control_count = controls.len();
// Serialize discovery-mutating controls so a concurrent resume cannot
// re-register the endpoint between a pause control's unregister and
// its engine-state mutation (and vice versa).
let control_lock = Arc::new(tokio::sync::Mutex::new(()));
for control_name in controls {
let callback = engine_control_callback(control_name.clone(), self.engine.clone());
let callback = wrap_engine_control_callback(
control_name.clone(),
callback,
endpoint.clone(),
control_lock.clone(),
);
registry.register(&control_name, callback);
}
tracing::info!(control_count, "registered engine management controls");
Ok(())
}
/// Full graceful-shutdown orchestrator: discovery unregister →
/// grace period → engine drain → cleanup. Shared by every shutdown path —
/// pre-serve (mid-start signal) and the serve loop's signal arm.
async fn orchestrator_steps(&mut self, endpoint: &dynamo_runtime::component::Endpoint) {
if let Err(e) = endpoint.unregister_endpoint_instance().await {
tracing::warn!(error = %e, "discovery unregister failed");
} else {
tracing::info!("Endpoint unregistered from discovery");
}
self.run_engine_shutdown_steps().await;
}
/// Start the engine exactly once. `Worker::run` consumes `self`, so all
/// lifecycle transitions are single-threaded and do not need a mutex.
async fn start_engine(&mut self, worker_id: u64) -> Result<EngineConfig, DynamoError> {
// `start_engine` is called once from `run_inner`, which consumes
// `self`. Hitting any other state is a programmer error worth
// panicking over in release as well as debug builds.
assert_eq!(
self.state,
LifecycleState::Init,
"start_engine called in unexpected state {:?}",
self.state
);
match self.engine.start(worker_id).await {
Ok(cfg) => {
self.state = LifecycleState::Running;
Ok(cfg)
}
Err(e) => {
// Engine.cleanup() still owed: start() may have built up
// partial state (inner LLM, sockets, background tasks)
// before raising, and the contract requires cleanup to be
// safe against that. cleanup_once() picks up StartFailed.
self.state = LifecycleState::StartFailed;
Err(e)
}
}
}
/// Idempotent cleanup.
async fn cleanup_once(&mut self) {
match self.state {
LifecycleState::Init | LifecycleState::Stopped => {
// Pre-start shutdown, or cleanup already ran. Nothing
// engine-side to do — `engine.start()` either never ran
// or its allocations have already been released.
self.state = LifecycleState::Stopped;
return;
}
LifecycleState::Running | LifecycleState::StartFailed => {}
}
let cleanup_start = std::time::Instant::now();
match self.engine.cleanup().await {
Ok(()) => tracing::info!("Engine cleanup complete"),
Err(e) => tracing::error!(error = %e, "engine cleanup failed"),
}
let cleanup_elapsed = cleanup_start.elapsed().as_secs_f64();
// Record cleanup latency on dynamo_component_cleanup_time_seconds.
// The gauge is operator-useful when scraped in the brief window
// between cleanup-complete and pod-terminate.
if let Some(lifecycle) = self.lifecycle.as_ref() {
lifecycle.observe_cleanup_time(cleanup_elapsed);
}
// Drop publisher handles AFTER engine.cleanup so the engine's
// last snapshot writes complete. There is no background task to
// join — snapshot writes are event-driven (engine pushes
// synchronously); KV-event publishers own their own threads.
self.publishers = None;
// Mark stopped even on failure so a follow-up call no-ops; engines
// like vLLM/TRT-LLM tear down NCCL groups in cleanup() and a second
// attempt can hang or raise.
self.state = LifecycleState::Stopped;
}
/// Drive the serve loop and the shutdown orchestrator. Returns when
/// either the serve loop exits or `shutdown` is cancelled.
async fn serve_with_orchestrator(
&mut self,
engine_config: &EngineConfig,
endpoint: dynamo_runtime::component::Endpoint,
shutdown: CancellationToken,
) -> Result<(), DynamoError> {
let model_type = resolve_model_type(&self.config)?;
let (worker_type, needs) = resolve_worker_type_and_needs(&self.config);
let mut local_model =
build_local_model(&self.config, engine_config, self.engine.is_raw()).await?;
tracing::debug!("local model built");
local_model
.attach(
&endpoint,
model_type,
self.config.model_input,
None,
Some(worker_type),
needs,
)
.await
.map_err(|e| {
err(
ErrorType::Backend(BackendError::Unknown),
format!("model attach: {e}"),
)
})?;
tracing::debug!("model registered with discovery");
self.register_engine_controls(&endpoint).await?;
let served = resolve_served_name(&self.config, engine_config)
.unwrap_or_else(|| engine_config.model.clone());
tracing::info!(
"Serving {} on {}.{}.{}",
served,
self.config.namespace,
self.config.component,
self.config.endpoint
);
// Build the request adapter and a JSON-shaped health-check probe
// engine for the worker's modality. The token pipeline
// (`EngineAdapter`) needs a `JsonProbeAdapter` wrapper to expose a
// `serde_json::Value` probe surface; the raw pipeline
// (`RawEngineAdapter`) is already JSON-shaped, so it serves as its
// own probe. The tuple annotation drives the trait-object coercions.
let (ingress, probe_engine): (
Arc<dyn dynamo_runtime::pipeline::network::PushWorkHandler>,
dynamo_runtime::local_endpoint_registry::LocalAsyncEngine,
) = match &self.engine {
EngineKind::Llm(engine) => {
let engine_adapter = Arc::new(EngineAdapter::new(
engine.clone(),
self.config.disaggregation_mode,
));
let ingress = Ingress::for_engine(engine_adapter.clone()).map_err(|e| {
err(
ErrorType::Backend(BackendError::Unknown),
format!("ingress: {e}"),
)
})?;
let probe = Arc::new(crate::adapter::JsonProbeAdapter::new(engine_adapter));
(ingress, probe)
}
EngineKind::Raw(engine) => {
let raw_adapter = Arc::new(RawEngineAdapter::new(engine.clone()));
let ingress = Ingress::for_engine(raw_adapter.clone()).map_err(|e| {
err(
ErrorType::Backend(BackendError::Unknown),
format!("ingress: {e}"),
)
})?;
(ingress, raw_adapter)
}
};
let metrics_labels = if self.config.metrics_labels.is_empty() {
None
} else {
Some(self.config.metrics_labels.clone())
};
// Hold a registration with the DRT's graceful-shutdown tracker for
// the entire serve + orchestrate window. If `Runtime::shutdown` is
// initiated externally, its Phase 2 wait will block on this guard
// (in addition to the endpoint's own registration), so Phase 3
// (NATS/etcd teardown) doesn't fire until our `orchestrator_steps`
// — discovery unregister, grace period, drain, cleanup — finishes.
let _orchestrator_registration = endpoint.drt().register_graceful_task();
// Precedence: WorkerConfig (Python argparse plumbs CLI/env here) >
// DYN_HEALTH_CHECK_PAYLOAD env (backstop for Rust-only engines) >
// engine default. Every override path stamps the `_HEALTH_CHECK`
// marker so engines can branch on `is_probe(request)` regardless of
// where the payload came from.
let probe = match std::mem::take(&mut self.config.health_check_payload)
.or_else(load_health_check_payload_from_env)
{
Some(p) => stamp_canary_marker(p),
None => self
.engine
.health_check_payload()
.await
.unwrap_or_else(|e| {
tracing::warn!(
error = %e,
"engine.health_check_payload() failed; canary disabled for this endpoint",
);
None
})
.and_then(stamp_canary_marker),
};
let mut builder = endpoint
.endpoint_builder()
.handler(ingress)
.metrics_labels(metrics_labels)
.graceful_shutdown(true);
if let Some(payload) = probe {
builder = builder.health_check_payload(payload);
// The runtime's `HealthCheckManager` fires the canary by looking
// up a `LocalAsyncEngine` for this endpoint name. Register the
// modality's JSON-shaped probe engine so the probe exercises the
// same `generate()` path as real traffic.
builder = builder.register_local_engine(probe_engine).map_err(|e| {
err(
ErrorType::Backend(BackendError::Unknown),
format!("register_local_engine: {e}"),
)
})?;
}
let serve_fut = builder.start();
tokio::pin!(serve_fut);
tokio::select! {
biased;
result = &mut serve_fut => {
match result {
// Endpoint exited cleanly (e.g. DRT primary token
// cancelled it) — run the orchestrator so drain/cleanup
// don't race transport teardown.
Ok(()) => {
tracing::info!(
"Endpoint completed gracefully; running shutdown orchestration"
);
}
// Serve errored; cleanup_once in run() is the safety net.
Err(e) => {
return Err(err(
ErrorType::Backend(BackendError::Unknown),
format!("serve: {e}"),
));
}
}
}
_ = shutdown.cancelled() => {
tracing::info!("Received shutdown signal; running graceful orchestration");
}
}
self.orchestrator_steps(&endpoint).await;
Ok(())
}
/// Engine-facing shutdown sequence: grace period sleep → `engine.drain()`
/// → `cleanup_once()`. Each engine step swallows non-fatal failures so a
/// misbehaving engine can't block the worker from exiting.
async fn run_engine_shutdown_steps(&mut self) {
self.run_engine_shutdown_steps_with_grace(grace_period_secs())
.await
}
/// Same as [`run_engine_shutdown_steps`] but with an explicit grace
/// period. Lets unit tests assert on call ordering without setting
/// `DYN_GRACEFUL_SHUTDOWN_GRACE_PERIOD_SECS` (which is process-global
/// and would race other parallel tests).
async fn run_engine_shutdown_steps_with_grace(&mut self, grace: f64) {
if grace > 0.0 {
tracing::info!("Grace period {:.2}s before drain", grace);
tokio::time::sleep(Duration::from_secs_f64(grace)).await;
}
let drain_start = std::time::Instant::now();
if let Err(e) = self.engine.drain().await {
tracing::warn!(error = %e, "engine drain failed");
}
let drain_elapsed = drain_start.elapsed().as_secs_f64();
if let Some(lifecycle) = self.lifecycle.as_ref() {
lifecycle.observe_drain_time(drain_elapsed);
}
self.cleanup_once().await;
}
}
/// Read the post-signal shutdown deadline from
/// `DYN_WORKER_GRACEFUL_SHUTDOWN_TIMEOUT` (matching `dynamo_runtime::Worker`).
/// On expiry the worker hard-exits with code 911 — same contract as the
/// upstream `worker.execute` flow we bypass. Defaults are imported from
/// `dynamo_runtime::worker` so a default change there propagates here
/// without manual sync.
fn graceful_shutdown_timeout() -> Duration {
use dynamo_runtime::config::environment_names::worker as env_worker;
use dynamo_runtime::worker::{
DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_DEBUG, DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_RELEASE,
};
let default = if cfg!(debug_assertions) {
DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_DEBUG
} else {
DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_RELEASE
};
let value = std::env::var(env_worker::DYN_WORKER_GRACEFUL_SHUTDOWN_TIMEOUT).ok();
let secs = graceful_shutdown_timeout_secs(value.as_deref(), default);
Duration::from_secs(secs)
}
fn graceful_shutdown_timeout_secs(value: Option<&str>, default: u64) -> u64 {
value
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(default)
}
/// Compose the post-signal shutdown deadline from the drain+cleanup
/// timeout and the grace-period sleep that precedes them.
///
/// The grace sleep is a fixed wait (not a hang risk), so reserving its
/// duration on top of `timeout` ensures `engine.drain()` and
/// `engine.cleanup()` always get the full timeout budget regardless of
/// how the operator configures the grace period. Without this reserve,
/// a grace period equal to the timeout (the debug default — both 5s)
/// consumes the whole budget and the deadline expires before drain or
/// cleanup get scheduled.
fn shutdown_deadline(timeout: Duration, grace_secs: f64) -> Duration {
let grace = if grace_secs > 0.0 {
Duration::from_secs_f64(grace_secs)
} else {
Duration::ZERO
};
timeout.saturating_add(grace)
}
/// Validate that `value` is a JSON object and stamp the canary marker on
/// it. Returns `None` for non-object payloads (logs a warning) so the
/// canary stays disabled rather than being registered with an invalid
/// shape. Operator overrides reach the engine's `generate()` with the
/// marker set so `is_probe(request)` detects them.
fn stamp_canary_marker(mut value: serde_json::Value) -> Option<serde_json::Value> {
let Some(obj) = value.as_object_mut() else {
tracing::warn!(
?value,
"health_check_payload override is not a JSON object; canary disabled"
);
return None;
};
obj.insert(
crate::engine::HEALTH_CHECK_KEY.to_string(),
serde_json::Value::Bool(true),
);
Some(value)
}
/// Read `DYN_HEALTH_CHECK_PAYLOAD` (JSON object or `@/path/to/file.json`).
/// Returns `None` when the env is unset or the value is invalid; an invalid
/// value logs a warning so it can't silently disable the engine default.
fn load_health_check_payload_from_env() -> Option<serde_json::Value> {
let raw = std::env::var(HEALTH_CHECK_PAYLOAD_ENV).ok();
load_health_check_payload(raw.as_deref())
}
fn load_health_check_payload(raw: Option<&str>) -> Option<serde_json::Value> {
let raw = raw.filter(|s| !s.is_empty())?;
let parsed: Result<serde_json::Value, _> = if let Some(path) = raw.strip_prefix('@') {
std::fs::read_to_string(path).map_or_else(