1use 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#[derive(Clone, Debug, Default)]
20pub struct HeartbeatBookkeeper {
21 inner: Arc<Mutex<HashMap<ActivityExecutionKey, Option<Instant>>>>,
22}
23
24impl HeartbeatBookkeeper {
25 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 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 #[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
76pub 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
104pub struct ActivityExecutionKey {
105 pub workflow_id: WorkflowId,
107 pub activity_id: ActivityId,
109}
110
111impl ActivityExecutionKey {
112 #[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 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 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}