use std::collections::{HashMap, HashSet};
use futures::StreamExt;
use tokio::sync::watch;
use dynamo_runtime::component::Endpoint;
use dynamo_runtime::discovery::{
DiscoveryEvent, DiscoveryInstanceId, DiscoveryQuery, DiscoveryStream,
};
use dynamo_runtime::prelude::DistributedRuntimeProvider;
use tokio_util::sync::CancellationToken;
use crate::local_model::runtime_config::ModelRuntimeConfig;
use crate::model_card::ModelDeploymentCard;
use dynamo_kv_router::protocols::WorkerId;
pub type RuntimeConfigWatch = watch::Receiver<HashMap<WorkerId, ModelRuntimeConfig>>;
fn base_runtime_config_watch(
mut stream: DiscoveryStream,
lifecycle: CancellationToken,
) -> watch::Receiver<HashMap<WorkerId, ModelRuntimeConfig>> {
let (tx, rx) = watch::channel(HashMap::new());
tokio::spawn(async move {
let mut configs = HashMap::new();
loop {
let result = tokio::select! {
_ = lifecycle.cancelled() => break,
event = stream.next() => match event {
Some(result) => result,
None => break,
},
};
match result {
Ok(DiscoveryEvent::Added(instance)) => {
let DiscoveryInstanceId::Model(id) = instance.id() else {
continue;
};
let card = match instance.deserialize_model::<ModelDeploymentCard>() {
Ok(card) => card,
Err(error) => {
tracing::warn!(
instance_id = id.instance_id,
%error,
"Failed to deserialize base model runtime config"
);
continue;
}
};
if id.model_suffix.is_some() || card.lora.is_some() {
continue;
}
if let Err(error) = card.runtime_config.data_parallel_rank_range() {
tracing::warn!(
instance_id = id.instance_id,
%error,
"Ignoring base model runtime config with invalid data-parallel rank range"
);
configs.remove(&id.instance_id);
} else {
configs.insert(id.instance_id, card.runtime_config);
}
}
Ok(DiscoveryEvent::ModelTaintsUpdated(update)) => {
if update.id.model_suffix.is_some() {
continue;
}
let Some(config) = configs.get_mut(&update.id.instance_id) else {
tracing::warn!(
instance_id = update.id.instance_id,
"Ignoring taint update for an unknown base model card"
);
continue;
};
let taints = update.taints.into_iter().collect();
if config.taints == taints {
continue;
}
config.taints = taints;
}
Ok(DiscoveryEvent::Removed(DiscoveryInstanceId::Model(id))) => {
if id.model_suffix.is_none() {
configs.remove(&id.instance_id);
}
}
Ok(DiscoveryEvent::Removed(_)) => continue,
Ok(DiscoveryEvent::Resync(_)) => continue,
Err(error) => {
tracing::error!(%error, "Base model runtime-config discovery stream failed");
continue;
}
}
if *tx.borrow() != configs && tx.send(configs.clone()).is_err() {
break;
}
}
});
rx
}
pub async fn runtime_config_watch(
endpoint: &Endpoint,
lifecycle: CancellationToken,
) -> anyhow::Result<RuntimeConfigWatch> {
let component = endpoint.component();
let cancel_token = component.drt().primary_token();
let client = endpoint.client().await?;
let mut instance_ids_rx = client.instance_avail_watcher();
let discovery = component.drt().discovery();
let eid = endpoint.id();
let stream = discovery
.list_and_watch(
DiscoveryQuery::EndpointModels {
namespace: eid.namespace.clone(),
component: eid.component.clone(),
endpoint: eid.name.clone(),
},
Some(cancel_token.clone()),
)
.await?;
let mut configs_rx = base_runtime_config_watch(stream, lifecycle.clone());
let (tx, rx) = watch::channel(HashMap::new());
tokio::spawn(async move {
loop {
tokio::select! {
_ = cancel_token.cancelled() => break,
_ = lifecycle.cancelled() => break,
_ = tx.closed() => break,
result = instance_ids_rx.changed() => { if result.is_err() { break; } }
result = configs_rx.changed() => { if result.is_err() { break; } }
}
let instances: HashSet<WorkerId> = instance_ids_rx
.borrow_and_update()
.iter()
.copied()
.collect();
let configs = configs_rx.borrow_and_update().clone();
let ready: HashMap<WorkerId, ModelRuntimeConfig> = instances
.into_iter()
.filter_map(|id| configs.get(&id).map(|cfg| (id, cfg.clone())))
.collect();
if *tx.borrow() == ready {
continue;
}
if tx.send(ready).is_err() {
break;
}
}
});
Ok(rx)
}
#[cfg(test)]
mod tests {
use super::*;
use dynamo_runtime::discovery::{DiscoveryInstance, ModelCardInstanceId, ModelTaintsUpdate};
fn model_instance(
instance_id: u64,
model_suffix: Option<&str>,
card: &ModelDeploymentCard,
) -> DiscoveryInstance {
DiscoveryInstance::Model {
namespace: "ns".to_string(),
component: "worker".to_string(),
endpoint: "generate".to_string(),
instance_id,
card_json: serde_json::to_value(card).unwrap(),
model_suffix: model_suffix.map(str::to_string),
}
}
#[tokio::test]
async fn base_runtime_config_watch_exits_on_lifecycle_cancellation_with_no_stream_activity() {
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
let stream: DiscoveryStream =
Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(rx));
let lifecycle = CancellationToken::new();
let mut configs = base_runtime_config_watch(stream, lifecycle.clone());
lifecycle.cancel();
tokio::time::timeout(std::time::Duration::from_secs(5), configs.changed())
.await
.expect("base_runtime_config_watch's task must exit within the timeout")
.expect_err("the watch::Sender must be dropped once the task exits");
}
#[tokio::test]
async fn only_base_cards_define_runtime_config_expectations() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let stream: DiscoveryStream =
Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(rx));
let mut configs = base_runtime_config_watch(stream, CancellationToken::new());
let mut base = ModelDeploymentCard::default();
base.runtime_config.data_parallel_start_rank = 3;
base.runtime_config.data_parallel_size = 2;
let mut lora = ModelDeploymentCard::default();
lora.lora = Some(crate::model_card::LoraInfo {
name: "adapter".to_string(),
max_gpu_lora_count: Some(4),
});
lora.runtime_config.data_parallel_start_rank = 99;
lora.runtime_config.data_parallel_size = 8;
let base_instance = model_instance(7, None, &base);
let lora_instance = model_instance(7, Some("adapter"), &lora);
tx.send(Ok(DiscoveryEvent::Added(lora_instance.clone())))
.unwrap();
tx.send(Ok(DiscoveryEvent::Added(base_instance.clone())))
.unwrap();
configs.changed().await.unwrap();
let config = configs.borrow().get(&7).cloned().unwrap();
assert_eq!(config.data_parallel_start_rank, 3);
assert_eq!(config.data_parallel_size, 2);
tx.send(Ok(DiscoveryEvent::Removed(lora_instance.id())))
.unwrap();
tx.send(Ok(DiscoveryEvent::Removed(base_instance.id())))
.unwrap();
configs.changed().await.unwrap();
assert!(configs.borrow().is_empty());
}
#[tokio::test]
async fn invalid_data_parallel_ranges_are_ignored_before_runtime_config_watch() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let stream: DiscoveryStream =
Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(rx));
let mut configs = base_runtime_config_watch(stream, CancellationToken::new());
let mut zero_size = ModelDeploymentCard::default();
zero_size.runtime_config.data_parallel_size = 0;
let mut oversized = ModelDeploymentCard::default();
oversized.runtime_config.data_parallel_size = 4097;
let mut overflowing = ModelDeploymentCard::default();
overflowing.runtime_config.data_parallel_start_rank = u32::MAX;
let valid = ModelDeploymentCard::default();
for (instance_id, card) in [(6, &zero_size), (7, &oversized), (8, &overflowing)] {
tx.send(Ok(DiscoveryEvent::Added(model_instance(
instance_id,
None,
card,
))))
.unwrap();
}
tx.send(Ok(DiscoveryEvent::Added(model_instance(9, None, &valid))))
.unwrap();
configs.changed().await.unwrap();
assert!(!configs.borrow().contains_key(&6));
assert!(!configs.borrow().contains_key(&7));
assert!(!configs.borrow().contains_key(&8));
assert_eq!(configs.borrow().get(&9).unwrap().data_parallel_size, 1);
}
#[tokio::test]
async fn scoped_taint_updates_replace_only_known_base_worker_taints() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let stream: DiscoveryStream =
Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(rx));
let mut configs = base_runtime_config_watch(stream, CancellationToken::new());
let mut base = ModelDeploymentCard::default();
base.runtime_config.taints = HashSet::from(["old".to_string()]);
let base_instance = model_instance(7, None, &base);
let DiscoveryInstanceId::Model(id) = base_instance.id() else {
unreachable!()
};
tx.send(Ok(DiscoveryEvent::Added(base_instance.clone())))
.unwrap();
configs.changed().await.unwrap();
configs.borrow_and_update();
tx.send(Ok(DiscoveryEvent::Resync(vec![base_instance])))
.unwrap();
assert!(
tokio::time::timeout(std::time::Duration::from_millis(50), configs.changed())
.await
.is_err()
);
let updated_taints = vec!["blue".to_string(), "gpu".to_string()];
tx.send(Ok(DiscoveryEvent::ModelTaintsUpdated(ModelTaintsUpdate {
id: id.clone(),
taints: updated_taints.clone(),
})))
.unwrap();
configs.changed().await.unwrap();
assert_eq!(
configs.borrow_and_update().get(&7).unwrap().taints,
updated_taints.iter().cloned().collect()
);
tx.send(Ok(DiscoveryEvent::ModelTaintsUpdated(ModelTaintsUpdate {
id: id.clone(),
taints: updated_taints,
})))
.unwrap();
assert!(
tokio::time::timeout(std::time::Duration::from_millis(50), configs.changed())
.await
.is_err()
);
tx.send(Ok(DiscoveryEvent::ModelTaintsUpdated(ModelTaintsUpdate {
id: ModelCardInstanceId {
instance_id: 99,
..id
},
taints: vec!["unknown".to_string()],
})))
.unwrap();
assert!(
tokio::time::timeout(std::time::Duration::from_millis(50), configs.changed())
.await
.is_err()
);
assert_eq!(configs.borrow().len(), 1);
}
}