Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion model_gateway/src/routers/common/kv_transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ pub(crate) fn connector_mode_for_worker(worker: &dyn Worker) -> KvConnectorMode
None
} else {
let dp_size = worker.dp_size().or(label_dp);
effective_kv_engine_id(meta.spec.kv_engine_id.as_deref(), dp_size, worker.dp_rank())
effective_kv_engine_id(worker.kv_engine_id().as_deref(), dp_size, worker.dp_rank())
};
kv_connector_mode(
meta.spec.kv_connector.as_deref(),
Expand Down Expand Up @@ -256,4 +256,28 @@ mod tests {
assert_eq!(effective_kv_engine_id(Some(""), None, None), None);
assert_eq!(effective_kv_engine_id(None, Some(2), Some(0)), None);
}

#[test]
fn a_refreshed_engine_id_is_what_the_handoff_is_minted_for() {
use crate::worker::{BasicWorkerBuilder, ConnectionMode, RuntimeType, WorkerType};

let worker = BasicWorkerBuilder::new("grpc://p:1")
.worker_type(WorkerType::Prefill)
.connection_mode(ConnectionMode::Grpc)
.runtime_type(RuntimeType::Vllm)
.kv_connector("MooncakeConnector")
.kv_engine_id("eng-old")
.bootstrap_port(Some(8998))
.build();
assert!(matches!(
connector_mode_for_worker(&worker),
KvConnectorMode::Mooncake { engine_id: Some(ref id), .. } if id == "eng-old"
));
assert!(worker.refresh_kv_engine_id(Some("eng-new".to_string())));
assert!(!worker.refresh_kv_engine_id(Some("eng-new".to_string())));
assert!(matches!(
connector_mode_for_worker(&worker),
KvConnectorMode::Mooncake { engine_id: Some(ref id), .. } if id == "eng-new"
));
}
}
1 change: 1 addition & 0 deletions model_gateway/src/worker/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ impl BasicWorkerBuilder {
let resilience = self.resilience.unwrap_or_default();

BasicWorker {
kv_engine_id: ArcSwapOption::new(metadata.spec.kv_engine_id.clone().map(Arc::new)),
runtime: ArcSwap::from_pointee(WorkerRuntime::new(&metadata.spec.url, initial_status)),
circuit_breaker: ArcSwap::from_pointee(CircuitBreaker::with_config_and_label(
self.circuit_breaker_config,
Expand Down
45 changes: 43 additions & 2 deletions model_gateway/src/worker/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ use crate::{
metrics_aggregator::{self, MetricPack},
registry::{WorkerDescriptor, WorkerId},
worker::WorkerTypeExt,
ConnectionMode, Worker, WorkerOrigin, WorkerRegistry, WorkerResult,
ConnectionMode, Worker, WorkerOrigin, WorkerRegistry, WorkerResult, WorkerType,
},
workflow::{Job, JobQueue},
workflow::{steps::local::discover_grpc_kv_engine_id, Job, JobQueue},
};

const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
Expand Down Expand Up @@ -492,6 +492,14 @@ fn queue_due_probes(
in_flight.insert(worker_id.clone());
probes.push(Box::pin(async move {
let probe_result = worker.check_health_async().await;
if probe_result.is_ok()
&& matches!(
launched_status,
WorkerStatus::Failed | WorkerStatus::NotReady
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: Pending is excluded, which leaves one variant of the same bug uncovered. Registration tolerates a failed metadata read — discover_metadata.rs:412 does unwrap_or_else(|e| { warn!(...); (HashMap::new(), None) }), and take_kv_transfer_metadata has no requirement that a PD worker carry an id — so a PD worker registered while its engine was still coming up lands in Pending with kv_engine_id: None. Its promotion is Pending → Ready, never Failed/NotReady, so this refresh never runs and the worker mints handoffs with no engine id for the rest of its life. Adding WorkerStatus::Pending to the matches! would close it (at the cost of one metadata read per pending probe on PD workers).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken: a Pending PD worker whose engine id is still None also re-reads on a successful probe (a Pending worker that already has an id does not, since it is the same process registration read). Note this still needs kv_connector to be known; a registration whose metadata read failed entirely has no connector either and stays Passthrough, which is the pre-existing behaviour.

{
refresh_kv_engine_id_after_recovery(&worker).await;
}
ProbeCompletion {
worker_id,
worker,
Expand Down Expand Up @@ -713,6 +721,39 @@ fn schedule_worker_at(
/// came back on the same address (a restart), and without
/// `--remove-unhealthy-workers` this is the only way it rejoins
/// - Failed stays Failed on failure; Draining never transitions here
/// A PD worker that answers its probe again after failing may be a new engine

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: the new function was inserted between compute_next_status's doc comment and its signature, so that doc block now documents refresh_kv_engine_id_after_recovery instead. Rustdoc for the state machine ("Apply the state machine to a probe outcome… Pending → Ready on success_threshold…", lines 711-723) is now attached to the engine-id refresh, and compute_next_status at line 754 is left undocumented. Moving the new function (and its own doc comment) above line 711 or below compute_next_status restores both.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved above compute_next_status's doc block; both functions carry their own docs again.

/// process on the same address, with a new KV transfer engine id (#2491).
/// Re-read the id before the worker is promoted, so the next handoff is
/// minted for the engine that is actually there. Only gRPC engines report
/// the id; a read that fails keeps the previous one and says so.
async fn refresh_kv_engine_id_after_recovery(worker: &Arc<dyn Worker>) {
let spec = &worker.metadata().spec;
if !matches!(spec.worker_type, WorkerType::Prefill | WorkerType::Decode)
|| *worker.connection_mode() != ConnectionMode::Grpc
|| spec.kv_connector.is_none()
{
return;
}
match discover_grpc_kv_engine_id(worker.url(), spec.runtime_type.as_str()).await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Important: this gRPC round-trip has no timeout, and it runs inside the probe future — a hang here permanently strands the worker it is trying to recover.

discover_grpc_kv_engine_id → fetch_grpc_metadata does GrpcClient::connect + get_model_info() + get_server_info() with no deadline on any of them (METADATA_TIMEOUT in discover_metadata.rs is only applied to the HTTP paths). Contrast the probe it now piggybacks on: BasicWorker::grpc_health_check wraps its RPC in time::timeout(health_config.timeout_secs, ...) precisely so a wedged engine cannot stall the loop.

Failure scenario: a restarted vLLM engine accepts the TCP connection and answers health_check, but GetServerInfo/GetModelInfo never returns (engine mid-init, wedged scheduler thread). The probe future never resolves, so apply_probe_completion never runs; worker_id stays in in_flight forever, which (a) excludes it from re-queue at manager.rs:445 so it is never probed again and never promoted out of Failed, and (b) permanently burns one of MAX_CONCURRENT_HEALTH_PROBES slots (manager.rs:438) — enough wedged PD workers and the whole health-check loop starves. That is a strictly worse outcome than the stale-engine-id bug this PR fixes.

Wrapping the call in tokio::time::timeout (the health config's timeout_secs, or REQUEST_TIMEOUT) and treating expiry as the existing Err branch — keep the previous id and warn — makes the worst case "stale id, as before" instead of "worker never recovers".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and fixed: the re-read is wrapped in tokio::time::timeout with the probe's timeout_secs, and an expiry takes the same path as a failed read (previous id kept, warning logged). Regression test added: a loopback server that accepts and never answers; the helper returns within the timeout with the old id intact.

Ok(discovered) => {
let previous = worker.kv_engine_id();
if worker.refresh_kv_engine_id(discovered.clone()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Important: a partial metadata read is Ok(None) here, and refresh_kv_engine_id(None) clears a known-good id — the opposite of the "a read that fails keeps the previous one" contract the doc comment above states.

discover_grpc_kv_engine_id (discover_metadata.rs:262-268) only returns Err when connect or get_model_info fails. get_server_info — the RPC that actually carries kv_engine_id for vLLM (ServerInfo::Vllm(info) => flat_labels(info)) — is tolerated inside fetch_grpc_metadata: Err(e) => warn!("Failed to fetch gRPC server info: {}", e). The function then succeeds with a label map that has no kv_engine_id, so discovered is None and the ArcSwapOption is stored as None.

Failure scenario: a restarted vLLM prefill answers health_check and GetModelInfo, but GetServerInfo transiently fails (engine still assembling its server-args snapshot — exactly the window a recovery probe lands in). The worker is promoted with kv_engine_id: None. connector_mode_for_worker then yields Mooncake { engine_id: None }, and pd_router.rs:1109-1117 takes the legacy branch: "Mooncake without a discovered kv_engine_id cannot be minted for; decode recomputes the prompt locally" — silently, for every request, until the worker next cycles through Failed. So a flaky side-RPC converts the bug this PR fixes (stale id → hang) into a permanent silent loss of PD.

Two smaller consequences of the same None write: it also discards an operator-supplied spec.kv_engine_id (the smg.ai/kv-engine-id annotation), which take_kv_transfer_metadata deliberately prefers over the discovered label at registration (create_worker.rs:307-319) — recovery silently inverts that precedence; and the Pending arm added in this push re-reads on every probe while the id is None, so a worker cleared this way keeps paying a full gRPC metadata round-trip per probe.

Only writing a discovered id when there is one keeps the worst case "stale id, as before":

Suggested change
if worker.refresh_kv_engine_id(discovered.clone()) {
Ok(Ok(Some(discovered))) => {
let previous = worker.kv_engine_id();
if worker.refresh_kv_engine_id(Some(discovered.clone())) {

(with a matching Ok(Ok(None)) => arm warning that the engine reported no id and the previous one is kept).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed as suggested: only Ok(Some(id)) is written; Ok(None) warns and keeps the previous id, so a partial read can neither clear a known id nor put the worker on the legacy path. On the precedence point: an id the engine actually reports wins over the spec's on recovery, since it is the one the handoff must target; an operator-pinned id that matches the engine's is unchanged, and one that does not would have hung either way.

info!(
worker_url = %worker.url(),
?previous,
?discovered,
"Recovered PD worker reports a new KV engine id"
);
}
}
Err(error) => warn!(
worker_url = %worker.url(),
%error,
"Could not re-read the KV engine id of a recovered PD worker; keeping the previous one"
),
}
}

fn compute_next_status(
worker: &Arc<dyn Worker>,
probe_ok: bool,
Expand Down
33 changes: 33 additions & 0 deletions model_gateway/src/worker/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,20 @@ pub trait Worker: Send + Sync + fmt::Debug + 'static {
self.metadata().dp_size()
}

/// The KV transfer engine id the worker's engine currently reports: the
/// spec's, unless a recovery re-read it (see
/// [`Self::refresh_kv_engine_id`]). A PD handoff must be minted for the
/// engine process that is there now, not the one discovered at
/// registration (#2491).
fn kv_engine_id(&self) -> Option<String> {
self.metadata().spec.kv_engine_id.clone()
}

/// Replace the engine id after a re-read. Returns `true` when it changed.
fn refresh_kv_engine_id(&self, _kv_engine_id: Option<String>) -> bool {
false
}

/// Transform a request for DP-aware routing.
///
/// When the worker has a `dp_rank`, injects `data_parallel_rank`
Expand Down Expand Up @@ -1282,6 +1296,11 @@ pub struct BasicWorker {
/// When not `Wildcard`, overrides metadata.models for routing decisions.
/// Uses `ArcSwap` for lock-free reads on the hot path (`supports_model`).
pub models_override: Arc<ArcSwap<WorkerModels>>,
/// The KV transfer engine id in force, seeded from the spec and replaced
/// when a recovered engine reports a new one (see
/// [`Worker::refresh_kv_engine_id`]). Not shared across same-URL
/// replacements: a replacement is built from a fresh discovery.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing: "a replacement is built from a fresh discovery" holds for the K8s remove+add path and for re-registration, but not for UpdateWorkerPropertiesStep. That step rebuilds the worker from the old spec's fields (update_worker_properties.rs:88-150) and the update workflow runs find_worker_to_update → update_worker_properties with no discovery step in between (steps/local/mod.rs:158-181), so nothing re-reads the engine.

The pre-existing part: that builder never sets kv_connector, kv_role, or kv_engine_id, and take_kv_transfer_metadata strips those keys out of spec.labels at creation — so a PUT that only touches labels/priority on a PD worker silently drops its whole KV transfer config today, not just the id. Not introduced here and not this PR's job to fix, but it does mean a refreshed id is thrown away by any worker-properties update, so the field doc's claim reads stronger than the code supports. Worth softening the wording, and worth a follow-up issue for the connector loss.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified and agreed: the properties rebuild carries none of kv_connector, kv_role, kv_engine_id, bootstrap host/port or pairing_protocol, so a label or priority PUT on a PD worker drops its whole transfer config today. Softened the field doc to say a rebuilt worker starts from its spec's unrefreshed id, and filed the connector loss as a follow-up issue.

pub kv_engine_id: ArcSwapOption<String>,
/// Worker-directed HTTP client, shared across same-config workers, built
/// on first use (see [`LazyHttpClient`]).
pub http_client: Arc<LazyHttpClient>,
Expand All @@ -1300,6 +1319,7 @@ impl Clone for BasicWorker {
zmq_connect_abort: Arc::clone(&self.zmq_connect_abort),
connect_signal_tx: self.connect_signal_tx.clone(),
models_override: Arc::clone(&self.models_override),
kv_engine_id: ArcSwapOption::new(self.kv_engine_id.load_full()),
http_client: Arc::clone(&self.http_client),
resilience: self.resilience.clone(),
}
Expand Down Expand Up @@ -1435,6 +1455,19 @@ impl BasicWorker {

#[async_trait]
impl Worker for BasicWorker {
fn kv_engine_id(&self) -> Option<String> {
self.kv_engine_id.load_full().map(|id| (*id).clone())
}

fn refresh_kv_engine_id(&self, kv_engine_id: Option<String>) -> bool {
let previous = self.kv_engine_id.load_full();
if previous.as_deref() == kv_engine_id.as_ref() {
return false;
}
self.kv_engine_id.store(kv_engine_id.map(Arc::new));
true
}

fn as_any(&self) -> &dyn Any {
self
}
Expand Down
11 changes: 11 additions & 0 deletions model_gateway/src/workflow/steps/local/discover_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,17 @@ async fn fetch_vllm_http_metadata(
labels
}

/// Re-read the KV transfer engine id a gRPC engine reports, for a PD worker
/// that came back on the same address (#2491): a restarted engine process
/// carries a new id, and a handoff minted for the old one strands the decode.
pub(crate) async fn discover_grpc_kv_engine_id(
url: &str,
runtime_type: &str,
) -> Result<Option<String>, String> {
let (mut labels, _) = fetch_grpc_metadata(url, runtime_type).await?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Ok(labels.remove("kv_engine_id").filter(|id| !id.is_empty()))
}

async fn fetch_grpc_metadata(
url: &str,
runtime_type: &str,
Expand Down
1 change: 1 addition & 0 deletions model_gateway/src/workflow/steps/local/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod detect_backend;
mod detect_connection;
mod discover_dp;
mod discover_metadata;
pub(crate) use discover_metadata::discover_grpc_kv_engine_id;
mod drain_workers;
mod ensure_harmony_encoding;
mod find_worker_to_update;
Expand Down
Loading