Repository navigation
fix(pd): re-read a recovered PD worker's KV engine id before it is promoted #2492
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
bd830d3
3abec98
d0c91dc
78da0c2
f7d0ce2
dc1e411
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||||||
|
|
@@ -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 | ||||||||||
| ) | ||||||||||
| { | ||||||||||
| refresh_kv_engine_id_after_recovery(&worker).await; | ||||||||||
| } | ||||||||||
| ProbeCompletion { | ||||||||||
| worker_id, | ||||||||||
| worker, | ||||||||||
|
|
@@ -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 | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Nit: the new function was inserted between
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Failure scenario: a restarted vLLM engine accepts the TCP connection and answers Wrapping the call in
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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()) { | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Important: a partial metadata read is
Failure scenario: a restarted vLLM prefill answers Two smaller consequences of the same Only writing a discovered id when there is one keeps the worst case "stale id, as before":
Suggested change
(with a matching
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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` | ||
|
|
@@ -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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 The pre-existing part: that builder never sets
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>, | ||
|
|
@@ -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(), | ||
| } | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Nit:
Pendingis excluded, which leaves one variant of the same bug uncovered. Registration tolerates a failed metadata read —discover_metadata.rs:412doesunwrap_or_else(|e| { warn!(...); (HashMap::new(), None) }), andtake_kv_transfer_metadatahas no requirement that a PD worker carry an id — so a PD worker registered while its engine was still coming up lands inPendingwithkv_engine_id: None. Its promotion isPending → Ready, neverFailed/NotReady, so this refresh never runs and the worker mints handoffs with no engine id for the rest of its life. AddingWorkerStatus::Pendingto thematches!would close it (at the cost of one metadata read per pending probe on PD workers).There was a problem hiding this comment.
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.