use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use aion_core::{ActivityId, WorkflowId};
use super::registry::WorkerId;
use crate::error::ServerError;
pub const WORKER_LOST_REASON_PREFIX: &str = "lost:";
pub const TRANSPORT_EXHAUSTED_REASON_PREFIX: &str = "transport-exhausted:";
pub const TRANSPORT_LOSS_BUDGET_WINDOWS: u32 = 4;
#[must_use]
pub fn is_transport_domain_reason(reason: &str) -> bool {
reason.starts_with(WORKER_LOST_REASON_PREFIX)
|| reason.starts_with(TRANSPORT_EXHAUSTED_REASON_PREFIX)
}
#[must_use]
pub fn worker_lost_detail(worker_id: WorkerId) -> String {
format!("worker {worker_id:?} lost before reporting activity result")
}
#[derive(Clone, Debug)]
pub struct TransportLossLedger {
inner: Arc<Mutex<HashMap<ExecutionKey, LossRecord>>>,
budget: Duration,
}
impl Default for TransportLossLedger {
fn default() -> Self {
Self::new(Duration::ZERO)
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct ExecutionKey(WorkflowId, ActivityId);
#[derive(Clone, Copy, Debug)]
struct LossRecord {
first_loss_at: Instant,
losses: u32,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportLossVerdict {
pub reason: String,
pub losses: u32,
pub exhausted: bool,
}
impl TransportLossLedger {
#[must_use]
pub fn new(heartbeat_window: Duration) -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
budget: heartbeat_window.saturating_mul(TRANSPORT_LOSS_BUDGET_WINDOWS),
}
}
#[must_use]
pub const fn budget(&self) -> Duration {
self.budget
}
pub fn record_loss(
&self,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
detail: &str,
) -> Result<TransportLossVerdict, ServerError> {
self.record_loss_at(workflow_id, activity_id, detail, Instant::now())
}
fn record_loss_at(
&self,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
detail: &str,
now: Instant,
) -> Result<TransportLossVerdict, ServerError> {
let key = ExecutionKey(workflow_id.clone(), activity_id.clone());
let mut ledger = self.state()?;
let record = ledger.entry(key.clone()).or_insert(LossRecord {
first_loss_at: now,
losses: 0,
});
record.losses = record.losses.saturating_add(1);
let losses = record.losses;
let elapsed = now.saturating_duration_since(record.first_loss_at);
if elapsed > self.budget {
ledger.remove(&key);
return Ok(TransportLossVerdict {
reason: format!(
"{TRANSPORT_EXHAUSTED_REASON_PREFIX}the transport failed to deliver this \
activity for {}ms across {losses} worker losses, past its {}ms budget \
({TRANSPORT_LOSS_BUDGET_WINDOWS} heartbeat windows); the infrastructure is \
flapping, the activity never ran. Last loss: {detail}",
elapsed.as_millis(),
self.budget.as_millis()
),
losses,
exhausted: true,
});
}
Ok(TransportLossVerdict {
reason: format!("{WORKER_LOST_REASON_PREFIX}{detail}"),
losses,
exhausted: false,
})
}
pub fn clear(
&self,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
) -> Result<(), ServerError> {
self.state()?
.remove(&ExecutionKey(workflow_id.clone(), activity_id.clone()));
Ok(())
}
fn state(
&self,
) -> Result<std::sync::MutexGuard<'_, HashMap<ExecutionKey, LossRecord>>, ServerError> {
self.inner
.lock()
.map_err(|_| ServerError::lock_poisoned("transport loss ledger"))
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use aion_core::{ActivityId, WorkflowId};
use super::{
TRANSPORT_EXHAUSTED_REASON_PREFIX, TransportLossLedger, WORKER_LOST_REASON_PREFIX,
is_transport_domain_reason,
};
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn site() -> (WorkflowId, ActivityId) {
(WorkflowId::new_v4(), ActivityId::from_sequence_position(3))
}
#[test]
fn a_loss_inside_budget_is_the_redispatchable_class() -> TestResult {
let ledger = TransportLossLedger::new(Duration::from_secs(30));
let (workflow_id, activity_id) = site();
let verdict = ledger.record_loss(&workflow_id, &activity_id, "worker gone")?;
assert!(
verdict.reason.starts_with(WORKER_LOST_REASON_PREFIX),
"{verdict:?}"
);
assert!(!verdict.exhausted);
assert_eq!(verdict.losses, 1);
assert!(
!verdict.reason.starts_with("retryable:"),
"a transport death must never borrow the action's retry vocabulary: {verdict:?}"
);
Ok(())
}
#[test]
fn a_flapping_link_exhausts_its_budget_and_names_the_transport() -> TestResult {
let window = Duration::from_secs(30);
let ledger = TransportLossLedger::new(window);
let (workflow_id, activity_id) = site();
let start = Instant::now();
for step in 0..=4 {
let verdict = ledger.record_loss_at(
&workflow_id,
&activity_id,
"worker gone",
start + window * step,
)?;
assert!(!verdict.exhausted, "step {step}: {verdict:?}");
}
let verdict = ledger.record_loss_at(
&workflow_id,
&activity_id,
"worker gone",
start + window * 4 + Duration::from_millis(1),
)?;
assert!(verdict.exhausted, "{verdict:?}");
assert!(
verdict
.reason
.starts_with(TRANSPORT_EXHAUSTED_REASON_PREFIX),
"{verdict:?}"
);
assert!(
verdict.reason.contains("the infrastructure is flapping"),
"the terminal must let an operator tell infra from a red action: {verdict:?}"
);
assert_eq!(verdict.losses, 6);
let fresh = ledger.record_loss_at(
&workflow_id,
&activity_id,
"worker gone",
start + window * 4 + Duration::from_millis(2),
)?;
assert!(
!fresh.exhausted,
"a retired record starts a fresh budget: {fresh:?}"
);
Ok(())
}
#[test]
fn a_real_resolution_retires_the_budget() -> TestResult {
let window = Duration::from_secs(30);
let ledger = TransportLossLedger::new(window);
let (workflow_id, activity_id) = site();
let start = Instant::now();
ledger.record_loss_at(&workflow_id, &activity_id, "worker gone", start)?;
ledger.clear(&workflow_id, &activity_id)?;
let verdict = ledger.record_loss_at(
&workflow_id,
&activity_id,
"worker gone",
start + window * 10,
)?;
assert!(
!verdict.exhausted,
"a cleared site must not inherit a spent budget: {verdict:?}"
);
assert_eq!(verdict.losses, 1);
Ok(())
}
#[test]
fn budgets_are_per_execution_site() -> TestResult {
let ledger = TransportLossLedger::new(Duration::from_secs(30));
let (workflow_id, activity_id) = site();
let other = ActivityId::from_sequence_position(9);
ledger.record_loss(&workflow_id, &activity_id, "worker gone")?;
let verdict = ledger.record_loss(&workflow_id, &other, "worker gone")?;
assert_eq!(verdict.losses, 1, "a sibling ordinal has its own budget");
Ok(())
}
#[test]
fn transport_domain_recognition_covers_exactly_the_pair() {
assert!(is_transport_domain_reason("lost:worker WorkerId(2) lost"));
assert!(is_transport_domain_reason("transport-exhausted:flapping"));
assert!(!is_transport_domain_reason("retryable:upstream refused"));
assert!(!is_transport_domain_reason("terminal:boom"));
assert!(!is_transport_domain_reason("parked:server-draining"));
}
}