Skip to main content

tatara_engine/domain/
convergence.rs

1//! Convergence engine — drives local allocations toward desired state.
2//!
3//! Replaces the old 5-pass reconciler with a single convergence algorithm
4//! that compares desired state (from Raft) to observed state (local) and
5//! applies the appropriate state transitions.
6//!
7//! Every node runs this independently. Each node only converges
8//! allocations assigned to it. The result is distributed choreography:
9//! no single coordinator, every node independently drives toward
10//! the declared state.
11
12use 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
25/// Subsystems needed by the convergence engine.
26pub 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/// Result of a single convergence tick.
38#[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
48/// Run a single convergence tick for the local node.
49///
50/// Compares desired allocations (from Raft) against observed allocations
51/// and drives each toward its desired phase.
52pub 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    // Only process allocations assigned to this node
61    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            // Want Active, not started → begin warming
69            (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            // Want Active, warming → check if ready to execute
78            (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            // Want Active, executing → run health checks
89            (DesiredPhase::Active, Some(WorkloadPhase::Executing(_))) => {
90                result.health_checks += 1;
91            }
92
93            // Want Stopped, executing → begin contraction
94            (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            // Want Stopped, contracting → check if drain complete
104            (DesiredPhase::Stopped { .. }, Some(WorkloadPhase::Contracting(_))) => {
105                debug!(
106                    alloc_id = %desired_alloc.alloc_id,
107                    "convergence: contracting → checking drain"
108                );
109            }
110
111            // Want Stopped, already terminal → no-op
112            (DesiredPhase::Stopped { .. }, Some(WorkloadPhase::Terminal(_))) => {}
113
114            // Want Active but terminal → scheduler should create replacement
115            (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    // Detect orphans: observed locally but not in desired set
127    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![]; // nothing desired
248        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}