use std::sync::Arc;
use anyhow::Result;
use tokio::time::{Duration, interval};
use tracing::{error, info, warn};
use crate::activities::{failed_event_payload, settled_event_payload};
use crate::store::WorkflowStore;
use crate::types::{ActivitySettlement, SettleOutcome, WorkflowActivity, WorkflowStatus};
const HEALTH_CHECK_SECS: u64 = 30;
const WORKER_TIMEOUT_SECS: f64 = 90.0;
const RECONCILE_BATCH: i64 = 100;
pub async fn run_health_monitor<S: WorkflowStore>(store: Arc<S>) {
let mut tick = interval(Duration::from_secs(HEALTH_CHECK_SECS));
info!("Health monitor started (check every {HEALTH_CHECK_SECS}s)");
loop {
tick.tick().await;
if let Err(e) = check_health(&*store).await {
error!("Health monitor error: {e}");
}
}
}
async fn check_health<S: WorkflowStore>(store: &S) -> Result<()> {
check_health_at(store, timestamp_now()).await
}
pub async fn check_health_at<S: WorkflowStore>(store: &S, now: f64) -> Result<()> {
let cutoff = now - WORKER_TIMEOUT_SECS;
let dead_workers = store.remove_dead_workers(cutoff).await?;
for worker_id in &dead_workers {
warn!("Removed dead worker: {worker_id}");
}
let timed_out = store.get_timed_out_activities(now).await?;
for act in &timed_out {
let act_id = act.id.unwrap_or(-1);
if act.attempt < act.max_attempts {
let backoff = act.initial_interval_secs * act.backoff_coefficient.powi(act.attempt - 1);
store
.requeue_activity_for_retry(act_id, act.attempt + 1, now + backoff)
.await?;
warn!(
"Activity {} heartbeat timed out (attempt {}/{}) — re-queued with backoff",
act.name, act.attempt, act.max_attempts
);
} else {
store
.complete_activity(
act_id,
None,
Some("heartbeat timeout — max retries exhausted"),
true,
)
.await?;
store
.update_workflow_status(
&act.workflow_id,
WorkflowStatus::Failed,
None,
Some(&format!(
"Activity '{}' timed out after {} attempts",
act.name, act.max_attempts
)),
)
.await?;
warn!(
"Activity {} permanently failed — workflow {} marked FAILED",
act.name, act.workflow_id
);
}
}
reconcile_settled_activities(store).await?;
Ok(())
}
pub async fn reconcile_settled_activities<S: WorkflowStore>(store: &S) -> Result<usize> {
let candidates = store.list_unsettled_activities(RECONCILE_BATCH).await?;
let mut repaired = 0;
for act in candidates {
let Some(id) = act.id else { continue };
let failed = act.status == "FAILED";
let payload = settlement_payload(&act, id, failed);
let outcome = store
.settle_activity(&ActivitySettlement {
activity_id: id,
workflow_id: &act.workflow_id,
result: act.result.as_deref(),
error: act.error.as_deref(),
failed,
event_type: if failed {
"ActivityFailed"
} else {
"ActivityCompleted"
},
payload: &payload,
now: timestamp_now(),
})
.await?;
if outcome == SettleOutcome::Repaired {
repaired += 1;
warn!(
"Activity {} (id {id}) was settled without its history event — \
appended it and re-armed workflow {}",
act.name, act.workflow_id
);
}
}
Ok(repaired)
}
fn settlement_payload(act: &WorkflowActivity, id: i64, failed: bool) -> String {
if failed {
failed_event_payload(
id,
act.seq,
&act.name,
act.error.as_deref().unwrap_or("activity failed"),
act.attempt,
)
} else {
settled_event_payload(
id,
act.seq,
&act.name,
act.result.as_deref(),
act.error.as_deref(),
)
}
.to_string()
}
fn timestamp_now() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64()
}