aion_server/worker/attempt_progress.rs
1//! Reading the volatile worker progress note of one attempt — honestly.
2//!
3//! A worker heartbeat may carry an opaque progress payload ("what I am doing
4//! right now"). The server holds the most recent one per in-flight attempt in
5//! [`HeartbeatTracker`], in memory, and writes it nowhere. That makes the read
6//! path a measurement problem rather than a lookup: the absence of a note has
7//! two completely different causes, and collapsing them would tell an operator
8//! that a busy worker has gone silent every time the server was restarted.
9//!
10//! So the read returns [`AttemptProgress`], which never says "no note" without
11//! saying which kind of no:
12//!
13//! - [`AttemptProgress::Reported`] — the tracker holds a note for this attempt.
14//! - [`AttemptProgress::NoneSent`] — the tracker holds the ATTEMPT and no note
15//! on it. This is a measurement: the worker really has reported nothing.
16//! - [`AttemptProgress::Untracked`] — the tracker does not hold the attempt, so
17//! there is nothing to measure. A restart is the usual cause (the notes were
18//! in a process that no longer exists), and the other causes — the owner was
19//! swept off the tracker, or nothing has leased the dispatch — are equally
20//! unmeasurable from here. Every one of them is this variant; none of them is
21//! ever reported as silence.
22
23use aion_core::{ActivityId, Payload, WorkflowId};
24use chrono::{DateTime, Utc};
25
26use super::heartbeat::{HeartbeatTracker, TaskLiveness};
27use crate::error::ServerError;
28
29/// What this server process can say about one attempt's progress note.
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub enum AttemptProgress {
32 /// The worker serving this attempt reported this note, and this process
33 /// received it at `reported_at`.
34 Reported {
35 /// The worker's opaque progress payload, exactly as it was sent.
36 payload: Payload,
37 /// Wall-clock instant this process received the reporting heartbeat.
38 reported_at: DateTime<Utc>,
39 },
40 /// This process is tracking the attempt and no note has been reported on it.
41 NoneSent,
42 /// This process is not tracking the attempt, so it cannot say whether a
43 /// note was reported.
44 Untracked,
45}
46
47/// The progress note this process holds for `(workflow, activity, attempt)`.
48///
49/// When a within-attempt failover has both a dying owner and its adopter
50/// tracked, the entry with the most recent heartbeat wins: it is the one still
51/// being fed, and taking the other would report a note the live worker has
52/// already superseded. `last_heartbeat_at` is a monotonic [`std::time::Instant`]
53/// minted by this process, so comparing entries is meaningful even though the
54/// value itself would be meaningless to report.
55///
56/// # Errors
57///
58/// Returns [`ServerError::LockPoisoned`] when the tracker state cannot be read.
59/// An unreadable tracker is reported, never rendered as "no note" — that would
60/// be the exact confusion this module exists to prevent.
61pub fn attempt_progress(
62 tracker: &HeartbeatTracker,
63 workflow_id: &WorkflowId,
64 activity_id: &ActivityId,
65 attempt: u32,
66) -> Result<AttemptProgress, ServerError> {
67 let entries = tracker.attempt_entries(workflow_id, activity_id, attempt)?;
68 let Some(freshest) = freshest(entries) else {
69 return Ok(AttemptProgress::Untracked);
70 };
71 match (freshest.last_progress, freshest.last_progress_at) {
72 (Some(payload), Some(reported_at)) => Ok(AttemptProgress::Reported {
73 payload,
74 reported_at,
75 }),
76 // A note is stamped with its receive instant in the same assignment, so
77 // a note without a stamp is unreachable. It is handled as UNTRACKED
78 // rather than as `NoneSent` because a note that exists without a receive
79 // instant is a note this process cannot describe — reporting it as
80 // "nothing was sent" would be the one lie the type exists to prevent.
81 (Some(_payload), None) => Ok(AttemptProgress::Untracked),
82 (None, _) => Ok(AttemptProgress::NoneSent),
83 }
84}
85
86/// The tracked entry with the most recent heartbeat.
87fn freshest(entries: Vec<TaskLiveness>) -> Option<TaskLiveness> {
88 entries
89 .into_iter()
90 .max_by_key(|liveness| liveness.last_heartbeat_at)
91}
92
93#[cfg(test)]
94#[path = "attempt_progress_tests.rs"]
95mod tests;