Repository navigation
feat(load-monitor): per-group polling with rich load data #554
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 all commits
9b5a802
952cf9d
d9d106c
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 |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| //! Step to remove workers from worker registry. | ||
|
|
||
| use std::collections::HashSet; | ||
| use std::collections::{HashMap, HashSet}; | ||
|
|
||
| use async_trait::async_trait; | ||
| use tracing::{debug, warn}; | ||
|
|
@@ -10,6 +10,7 @@ use crate::{ | |
| core::{ | ||
| steps::workflow_data::WorkerRemovalWorkflowData, | ||
| worker::{ConnectionModeExt, WorkerTypeExt}, | ||
| WorkerGroupKey, | ||
| }, | ||
| observability::metrics::Metrics, | ||
| }; | ||
|
|
@@ -37,19 +38,28 @@ impl StepExecutor<WorkerRemovalWorkflowData> for RemoveFromWorkerRegistryStep { | |
| worker_urls.len() | ||
| ); | ||
|
|
||
| // Collect unique worker configurations before removal for pool size updates | ||
| let unique_configs: HashSet<_> = worker_urls | ||
| .iter() | ||
| .filter_map(|url| app_context.worker_registry.get_by_url(url)) | ||
| .map(|w| { | ||
| // Collect unique worker configurations and their URLs before removal. | ||
| // We need the URLs pre-removal so LoadMonitor can clean up stale load entries. | ||
| let mut urls_by_group: HashMap< | ||
| ( | ||
| openai_protocol::worker::WorkerType, | ||
| openai_protocol::worker::ConnectionMode, | ||
| String, | ||
| ), | ||
| Vec<String>, | ||
| > = HashMap::new(); | ||
| for url in worker_urls { | ||
| if let Some(w) = app_context.worker_registry.get_by_url(url) { | ||
| let meta = w.metadata(); | ||
| ( | ||
| let key = ( | ||
| meta.spec.worker_type, | ||
| meta.spec.connection_mode, | ||
| w.model_id().to_string(), | ||
| ) | ||
| }) | ||
| .collect(); | ||
| ); | ||
| urls_by_group.entry(key).or_default().push(url.clone()); | ||
| } | ||
| } | ||
| let unique_configs: HashSet<_> = urls_by_group.keys().cloned().collect(); | ||
|
|
||
| let mut removed_count = 0; | ||
| for worker_url in worker_urls { | ||
|
|
@@ -74,17 +84,18 @@ impl StepExecutor<WorkerRemovalWorkflowData> for RemoveFromWorkerRegistryStep { | |
| } | ||
|
|
||
| // Update Layer 3 worker pool size metrics for unique configurations | ||
| for (worker_type, connection_mode, model_id) in unique_configs { | ||
| // and notify LoadMonitor when groups become empty | ||
| for (worker_type, connection_mode, model_id) in &unique_configs { | ||
| // Get labels before moving values into get_workers_filtered | ||
| let worker_type_label = worker_type.as_metric_label(); | ||
| let connection_mode_label = connection_mode.as_metric_label(); | ||
|
|
||
| let pool_size = app_context | ||
| .worker_registry | ||
| .get_workers_filtered( | ||
| Some(&model_id), | ||
| Some(worker_type), | ||
| Some(connection_mode), | ||
| Some(model_id), | ||
| Some(*worker_type), | ||
| Some(*connection_mode), | ||
| None, | ||
| false, | ||
| ) | ||
|
|
@@ -93,9 +104,25 @@ impl StepExecutor<WorkerRemovalWorkflowData> for RemoveFromWorkerRegistryStep { | |
| Metrics::set_worker_pool_size( | ||
| worker_type_label, | ||
| connection_mode_label, | ||
| &model_id, | ||
| model_id, | ||
| pool_size, | ||
| ); | ||
|
|
||
| // If the group is now empty, stop its load monitor and clean up entries | ||
| if pool_size == 0 { | ||
| if let Some(ref load_monitor) = app_context.load_monitor { | ||
| let key = WorkerGroupKey { | ||
| model_id: model_id.clone(), | ||
| worker_type: *worker_type, | ||
| connection_mode: *connection_mode, | ||
| }; | ||
| let removed_urls = urls_by_group | ||
| .get(&(*worker_type, *connection_mode, model_id.clone())) | ||
| .map(|v| v.as_slice()) | ||
| .unwrap_or_default(); | ||
| load_monitor.on_group_removed(&key, removed_urls).await; | ||
| } | ||
|
Comment on lines
+111
to
+124
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. Cleanup is skipped for removed workers when the group still has members. Line 112 gates cleanup behind Suggested direction- // If the group is now empty, stop its load monitor and clean up entries
- if pool_size == 0 {
- if let Some(ref load_monitor) = app_context.load_monitor {
+ if let Some(ref load_monitor) = app_context.load_monitor {
+ let removed_urls = urls_by_group
+ .get(&(*worker_type, *connection_mode, model_id.clone()))
+ .map(|v| v.as_slice())
+ .unwrap_or_default();
+
+ // New API: prune removed URLs even when group remains active.
+ load_monitor.on_workers_removed(removed_urls).await;
+
+ if pool_size == 0 {
let key = WorkerGroupKey {
model_id: model_id.clone(),
worker_type: *worker_type,
connection_mode: *connection_mode,
};
- let removed_urls = urls_by_group
- .get(&(*worker_type, *connection_mode, model_id.clone()))
- .map(|v| v.as_slice())
- .unwrap_or_default();
load_monitor.on_group_removed(&key, removed_urls).await;
}
}🤖 Prompt for AI Agents
Member
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. Pushing back. Stale entries for removed workers are cleaned up on the next poll tick — group_monitor_loop clears all group URLs before inserting (d9d106c), and removed workers wont appear in get_workers_filtered, so their URLs wont be re-inserted. Cleanup window is bounded to one interval period. 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. 🧩 Analysis chain🏁 Script executed: Length of output: 9054
🧠 Learnings used |
||
| } | ||
| } | ||
|
|
||
| Ok(StepResult::Success) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ use crate::{ | |
| core::{ | ||
| steps::workflow_data::WorkerRegistrationData, | ||
| worker::{ConnectionModeExt, WorkerTypeExt}, | ||
| WorkerGroupKey, | ||
| }, | ||
| observability::metrics::Metrics, | ||
| }; | ||
|
|
@@ -64,17 +65,18 @@ impl<D: WorkerRegistrationData + WorkflowData> StepExecutor<D> for RegisterWorke | |
| .collect(); | ||
|
|
||
| // Update Layer 3 worker pool size metrics per unique type/connection/model | ||
| for (worker_type, connection_mode, model_id) in unique_configs { | ||
| // and notify LoadMonitor of new groups | ||
| for (worker_type, connection_mode, model_id) in &unique_configs { | ||
| // Get labels before moving values into get_workers_filtered | ||
| let worker_type_label = worker_type.as_metric_label(); | ||
| let connection_mode_label = connection_mode.as_metric_label(); | ||
|
|
||
| let pool_size = app_context | ||
| .worker_registry | ||
| .get_workers_filtered( | ||
| Some(&model_id), | ||
| Some(worker_type), | ||
| Some(connection_mode), | ||
| Some(model_id), | ||
| Some(*worker_type), | ||
| Some(*connection_mode), | ||
| None, | ||
| false, | ||
| ) | ||
|
|
@@ -83,11 +85,34 @@ impl<D: WorkerRegistrationData + WorkflowData> StepExecutor<D> for RegisterWorke | |
| Metrics::set_worker_pool_size( | ||
| worker_type_label, | ||
| connection_mode_label, | ||
| &model_id, | ||
| model_id, | ||
| pool_size, | ||
| ); | ||
| } | ||
|
|
||
| // Notify LoadMonitor of new worker groups so it can start polling | ||
| if let Some(ref load_monitor) = app_context.load_monitor { | ||
| for (worker_type, connection_mode, model_id) in &unique_configs { | ||
| let key = WorkerGroupKey { | ||
| model_id: model_id.clone(), | ||
| worker_type: *worker_type, | ||
| connection_mode: *connection_mode, | ||
| }; | ||
| // Use the minimum interval across all workers in this group. | ||
| // If workers disagree, the fastest polling rate wins (safest default). | ||
| let interval = workers | ||
| .iter() | ||
| .filter(|w| { | ||
| w.model_id() == model_id | ||
| && w.worker_type() == worker_type | ||
| && w.connection_mode() == connection_mode | ||
| }) | ||
| .filter_map(|w| w.metadata().spec.load_monitor_interval_secs) | ||
|
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.
This code derives the group polling interval from Useful? React with 👍 / 👎.
Member
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. Valid observation. The creation steps use individual builder setters and dont pass load_monitor_interval_secs through. The field exists on WorkerSpec and the monitoring code reads it, but the creation pipeline needs a builder setter or from_spec() usage. Will address in a follow-up. |
||
| .min(); | ||
|
Comment on lines
+110
to
+111
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.
This minimum only considers explicit per-worker overrides, so workers with Useful? React with 👍 / 👎.
Member
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. Pushing back. Per-worker interval is an explicit override. In a mixed group [Some(30), None] with global=10, we pass Some(30) — the user set 30s intentionally. Workers without an override defer to whatever the group picks. The global default only kicks in when no worker specifies an interval. |
||
| load_monitor.on_group_added(key, interval).await; | ||
| } | ||
| } | ||
|
slin1237 marked this conversation as resolved.
Resolved
|
||
|
|
||
| // Note: worker_ids are stored for potential future use but not persisted | ||
| // as they are internal registry identifiers | ||
| debug!( | ||
|
|
||
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.
This cleanup path only runs when
pool_size == 0, so removing a worker from a still-active group skipson_group_removed. The monitor loop later removes only URLs that still exist in that group, so departed URLs are never purged from the shared load snapshot for long-lived groups, causing stale-entry growth and allowing old load data to resurface if that URL is re-registered before the next successful poll.Useful? React with 👍 / 👎.
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.
Duplicate of the earlier comment. Already addressed — the monitor loop clears and re-inserts on each tick (d9d106c), so departed URLs are cleaned up within one interval period.