use crate::runtime::{InFlightMap, RunLease};
use sqlx::PgPool;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
pub struct HeartbeatService {
pool: PgPool,
in_flight: InFlightMap,
interval: std::time::Duration,
batch_size: usize,
alive: Arc<AtomicBool>,
cancel: CancellationToken,
}
impl HeartbeatService {
pub(crate) fn new(
pool: PgPool,
in_flight: InFlightMap,
interval: std::time::Duration,
alive: Arc<AtomicBool>,
cancel: CancellationToken,
) -> Self {
Self {
pool,
in_flight,
interval,
batch_size: 500,
alive,
cancel,
}
}
#[tracing::instrument(skip(self), fields(interval_ms = self.interval.as_millis() as u64))]
pub async fn run(&self) {
self.alive.store(true, Ordering::SeqCst);
debug!(
interval_ms = self.interval.as_millis(),
"Heartbeat service started"
);
loop {
tokio::select! {
_ = self.cancel.cancelled() => {
debug!("Heartbeat service shutting down");
self.heartbeat_once().await;
break;
}
_ = tokio::time::sleep(self.interval) => {
self.heartbeat_once().await;
}
}
}
self.alive.store(false, Ordering::SeqCst);
}
#[tracing::instrument(skip(self))]
async fn heartbeat_once(&self) {
let all_keys: Vec<(i64, RunLease)> = self.in_flight.keys();
if all_keys.is_empty() {
return;
}
let pending_progress = self.in_flight.snapshot_pending_progress();
let progress_keys: std::collections::HashSet<(i64, i64)> = pending_progress
.iter()
.map(|(id, lease, _, _)| (*id, *lease))
.collect();
let heartbeat_only: Vec<(i64, RunLease)> = all_keys
.iter()
.filter(|key| !progress_keys.contains(key))
.copied()
.collect();
for chunk in heartbeat_only.chunks(self.batch_size) {
let job_ids: Vec<i64> = chunk.iter().map(|(job_id, _)| *job_id).collect();
let run_leases: Vec<i64> = chunk.iter().map(|(_, run_lease)| *run_lease).collect();
match sqlx::query(
r#"
UPDATE awa.jobs_hot AS jobs
SET heartbeat_at = now()
FROM unnest($1::bigint[], $2::bigint[]) AS inflight(id, run_lease)
WHERE jobs.id = inflight.id
AND jobs.run_lease = inflight.run_lease
AND jobs.state = 'running'
"#,
)
.bind(&job_ids)
.bind(&run_leases)
.execute(&self.pool)
.await
{
Ok(result) => {
debug!(
count = job_ids.len(),
updated = result.rows_affected(),
"Heartbeat batch sent"
);
}
Err(err) => {
warn!(error = %err, "Failed to send heartbeat batch");
}
}
}
if !pending_progress.is_empty() {
for chunk in pending_progress.chunks(self.batch_size) {
let job_ids: Vec<i64> = chunk.iter().map(|(id, _, _, _)| *id).collect();
let run_leases: Vec<i64> = chunk.iter().map(|(_, lease, _, _)| *lease).collect();
let progress_values: Vec<serde_json::Value> =
chunk.iter().map(|(_, _, _, p)| p.clone()).collect();
match sqlx::query(
r#"
UPDATE awa.jobs_hot AS jobs
SET heartbeat_at = now(),
progress = v.progress
FROM unnest($1::bigint[], $2::bigint[], $3::jsonb[]) AS v(id, run_lease, progress)
WHERE jobs.id = v.id
AND jobs.run_lease = v.run_lease
AND jobs.state = 'running'
"#,
)
.bind(&job_ids)
.bind(&run_leases)
.bind(&progress_values)
.execute(&self.pool)
.await
{
Ok(result) => {
debug!(
count = job_ids.len(),
updated = result.rows_affected(),
"Heartbeat+progress batch sent"
);
let acked: Vec<(i64, i64, u64)> = chunk
.iter()
.map(|(id, lease, gen, _)| (*id, *lease, *gen))
.collect();
self.in_flight.ack_progress(&acked);
}
Err(err) => {
warn!(error = %err, "Failed to send heartbeat+progress batch");
let failed: Vec<(i64, i64, u64)> = chunk
.iter()
.map(|(id, lease, gen, _)| (*id, *lease, *gen))
.collect();
self.in_flight.clear_in_flight_progress(&failed);
}
}
}
}
}
}