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            completion_token: String,
178            result: Payload,
179        ) -> Result<(), WorkerError> {
180            drop((workflow_id, activity_id, run_id, completion_token, result));
181            Ok(())
182        }
183
184        async fn report_failure(
185            &mut self,
186            workflow_id: WorkflowId,
187            activity_id: ActivityId,
188            run_id: Option<RunId>,
189            completion_token: String,
190            failure: ActivityError,
191        ) -> Result<(), WorkerError> {
192            drop((workflow_id, activity_id, run_id, completion_token, failure));
193            Ok(())
194        }
195
196        async fn send_heartbeat(
197            &mut self,
198            workflow_id: WorkflowId,
199            activity_id: ActivityId,
200            progress: Option<Payload>,
201        ) -> Result<(), WorkerError> {
202            self.heartbeats.push(RecordedHeartbeat {
203                workflow_id,
204                activity_id,
205                detail: progress,
206            });
207            Ok(())
208        }
209    }
210
211    #[tokio::test]
212    async fn sends_explicit_heartbeats_and_preserves_detail() -> Result<(), WorkerError> {
213        let workflow_id = WorkflowId::new_v4();
214        let activity_id = ActivityId::from_sequence_position(7);
215        let detail = Payload::new(ContentType::Json, br#"{"progress":1}"#.to_vec());
216        let bookkeeper = HeartbeatBookkeeper::default();
217        let mut session = FakeSession::default();
218
219        send_heartbeat(
220            &mut session,
221            &bookkeeper,
222            HeartbeatRequest {
223                workflow_id: workflow_id.clone(),
224                activity_id: activity_id.clone(),
225                detail: Some(detail.clone()),
226            },
227        )
228        .await?;
229        send_heartbeat(
230            &mut session,
231            &bookkeeper,
232            HeartbeatRequest {
233                workflow_id: workflow_id.clone(),
234                activity_id: activity_id.clone(),
235                detail: Some(detail.clone()),
236            },
237        )
238        .await?;
239
240        assert_eq!(
241            session.heartbeats,
242            vec![
243                RecordedHeartbeat {
244                    workflow_id: workflow_id.clone(),
245                    activity_id: activity_id.clone(),
246                    detail: Some(detail.clone()),
247                },
248                RecordedHeartbeat {
249                    workflow_id,
250                    activity_id,
251                    detail: Some(detail.clone()),
252                },
253            ]
254        );
255        assert_eq!(detail.content_type(), &ContentType::Json);
256        Ok(())
257    }
258
259    #[tokio::test]
260    async fn last_heartbeat_timestamp_advances_on_each_send() -> Result<(), WorkerError> {
261        let workflow_id = WorkflowId::new_v4();
262        let activity_id = ActivityId::from_sequence_position(8);
263        let key = ActivityExecutionKey::new(workflow_id.clone(), activity_id.clone());
264        let bookkeeper = HeartbeatBookkeeper::default();
265        let mut session = FakeSession::default();
266
267        send_heartbeat(
268            &mut session,
269            &bookkeeper,
270            HeartbeatRequest {
271                workflow_id: workflow_id.clone(),
272                activity_id: activity_id.clone(),
273                detail: None,
274            },
275        )
276        .await?;
277        let first = bookkeeper.last_heartbeat(&key);
278        tokio::time::sleep(Duration::from_millis(1)).await;
279        send_heartbeat(
280            &mut session,
281            &bookkeeper,
282            HeartbeatRequest {
283                workflow_id,
284                activity_id: activity_id.clone(),
285                detail: None,
286            },
287        )
288        .await?;
289        let second = bookkeeper.last_heartbeat(&key);
290
291        let (Some(first), Some(second)) = (first, second) else {
292            return Err(WorkerError::decode(MissingHeartbeatTimestamp));
293        };
294        assert!(second > first);
295        Ok(())
296    }
297
298    #[tokio::test]
299    async fn colliding_sequence_positions_track_per_workflow() -> Result<(), WorkerError> {
300        let activity_id = ActivityId::from_sequence_position(3);
301        let workflow_a = WorkflowId::new_v4();
302        let workflow_b = WorkflowId::new_v4();
303        let key_a = ActivityExecutionKey::new(workflow_a.clone(), activity_id.clone());
304        let key_b = ActivityExecutionKey::new(workflow_b.clone(), activity_id.clone());
305        let bookkeeper = HeartbeatBookkeeper::default();
306        let mut session = FakeSession::default();
307
308        bookkeeper.register(key_a.clone())?;
309        bookkeeper.register(key_b.clone())?;
310
311        // record_sent for workflow A never touches workflow B's timestamp.
312        send_heartbeat(
313            &mut session,
314            &bookkeeper,
315            HeartbeatRequest {
316                workflow_id: workflow_a,
317                activity_id: activity_id.clone(),
318                detail: None,
319            },
320        )
321        .await?;
322        assert!(bookkeeper.last_heartbeat(&key_a).is_some());
323        assert!(bookkeeper.last_heartbeat(&key_b).is_none());
324
325        send_heartbeat(
326            &mut session,
327            &bookkeeper,
328            HeartbeatRequest {
329                workflow_id: workflow_b,
330                activity_id,
331                detail: None,
332            },
333        )
334        .await?;
335        let b_before_a_completes = bookkeeper.last_heartbeat(&key_b);
336        let Some(b_before_a_completes) = b_before_a_completes else {
337            return Err(WorkerError::decode(MissingHeartbeatTimestamp));
338        };
339
340        // Completing workflow A's activity leaves workflow B's entry intact.
341        bookkeeper.remove(&key_a)?;
342        assert!(bookkeeper.last_heartbeat(&key_a).is_none());
343        assert_eq!(
344            bookkeeper.last_heartbeat(&key_b),
345            Some(b_before_a_completes)
346        );
347        Ok(())
348    }
349}