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 5 commits
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,10 @@ 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() && engine_id_may_be_stale(launched_status, worker.as_ref()) { | ||
| let timeout = Duration::from_secs(health_config.timeout_secs.max(1)); | ||
| refresh_kv_engine_id_after_recovery(&worker, timeout).await; | ||
| } | ||
| ProbeCompletion { | ||
| worker_id, | ||
| worker, | ||
|
|
@@ -700,6 +704,87 @@ fn schedule_worker_at( | |
| ); | ||
| } | ||
|
|
||
| /// Whether a probe launched at `launched_status` that succeeds should re-read | ||
| /// the worker's KV engine id: after a failure (the engine may be a new | ||
| /// process), while a worker that registered before its engine reported an id | ||
| /// is still pending, or while a Ready worker's last recovery re-read has not | ||
| /// been confirmed. A Draining worker, or a pending one that already has an | ||
| /// id, is the same process registration read. | ||
| fn engine_id_may_be_stale(launched_status: WorkerStatus, worker: &dyn Worker) -> bool { | ||
| match launched_status { | ||
| WorkerStatus::Failed | WorkerStatus::NotReady => true, | ||
| WorkerStatus::Pending => worker.kv_engine_id().is_none(), | ||
| WorkerStatus::Ready => !worker.kv_engine_id_confirmed(), | ||
| WorkerStatus::Draining => false, | ||
| } | ||
| } | ||
|
|
||
| /// A PD worker that answers its probe again may be a new engine process on | ||
| /// the same address, with a new KV transfer engine id (#2491); a worker | ||
| /// registered while its engine was still coming up may have none at all. | ||
| /// 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. The read is bounded by the probe timeout: one that fails, expires | ||
| /// or comes back without an id keeps the previous id and says so, since a | ||
| /// stale id is a better outcome than a probe slot held forever or a handoff | ||
| /// with no id at all. An id the engine does report wins over the spec's: it | ||
| /// is the one a handoff must target. | ||
| async fn refresh_kv_engine_id_after_recovery(worker: &Arc<dyn Worker>, timeout: Duration) { | ||
| 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; | ||
| } | ||
| let read = tokio::time::timeout( | ||
| timeout, | ||
| discover_grpc_kv_engine_id(worker.url(), spec.runtime_type.as_str()), | ||
| ) | ||
| .await; | ||
| // Anything but a confirmed id leaves the flag set, so the next probe | ||
| // retries instead of the worker serving with a possibly stale id until | ||
| // its next outage. | ||
| worker.set_kv_engine_id_confirmed(false); | ||
| match read { | ||
| Ok(Ok(Some(discovered))) => { | ||
| let previous = worker.kv_engine_id(); | ||
| if worker.refresh_kv_engine_id(Some(discovered.clone())) { | ||
| info!( | ||
| worker_url = %worker.url(), | ||
| ?previous, | ||
| discovered, | ||
| "Recovered PD worker reports a new KV engine id" | ||
| ); | ||
| } | ||
| worker.set_kv_engine_id_confirmed(true); | ||
| } | ||
| // A partial read (server info tolerated as missing) or an engine | ||
| // that reports no id: a known id must never be cleared by it. | ||
| Ok(Ok(None)) => match worker.kv_engine_id() { | ||
| Some(previous) => warn!( | ||
| worker_url = %worker.url(), | ||
| previous, | ||
| "Recovered PD worker reported no KV engine id; keeping the previous one until it does" | ||
| ), | ||
| None => warn!( | ||
| worker_url = %worker.url(), | ||
| "PD worker reports no KV engine id yet; handoffs carry none until it does" | ||
| ), | ||
| }, | ||
| Ok(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 and retrying on the next probe" | ||
| ), | ||
| Err(_) => warn!( | ||
| worker_url = %worker.url(), | ||
| timeout_secs = timeout.as_secs(), | ||
| "Re-reading the KV engine id of a recovered PD worker timed out; keeping the previous one and retrying on the next probe" | ||
| ), | ||
| } | ||
| } | ||
|
|
||
| /// Apply the state machine to a probe outcome. Returns the next status if | ||
| /// a transition is needed, `None` if the worker stays in its current state. | ||
| /// | ||
|
|
@@ -1170,14 +1255,100 @@ mod tests { | |
| }, | ||
| }; | ||
|
|
||
| /// The re-read gate: after a failure always, while pending only without | ||
|
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: this doc comment (and the test name below it) now contradict the case added right underneath — "never on a Ready or Draining probe" is no longer true, Also uncovered: nothing pins the clearing side of the flag.
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. Doc and test name updated to cover the Ready-until-confirmed case. |
||
| /// an id, never on a Ready or Draining probe. | ||
| #[test] | ||
| fn the_engine_id_is_re_read_after_a_failure_or_while_pending_without_one() { | ||
| let worker = |kv_engine_id: Option<&str>| -> Arc<dyn Worker> { | ||
| let mut builder = BasicWorkerBuilder::new("grpc://p:1") | ||
| .worker_type(WorkerType::Prefill) | ||
| .connection_mode(ConnectionMode::Grpc) | ||
| .runtime_type(RuntimeType::Vllm) | ||
| .kv_connector("MooncakeConnector"); | ||
| if let Some(id) = kv_engine_id { | ||
| builder = builder.kv_engine_id(id); | ||
| } | ||
| Arc::new(builder.build()) | ||
| }; | ||
| let with_id = worker(Some("eng")); | ||
| let without_id = worker(None); | ||
| let unconfirmed = worker(Some("eng")); | ||
| unconfirmed.set_kv_engine_id_confirmed(false); | ||
| let cases = [ | ||
| (WorkerStatus::Failed, &with_id, true), | ||
| (WorkerStatus::NotReady, &with_id, true), | ||
| (WorkerStatus::Pending, &with_id, false), | ||
| (WorkerStatus::Pending, &without_id, true), | ||
| (WorkerStatus::Ready, &with_id, false), | ||
| (WorkerStatus::Ready, &without_id, false), | ||
| (WorkerStatus::Ready, &unconfirmed, true), | ||
| (WorkerStatus::Draining, &unconfirmed, false), | ||
| ]; | ||
| for (status, worker, expected) in cases { | ||
| assert_eq!( | ||
| engine_id_may_be_stale(status, worker.as_ref()), | ||
| expected, | ||
| "{status:?} with id {:?}", | ||
| worker.kv_engine_id() | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /// A recovered worker whose engine accepts the connection but never | ||
| /// answers the metadata read must not hold the probe slot: the read | ||
| /// expires and the previous engine id stays (#2491). | ||
| #[tokio::test] | ||
| #[expect( | ||
| clippy::disallowed_methods, | ||
| reason = "the held connections live for the duration of the test" | ||
| )] | ||
| async fn a_hanging_engine_id_read_expires_and_keeps_the_previous_id() { | ||
| let listener = tokio::net::TcpListener::bind("127.0.0.1:0") | ||
| .await | ||
| .expect("bind a loopback listener"); | ||
| let port = listener.local_addr().expect("listener address").port(); | ||
| // Accept every connection and never answer on it. | ||
| let hold = tokio::spawn(async move { | ||
| let mut held = Vec::new(); | ||
| loop { | ||
| if let Ok((socket, _)) = listener.accept().await { | ||
| held.push(socket); | ||
| } | ||
| } | ||
| }); | ||
| let worker: Arc<dyn Worker> = Arc::new( | ||
| BasicWorkerBuilder::new(format!("grpc://127.0.0.1:{port}")) | ||
| .worker_type(WorkerType::Prefill) | ||
| .connection_mode(ConnectionMode::Grpc) | ||
| .runtime_type(RuntimeType::Vllm) | ||
| .kv_connector("MooncakeConnector") | ||
| .kv_engine_id("eng-old") | ||
| .build(), | ||
| ); | ||
|
|
||
| let started = std::time::Instant::now(); | ||
| refresh_kv_engine_id_after_recovery(&worker, Duration::from_millis(300)).await; | ||
|
|
||
| assert!( | ||
| started.elapsed() < Duration::from_secs(5), | ||
| "the re-read must be bounded by the timeout" | ||
| ); | ||
| assert_eq!(worker.kv_engine_id().as_deref(), Some("eng-old")); | ||
| // Not confirmed: a later Ready probe re-reads instead of giving up. | ||
| assert!(!worker.kv_engine_id_confirmed()); | ||
| assert!(engine_id_may_be_stale(WorkerStatus::Ready, worker.as_ref())); | ||
| hold.abort(); | ||
| } | ||
|
|
||
| use openai_protocol::{ | ||
| model_card::ModelCard, | ||
| worker::{HealthCheckConfig, WorkerStatus}, | ||
| }; | ||
|
|
||
| use super::*; | ||
| use crate::worker::{ | ||
| BasicWorkerBuilder, ConnectionMode, Worker, WorkerError, WorkerRegistry, WorkerType, | ||
| BasicWorkerBuilder, ConnectionMode, RuntimeType, Worker, WorkerError, WorkerRegistry, | ||
| WorkerType, | ||
| }; | ||
|
|
||
| fn make_worker(url: &str, success_threshold: u32, failure_threshold: u32) -> Arc<dyn Worker> { | ||
|
|
||
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.
🔴 Important: this arm is not just the transient partial-read case — it is also the steady state of every PD worker whose engine never reports an id, and those now re-read on every probe for the life of the process.
worker.set_kv_engine_id_confirmed(false)at line 748 runs before the match and is only undone byOk(Ok(Some(_))).Ok(Ok(None))is returned both by a partial read (get_server_infotolerated as awarn!insidefetch_grpc_metadata,discover_metadata.rs:286) and by an engine that simply has nokv_engine_idin its server info — the case this very push added a branch for ("PD worker reports no KV engine id yet"), and the casekv_transfer.rs:26documents as supported ("the servicer predateskv_engine_idreporting").discover_grpc_kv_engine_idcollapses the two into the sameOk(None), so the flag can never be cleared for the second kind of worker.Failure scenario: a gRPC PD worker with
kv_connectorset whose engine reports nokv_engine_id(older servicer, or a connector that does not carry one). It registersPendingwithkv_engine_id: None, soengine_id_may_be_stalealready fires on the pending probe →Ok(None)→ unconfirmed. It is promoted toReady, and the newWorkerStatus::Ready => !worker.kv_engine_id_confirmed()arm (line 717) is thentruefor every subsequent probe. So each health probe — everycheck_interval_secs, forever — additionally doesGrpcClient::connect+get_model_info()+get_server_info()(a fresh channel each time,fetch_grpc_metadatadoes not reuse one), emits onewarn!, and holds itsMAX_CONCURRENT_HEALTH_PROBESslot for up totimeout_secslonger. Before this push the read happened once per recovery; now it is unbounded and permanent, on workers where it can never succeed.Two ways out, either is small:
discover_grpc_kv_engine_id: propagate theget_server_infofailure (a variant offetch_grpc_metadatathat does not tolerate it for this call site) so a partial read isErr→ unconfirmed → retried, while a complete read that reports no id is a confirmed "this engine has none" → flag cleared, no retry.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.
Took the first way out: discover_grpc_kv_engine_id now calls server info directly and propagates its failure, so a partial read is Err (unconfirmed, retried next probe) and a completed read without an id is Ok(None), which confirms the worker (no retry, known id kept, debug-level when there was none). A worker whose engine never reports an id therefore reads once while pending and never again.