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 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 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 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}