use crate::catalog::registry::CatalogRegistry;
use crate::domain::health_probe::ProbeExecutor;
use crate::domain::port_allocator::PortAllocator;
use crate::domain::volume_manager::VolumeManager;
use crate::metrics::TataraMetrics;
use crate::nats::NatsEventBus;
use crate::secrets::SecretResolver;
use std::sync::Arc;
use tatara_core::cluster::types::NodeId;
use tatara_core::domain::lifecycle::*;
use tracing::{debug, info, warn};
pub struct ConvergenceContext {
pub local_node_id: NodeId,
pub probe_executor: Arc<ProbeExecutor>,
pub catalog_registry: Arc<CatalogRegistry>,
pub port_allocator: Arc<PortAllocator>,
pub volume_manager: Arc<VolumeManager>,
pub secret_resolver: Arc<SecretResolver>,
pub nats_bus: Arc<NatsEventBus>,
pub metrics: Arc<TataraMetrics>,
}
#[derive(Debug, Default)]
pub struct ConvergenceResult {
pub warmed: u32,
pub started: u32,
pub contracted: u32,
pub terminated: u32,
pub health_checks: u32,
pub orphans_detected: u32,
}
pub async fn converge_tick(
ctx: &ConvergenceContext,
desired: &[DesiredAllocationState],
observed: &std::collections::HashMap<uuid::Uuid, ObservedAllocationState>,
) -> ConvergenceResult {
let mut result = ConvergenceResult::default();
let my_node = format!("{}", ctx.local_node_id);
let my_desired: Vec<&DesiredAllocationState> =
desired.iter().filter(|d| d.node_id == my_node).collect();
for desired_alloc in &my_desired {
let obs_phase = observed.get(&desired_alloc.alloc_id).map(|o| &o.phase);
match (&desired_alloc.desired_phase, obs_phase) {
(DesiredPhase::Active, None) | (DesiredPhase::Active, Some(WorkloadPhase::Initial)) => {
debug!(
alloc_id = %desired_alloc.alloc_id,
"convergence: initial → warming"
);
result.warmed += 1;
}
(DesiredPhase::Active, Some(WorkloadPhase::Warming(progress))) => {
if progress.secrets_resolved && progress.volumes_mounted {
debug!(
alloc_id = %desired_alloc.alloc_id,
"convergence: warming → executing"
);
result.started += 1;
}
}
(DesiredPhase::Active, Some(WorkloadPhase::Executing(_))) => {
result.health_checks += 1;
}
(DesiredPhase::Stopped { reason }, Some(WorkloadPhase::Executing(_))) => {
info!(
alloc_id = %desired_alloc.alloc_id,
reason = ?reason,
"convergence: executing → contracting"
);
result.contracted += 1;
}
(DesiredPhase::Stopped { .. }, Some(WorkloadPhase::Contracting(_))) => {
debug!(
alloc_id = %desired_alloc.alloc_id,
"convergence: contracting → checking drain"
);
}
(DesiredPhase::Stopped { .. }, Some(WorkloadPhase::Terminal(_))) => {}
(DesiredPhase::Active, Some(WorkloadPhase::Terminal(_))) => {
warn!(
alloc_id = %desired_alloc.alloc_id,
"desired Active but allocation is Terminal — scheduler should replace"
);
}
_ => {}
}
}
for (alloc_id, obs) in observed {
if obs.node_id != my_node {
continue;
}
if obs.phase.is_terminal() {
continue;
}
let is_desired = desired.iter().any(|d| d.alloc_id == *alloc_id);
if !is_desired {
info!(alloc_id = %alloc_id, "orphaned allocation detected");
result.orphans_detected += 1;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn make_desired(id: uuid::Uuid, phase: DesiredPhase) -> DesiredAllocationState {
DesiredAllocationState {
alloc_id: id,
job_id: "test-job".to_string(),
group_name: "main".to_string(),
node_id: "1".to_string(),
job_version: 1,
desired_phase: phase,
generation: 1,
}
}
fn make_observed(id: uuid::Uuid, phase: AllocationPhase) -> ObservedAllocationState {
ObservedAllocationState {
alloc_id: id,
node_id: "1".to_string(),
phase,
observed_at: chrono::Utc::now(),
observation_seq: 1,
}
}
fn make_ctx() -> ConvergenceContext {
ConvergenceContext {
local_node_id: 1,
probe_executor: Arc::new(ProbeExecutor::new()),
catalog_registry: Arc::new(CatalogRegistry::new()),
port_allocator: Arc::new(PortAllocator::default_range()),
volume_manager: Arc::new(VolumeManager::new("/tmp/test-volumes".into())),
secret_resolver: Arc::new(SecretResolver::new()),
nats_bus: Arc::new(NatsEventBus::disconnected()),
metrics: TataraMetrics::new(),
}
}
#[tokio::test]
async fn test_convergence_initial_to_warming() {
let ctx = make_ctx();
let id = uuid::Uuid::new_v4();
let desired = vec![make_desired(id, DesiredPhase::Active)];
let observed = HashMap::new();
let result = converge_tick(&ctx, &desired, &observed).await;
assert_eq!(result.warmed, 1);
}
#[tokio::test]
async fn test_convergence_executing_health_check() {
let ctx = make_ctx();
let id = uuid::Uuid::new_v4();
let desired = vec![make_desired(id, DesiredPhase::Active)];
let observed = HashMap::from([(
id,
make_observed(
id,
AllocationPhase::Executing(AllocExecuteDetail {
registered_in_catalog: true,
health: HealthStatus::Passing,
task_states: HashMap::new(),
}),
),
)]);
let result = converge_tick(&ctx, &desired, &observed).await;
assert_eq!(result.health_checks, 1);
}
#[tokio::test]
async fn test_convergence_stop_triggers_contraction() {
let ctx = make_ctx();
let id = uuid::Uuid::new_v4();
let desired = vec![make_desired(
id,
DesiredPhase::Stopped {
reason: ContractReason::Stopped,
},
)];
let observed = HashMap::from([(
id,
make_observed(
id,
AllocationPhase::Executing(AllocExecuteDetail {
registered_in_catalog: true,
health: HealthStatus::Passing,
task_states: HashMap::new(),
}),
),
)]);
let result = converge_tick(&ctx, &desired, &observed).await;
assert_eq!(result.contracted, 1);
}
#[tokio::test]
async fn test_convergence_orphan_detection() {
let ctx = make_ctx();
let orphan_id = uuid::Uuid::new_v4();
let desired = vec![]; let observed = HashMap::from([(
orphan_id,
make_observed(
orphan_id,
AllocationPhase::Executing(AllocExecuteDetail {
registered_in_catalog: false,
health: HealthStatus::Unknown,
task_states: HashMap::new(),
}),
),
)]);
let result = converge_tick(&ctx, &desired, &observed).await;
assert_eq!(result.orphans_detected, 1);
}
}