Skip to main content

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}