1use crate::catalog::registry::CatalogRegistry;
13use crate::domain::health_probe::ProbeExecutor;
14use crate::domain::port_allocator::PortAllocator;
15use crate::domain::volume_manager::VolumeManager;
16use crate::metrics::TataraMetrics;
17use crate::nats::NatsEventBus;
18use crate::secrets::SecretResolver;
19
20use std::sync::Arc;
21use tatara_core::cluster::types::NodeId;
22use tatara_core::domain::lifecycle::*;
23use tracing::{debug, info, warn};
24
25pub struct ConvergenceContext {
27 pub local_node_id: NodeId,
28 pub probe_executor: Arc<ProbeExecutor>,
29 pub catalog_registry: Arc<CatalogRegistry>,
30 pub port_allocator: Arc<PortAllocator>,
31 pub volume_manager: Arc<VolumeManager>,
32 pub secret_resolver: Arc<SecretResolver>,
33 pub nats_bus: Arc<NatsEventBus>,
34 pub metrics: Arc<TataraMetrics>,
35}
36
37#[derive(Debug, Default)]
39pub struct ConvergenceResult {
40 pub warmed: u32,
41 pub started: u32,
42 pub contracted: u32,
43 pub terminated: u32,
44 pub health_checks: u32,
45 pub orphans_detected: u32,
46}
47
48pub async fn converge_tick(
53 ctx: &ConvergenceContext,
54 desired: &[DesiredAllocationState],
55 observed: &std::collections::HashMap<uuid::Uuid, ObservedAllocationState>,
56) -> ConvergenceResult {
57 let mut result = ConvergenceResult::default();
58 let my_node = format!("{}", ctx.local_node_id);
59
60 let my_desired: Vec<&DesiredAllocationState> =
62 desired.iter().filter(|d| d.node_id == my_node).collect();
63
64 for desired_alloc in &my_desired {
65 let obs_phase = observed.get(&desired_alloc.alloc_id).map(|o| &o.phase);
66
67 match (&desired_alloc.desired_phase, obs_phase) {
68 (DesiredPhase::Active, None) | (DesiredPhase::Active, Some(WorkloadPhase::Initial)) => {
70 debug!(
71 alloc_id = %desired_alloc.alloc_id,
72 "convergence: initial → warming"
73 );
74 result.warmed += 1;
75 }
76
77 (DesiredPhase::Active, Some(WorkloadPhase::Warming(progress))) => {
79 if progress.secrets_resolved && progress.volumes_mounted {
80 debug!(
81 alloc_id = %desired_alloc.alloc_id,
82 "convergence: warming → executing"
83 );
84 result.started += 1;
85 }
86 }
87
88 (DesiredPhase::Active, Some(WorkloadPhase::Executing(_))) => {
90 result.health_checks += 1;
91 }
92
93 (DesiredPhase::Stopped { reason }, Some(WorkloadPhase::Executing(_))) => {
95 info!(
96 alloc_id = %desired_alloc.alloc_id,
97 reason = ?reason,
98 "convergence: executing → contracting"
99 );
100 result.contracted += 1;
101 }
102
103 (DesiredPhase::Stopped { .. }, Some(WorkloadPhase::Contracting(_))) => {
105 debug!(
106 alloc_id = %desired_alloc.alloc_id,
107 "convergence: contracting → checking drain"
108 );
109 }
110
111 (DesiredPhase::Stopped { .. }, Some(WorkloadPhase::Terminal(_))) => {}
113
114 (DesiredPhase::Active, Some(WorkloadPhase::Terminal(_))) => {
116 warn!(
117 alloc_id = %desired_alloc.alloc_id,
118 "desired Active but allocation is Terminal — scheduler should replace"
119 );
120 }
121
122 _ => {}
123 }
124 }
125
126 for (alloc_id, obs) in observed {
128 if obs.node_id != my_node {
129 continue;
130 }
131 if obs.phase.is_terminal() {
132 continue;
133 }
134 let is_desired = desired.iter().any(|d| d.alloc_id == *alloc_id);
135 if !is_desired {
136 info!(alloc_id = %alloc_id, "orphaned allocation detected");
137 result.orphans_detected += 1;
138 }
139 }
140
141 result
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use std::collections::HashMap;
148
149 fn make_desired(id: uuid::Uuid, phase: DesiredPhase) -> DesiredAllocationState {
150 DesiredAllocationState {
151 alloc_id: id,
152 job_id: "test-job".to_string(),
153 group_name: "main".to_string(),
154 node_id: "1".to_string(),
155 job_version: 1,
156 desired_phase: phase,
157 generation: 1,
158 }
159 }
160
161 fn make_observed(id: uuid::Uuid, phase: AllocationPhase) -> ObservedAllocationState {
162 ObservedAllocationState {
163 alloc_id: id,
164 node_id: "1".to_string(),
165 phase,
166 observed_at: chrono::Utc::now(),
167 observation_seq: 1,
168 }
169 }
170
171 fn make_ctx() -> ConvergenceContext {
172 ConvergenceContext {
173 local_node_id: 1,
174 probe_executor: Arc::new(ProbeExecutor::new()),
175 catalog_registry: Arc::new(CatalogRegistry::new()),
176 port_allocator: Arc::new(PortAllocator::default_range()),
177 volume_manager: Arc::new(VolumeManager::new("/tmp/test-volumes".into())),
178 secret_resolver: Arc::new(SecretResolver::new()),
179 nats_bus: Arc::new(NatsEventBus::disconnected()),
180 metrics: TataraMetrics::new(),
181 }
182 }
183
184 #[tokio::test]
185 async fn test_convergence_initial_to_warming() {
186 let ctx = make_ctx();
187 let id = uuid::Uuid::new_v4();
188 let desired = vec![make_desired(id, DesiredPhase::Active)];
189 let observed = HashMap::new();
190
191 let result = converge_tick(&ctx, &desired, &observed).await;
192 assert_eq!(result.warmed, 1);
193 }
194
195 #[tokio::test]
196 async fn test_convergence_executing_health_check() {
197 let ctx = make_ctx();
198 let id = uuid::Uuid::new_v4();
199 let desired = vec![make_desired(id, DesiredPhase::Active)];
200 let observed = HashMap::from([(
201 id,
202 make_observed(
203 id,
204 AllocationPhase::Executing(AllocExecuteDetail {
205 registered_in_catalog: true,
206 health: HealthStatus::Passing,
207 task_states: HashMap::new(),
208 }),
209 ),
210 )]);
211
212 let result = converge_tick(&ctx, &desired, &observed).await;
213 assert_eq!(result.health_checks, 1);
214 }
215
216 #[tokio::test]
217 async fn test_convergence_stop_triggers_contraction() {
218 let ctx = make_ctx();
219 let id = uuid::Uuid::new_v4();
220 let desired = vec![make_desired(
221 id,
222 DesiredPhase::Stopped {
223 reason: ContractReason::Stopped,
224 },
225 )];
226 let observed = HashMap::from([(
227 id,
228 make_observed(
229 id,
230 AllocationPhase::Executing(AllocExecuteDetail {
231 registered_in_catalog: true,
232 health: HealthStatus::Passing,
233 task_states: HashMap::new(),
234 }),
235 ),
236 )]);
237
238 let result = converge_tick(&ctx, &desired, &observed).await;
239 assert_eq!(result.contracted, 1);
240 }
241
242 #[tokio::test]
243 async fn test_convergence_orphan_detection() {
244 let ctx = make_ctx();
245 let orphan_id = uuid::Uuid::new_v4();
246
247 let desired = vec![]; let observed = HashMap::from([(
249 orphan_id,
250 make_observed(
251 orphan_id,
252 AllocationPhase::Executing(AllocExecuteDetail {
253 registered_in_catalog: false,
254 health: HealthStatus::Unknown,
255 task_states: HashMap::new(),
256 }),
257 ),
258 )]);
259
260 let result = converge_tick(&ctx, &desired, &observed).await;
261 assert_eq!(result.orphans_detected, 1);
262 }
263}