Skip to main content

aion_worker/protocol/
heartbeat.rs

1//! heartbeat frame send + heartbeat-timeout bookkeeping
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5use std::time::Instant;
6
7use aion_core::{ActivityId, WorkflowId};
8
9use crate::context::HeartbeatRequest;
10use crate::error::WorkerError;
11use crate::protocol::WorkerSession;
12
13/// In-memory liveness view for explicitly emitted activity heartbeats.
14///
15/// This bookkeeper is observability-only. It records the last successful local
16/// send time for in-flight activities, but the SDK never enforces heartbeat
17/// timeouts or fails activities for missing heartbeats; timeout ownership stays
18/// with the engine.
19#[derive(Clone, Debug, Default)]
20pub struct HeartbeatBookkeeper {
21    inner: Arc<Mutex<HashMap<ActivityExecutionKey, Option<Instant>>>>,
22}
23
24impl HeartbeatBookkeeper {
25    /// Marks an activity execution as in flight without recording a heartbeat
26    /// yet.
27    ///
28    /// # Errors
29    ///
30    /// Returns [`WorkerError`] if the in-memory bookkeeping mutex is poisoned.
31    pub fn register(&self, key: ActivityExecutionKey) -> Result<(), WorkerError> {
32        let mut last_heartbeats = self.lock_last_heartbeats()?;
33        last_heartbeats.entry(key).or_insert(None);
34        Ok(())
35    }
36
37    /// Removes bookkeeping for a completed activity execution.
38    ///
39    /// # Errors
40    ///
41    /// Returns [`WorkerError`] if the in-memory bookkeeping mutex is poisoned.
42    pub fn remove(&self, key: &ActivityExecutionKey) -> Result<(), WorkerError> {
43        let mut last_heartbeats = self.lock_last_heartbeats()?;
44        last_heartbeats.remove(key);
45        Ok(())
46    }
47
48    /// Returns the last successful local heartbeat send instant for an
49    /// activity execution.
50    #[must_use]
51    pub fn last_heartbeat(&self, key: &ActivityExecutionKey) -> Option<Instant> {
52        match self.inner.lock() {
53            Ok(last_heartbeats) => last_heartbeats.get(key).copied().flatten(),
54            Err(poisoned) => poisoned.into_inner().get(key).copied().flatten(),
55        }
56    }
57
58    fn record_sent(&self, key: ActivityExecutionKey, sent_at: Instant) -> Result<(), WorkerError> {
59        let mut last_heartbeats = self.lock_last_heartbeats()?;
60        last_heartbeats.insert(key, Some(sent_at));
61        Ok(())
62    }
63
64    fn lock_last_heartbeats(
65        &self,
66    ) -> Result<
67        std::sync::MutexGuard<'_, HashMap<ActivityExecutionKey, Option<Instant>>>,
68        WorkerError,
69    > {
70        self.inner
71            .lock()
72            .map_err(|_| WorkerError::registration(HeartbeatBookkeeperPoisoned))
73    }
74}
75
76/// Sends one explicit heartbeat request and updates local liveness bookkeeping
77/// after the transport accepts the frame.
78///
79/// # Errors
80///
81/// Returns [`WorkerError`] when the session send fails or bookkeeping cannot be
82/// updated.
83pub async fn send_heartbeat<S>(
84    session: &mut S,
85    bookkeeper: &HeartbeatBookkeeper,
86    request: HeartbeatRequest,
87) -> Result<(), WorkerError>
88where
89    S: WorkerSession,
90{
91    let key = ActivityExecutionKey::new(request.workflow_id.clone(), request.activity_id.clone());
92    session
93        .send_heartbeat(request.workflow_id, request.activity_id, request.detail)
94        .await?;
95    bookkeeper.record_sent(key, Instant::now())
96}
97
98#[derive(Debug, thiserror::Error)]
99#[error("heartbeat bookkeeper mutex was poisoned")]
100struct HeartbeatBookkeeperPoisoned;
101
102/// Key identifying one in-flight activity execution.
103#[derive(Clone, Debug, PartialEq, Eq, Hash)]
104pub struct ActivityExecutionKey {
105    /// Owning workflow id.
106    pub workflow_id: WorkflowId,
107    /// Activity id within the workflow.
108    pub activity_id: ActivityId,
109}
110
111impl ActivityExecutionKey {
112    /// Creates a key for an in-flight activity execution.
113    #[must_use]
114    pub const fn new(workflow_id: WorkflowId, activity_id: ActivityId) -> Self {
115        Self {
116            workflow_id,
117            activity_id,
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use std::collections::BTreeSet;
125    use std::time::Duration;
126
127    use aion_core::{ActivityError, ActivityId, ContentType, Payload, RunId, WorkflowId};
128    use async_trait::async_trait;
129    use futures::stream;
130
131    use super::{ActivityExecutionKey, HeartbeatBookkeeper, send_heartbeat};
132    use crate::WorkerConfig;
133    use crate::context::HeartbeatRequest;
134    use crate::error::WorkerError;
135    use crate::protocol::{WorkerSession, WorkerTaskStream, validate_activity_handlers};
136
137    #[derive(Debug, thiserror::Error)]
138    #[error("heartbeat timestamp was not recorded")]
139    struct MissingHeartbeatTimestamp;
140
141    #[derive(Default)]
142    struct FakeSession {
143        heartbeats: Vec<RecordedHeartbeat>,
144    }
145
146    #[derive(Clone, Debug, PartialEq, Eq)]
147    struct RecordedHeartbeat {
148        workflow_id: WorkflowId,
149        activity_id: ActivityId,
150        detail: Option<Payload>,
151    }
152
153    #[async_trait]
154    impl WorkerSession for FakeSession {
155        async fn handshake(&mut self, config: &WorkerConfig) -> Result<(), WorkerError> {
156            drop(config.clone());
157            Ok(())
158        }
159
160        async fn register(
161            &mut self,
162            activity_types: Vec<String>,
163            available_handlers: &BTreeSet<String>,
164        ) -> Result<(), WorkerError> {
165            validate_activity_handlers(&activity_types, available_handlers)
166        }
167
168        fn receive_tasks(&mut self) -> WorkerTaskStream {
169            Box::pin(stream::empty())
170        }
171
172        async fn report_result(
173            &mut self,
174            workflow_id: WorkflowId,
175            activity_id: ActivityId,
176            run_id: Option<RunId>,
177            result: Payload,
178        ) -> Result<(), WorkerError> {
179            drop((workflow_id, activity_id, run_id, result));
180            Ok(())
181        }
182
183        async fn report_failure(
184            &mut self,
185            workflow_id: WorkflowId,
186            activity_id: ActivityId,
187            run_id: Option<RunId>,
188            failure: ActivityError,
189        ) -> Result<(), WorkerError> {
190            drop((workflow_id, activity_id, run_id, failure));
191            Ok(())
192        }
193
194        async fn send_heartbeat(
195            &mut self,
196            workflow_id: WorkflowId,
197            activity_id: ActivityId,
198            progress: Option<Payload>,
199        ) -> Result<(), WorkerError> {
200            self.heartbeats.push(RecordedHeartbeat {
201                workflow_id,
202                activity_id,
203                detail: progress,
204            });
205            Ok(())
206        }
207    }
208
209    #[tokio::test]
210    async fn sends_explicit_heartbeats_and_preserves_detail() -> Result<(), WorkerError> {
211        let workflow_id = WorkflowId::new_v4();
212        let activity_id = ActivityId::from_sequence_position(7);
213        let detail = Payload::new(ContentType::Json, br#"{"progress":1}"#.to_vec());
214        let bookkeeper = HeartbeatBookkeeper::default();
215        let mut session = FakeSession::default();
216
217        send_heartbeat(
218            &mut session,
219            &bookkeeper,
220            HeartbeatRequest {
221                workflow_id: workflow_id.clone(),
222                activity_id: activity_id.clone(),
223                detail: Some(detail.clone()),
224            },
225        )
226        .await?;
227        send_heartbeat(
228            &mut session,
229            &bookkeeper,
230            HeartbeatRequest {
231                workflow_id: workflow_id.clone(),
232                activity_id: activity_id.clone(),
233                detail: Some(detail.clone()),
234            },
235        )
236        .await?;
237
238        assert_eq!(
239            session.heartbeats,
240            vec![
241                RecordedHeartbeat {
242                    workflow_id: workflow_id.clone(),
243                    activity_id: activity_id.clone(),
244                    detail: Some(detail.clone()),
245                },
246                RecordedHeartbeat {
247                    workflow_id,
248                    activity_id,
249                    detail: Some(detail.clone()),
250                },
251            ]
252        );
253        assert_eq!(detail.content_type(), &ContentType::Json);
254        Ok(())
255    }
256
257    #[tokio::test]
258    async fn last_heartbeat_timestamp_advances_on_each_send() -> Result<(), WorkerError> {
259        let workflow_id = WorkflowId::new_v4();
260        let activity_id = ActivityId::from_sequence_position(8);
261        let key = ActivityExecutionKey::new(workflow_id.clone(), activity_id.clone());
262        let bookkeeper = HeartbeatBookkeeper::default();
263        let mut session = FakeSession::default();
264
265        send_heartbeat(
266            &mut session,
267            &bookkeeper,
268            HeartbeatRequest {
269                workflow_id: workflow_id.clone(),
270                activity_id: activity_id.clone(),
271                detail: None,
272            },
273        )
274        .await?;
275        let first = bookkeeper.last_heartbeat(&key);
276        tokio::time::sleep(Duration::from_millis(1)).await;
277        send_heartbeat(
278            &mut session,
279            &bookkeeper,
280            HeartbeatRequest {
281                workflow_id,
282                activity_id: activity_id.clone(),
283                detail: None,
284            },
285        )
286        .await?;
287        let second = bookkeeper.last_heartbeat(&key);
288
289        let (Some(first), Some(second)) = (first, second) else {
290            return Err(WorkerError::decode(MissingHeartbeatTimestamp));
291        };
292        assert!(second > first);
293        Ok(())
294    }
295
296    #[tokio::test]
297    async fn colliding_sequence_positions_track_per_workflow() -> Result<(), WorkerError> {
298        let activity_id = ActivityId::from_sequence_position(3);
299        let workflow_a = WorkflowId::new_v4();
300        let workflow_b = WorkflowId::new_v4();
301        let key_a = ActivityExecutionKey::new(workflow_a.clone(), activity_id.clone());
302        let key_b = ActivityExecutionKey::new(workflow_b.clone(), activity_id.clone());
303        let bookkeeper = HeartbeatBookkeeper::default();
304        let mut session = FakeSession::default();
305
306        bookkeeper.register(key_a.clone())?;
307        bookkeeper.register(key_b.clone())?;
308
309        // record_sent for workflow A never touches workflow B's timestamp.
310        send_heartbeat(
311            &mut session,
312            &bookkeeper,
313            HeartbeatRequest {
314                workflow_id: workflow_a,
315                activity_id: activity_id.clone(),
316                detail: None,
317            },
318        )
319        .await?;
320        assert!(bookkeeper.last_heartbeat(&key_a).is_some());
321        assert!(bookkeeper.last_heartbeat(&key_b).is_none());
322
323        send_heartbeat(
324            &mut session,
325            &bookkeeper,
326            HeartbeatRequest {
327                workflow_id: workflow_b,
328                activity_id,
329                detail: None,
330            },
331        )
332        .await?;
333        let b_before_a_completes = bookkeeper.last_heartbeat(&key_b);
334        let Some(b_before_a_completes) = b_before_a_completes else {
335            return Err(WorkerError::decode(MissingHeartbeatTimestamp));
336        };
337
338        // Completing workflow A's activity leaves workflow B's entry intact.
339        bookkeeper.remove(&key_a)?;
340        assert!(bookkeeper.last_heartbeat(&key_a).is_none());
341        assert_eq!(
342            bookkeeper.last_heartbeat(&key_b),
343            Some(b_before_a_completes)
344        );
345        Ok(())
346    }
347}