zlayer_agent/stabilization.rs
1//! Deployment stabilization polling.
2//!
3//! Provides a reusable function that waits for all services in a deployment to
4//! reach their desired replica count and pass health checks, or time out.
5//!
6//! This module lives in `zlayer-agent` (a library crate) so that both the
7//! runtime binary and the API server can share the same stabilization logic
8//! instead of duplicating it.
9
10use std::time::{Duration, Instant};
11
12use crate::service::ServiceManager;
13use zlayer_spec::{DeploymentSpec, Protocol, ScaleSpec};
14
15/// Per-service health summary returned by stabilization polling.
16#[derive(Debug, Clone, serde::Serialize)]
17pub struct ServiceHealthSummary {
18 /// Service name
19 pub name: String,
20 /// Running replica count
21 pub running: u32,
22 /// Desired replica count from the spec
23 pub desired: u32,
24 /// Whether health checks are passing for all running replicas
25 pub healthy: bool,
26 /// Endpoint URLs for this service (e.g. "<http://localhost:8080>")
27 pub endpoints: Vec<String>,
28}
29
30/// Outcome of the stabilization wait.
31///
32/// This is intentionally decoupled from `DeploymentStatus` (which lives in
33/// `zlayer-api`) to avoid circular dependencies. Callers should map this to
34/// their own status types.
35#[derive(Debug, Clone)]
36pub enum StabilizationOutcome {
37 /// All services reached their desired state within the timeout.
38 Ready,
39 /// The timeout expired before all services stabilized.
40 TimedOut {
41 /// Human-readable description of which services were not ready.
42 message: String,
43 },
44}
45
46/// Result of waiting for a deployment to stabilize.
47#[derive(Debug, Clone)]
48pub struct StabilizationResult {
49 /// Whether stabilization succeeded or timed out
50 pub outcome: StabilizationOutcome,
51 /// Per-service health summaries (always populated regardless of outcome)
52 pub services: Vec<ServiceHealthSummary>,
53}
54
55/// Wait for all services in a deployment to reach their desired replica count
56/// and pass health checks, or time out.
57///
58/// Polls every 500ms for up to `timeout`. Returns [`StabilizationOutcome::Ready`]
59/// if all services reach their desired state, or [`StabilizationOutcome::TimedOut`]
60/// if the timeout expires.
61pub async fn wait_for_stabilization(
62 manager: &ServiceManager,
63 spec: &DeploymentSpec,
64 timeout: Duration,
65) -> StabilizationResult {
66 let poll_interval = Duration::from_millis(500);
67 let start = Instant::now();
68
69 loop {
70 let mut all_ready = true;
71 let mut summaries = Vec::with_capacity(spec.services.len());
72
73 for (name, service_spec) in &spec.services {
74 // Jobs and cron jobs are run-to-completion workloads (triggered /
75 // scheduled), not scaled long-running services — they have no desired
76 // replica count to stabilize on. Skip them so a deployment containing
77 // a job doesn't time out waiting for a "running" replica that never
78 // exists (the job runs only when triggered).
79 if matches!(
80 service_spec.rtype,
81 zlayer_spec::ResourceType::Job | zlayer_spec::ResourceType::Cron
82 ) {
83 continue;
84 }
85
86 let desired = match &service_spec.scale {
87 ScaleSpec::Fixed { replicas } => *replicas,
88 ScaleSpec::Adaptive { min, .. } => *min,
89 ScaleSpec::Manual => 0,
90 };
91
92 // Cluster-wide: sum running replicas + health across every node the
93 // service is placed on (distributed scaling places replicas on
94 // remote nodes; a leader-local count would read 0 and never
95 // stabilize). Falls back to the local view when not clustered.
96 let node_states = manager.cluster_service_states(name).await;
97 #[allow(clippy::cast_possible_truncation)]
98 let running: u32 = node_states.iter().map(|s| s.running).sum();
99 let healthy = if desired == 0 {
100 true // Manual scaling / 0 replicas is trivially healthy.
101 } else {
102 // Every node with replicas must report healthy; nodes running
103 // none are trivially healthy and don't drag the aggregate.
104 node_states.iter().all(|s| s.healthy)
105 };
106
107 let service_ready = running == desired && healthy;
108 if !service_ready && desired > 0 {
109 all_ready = false;
110 }
111
112 // Build endpoint URLs from the spec
113 let endpoints: Vec<String> = service_spec
114 .endpoints
115 .iter()
116 .map(|ep| {
117 let proto = match ep.protocol {
118 Protocol::Http => "http",
119 Protocol::Https => "https",
120 Protocol::Tcp => "tcp",
121 Protocol::Udp => "udp",
122 Protocol::Websocket => "ws",
123 };
124 format!("{}://localhost:{}", proto, ep.port)
125 })
126 .collect();
127
128 summaries.push(ServiceHealthSummary {
129 name: name.clone(),
130 running,
131 desired,
132 healthy,
133 endpoints,
134 });
135 }
136
137 if all_ready {
138 return StabilizationResult {
139 outcome: StabilizationOutcome::Ready,
140 services: summaries,
141 };
142 }
143
144 if start.elapsed() >= timeout {
145 // Build a failure message from unhealthy services, including
146 // the tail of each failing service's container logs so the
147 // user sees the real cause (e.g. "GLIBC_2.38 not found",
148 // "failed to prepare rootfs", "panicked at ...") instead of
149 // just "1/1 replicas, healthy=false".
150 let failing: Vec<&ServiceHealthSummary> = summaries
151 .iter()
152 .filter(|s| (s.running != s.desired || !s.healthy) && s.desired > 0)
153 .collect();
154
155 let mut parts: Vec<String> = Vec::with_capacity(failing.len());
156 for s in &failing {
157 let header = format!(
158 "{}: {}/{} replicas, healthy={}",
159 s.name, s.running, s.desired, s.healthy
160 );
161 // Try to fetch the last 20 log lines from this service's
162 // replicas. A miss (no containers, runtime error) falls
163 // back to just the header so we never block the error
164 // on log retrieval.
165 match manager.get_service_logs(&s.name, 20, None).await {
166 Ok(entries) if !entries.is_empty() => {
167 let body = entries
168 .iter()
169 .map(|e| format!(" {}", e.message))
170 .collect::<Vec<_>>()
171 .join("\n");
172 parts.push(format!("{header}\n logs:\n{body}"));
173 }
174 _ => parts.push(header),
175 }
176 }
177
178 let message = if parts.is_empty() {
179 "Stabilization timed out".to_string()
180 } else {
181 format!("Stabilization timed out:\n {}", parts.join("\n "))
182 };
183
184 return StabilizationResult {
185 outcome: StabilizationOutcome::TimedOut { message },
186 services: summaries,
187 };
188 }
189
190 tokio::time::sleep(poll_interval).await;
191 }
192}