1use chrono::{DateTime, Utc};
8use tokio::task::JoinHandle;
9use tokio::time::MissedTickBehavior;
10use tokio_util::sync::CancellationToken;
11
12use crate::{TaskCoreError, TaskLease, TaskLeaseGuard};
13
14#[async_trait::async_trait]
16pub trait TaskHeartbeatStore: Send + Sync {
17 type Error: From<TaskCoreError> + std::fmt::Display + Send;
19
20 async fn touch_task_heartbeat(
26 &self,
27 lease: TaskLease,
28 now: DateTime<Utc>,
29 lease_expires_at: DateTime<Utc>,
30 ) -> std::result::Result<bool, Self::Error>;
31}
32
33pub async fn run_task_heartbeat_loop<Store, LeaseExpiresFn>(
35 store: Store,
36 lease_guard: TaskLeaseGuard,
37 stop_token: CancellationToken,
38 interval: std::time::Duration,
39 lease_expires_at: LeaseExpiresFn,
40) where
41 Store: TaskHeartbeatStore,
42 LeaseExpiresFn: Fn(DateTime<Utc>) -> DateTime<Utc> + Send + Sync,
43{
44 let mut heartbeat = tokio::time::interval(interval);
45 heartbeat.set_missed_tick_behavior(MissedTickBehavior::Delay);
46 heartbeat.tick().await;
47
48 loop {
49 tokio::select! {
50 _ = stop_token.cancelled() => return,
51 _ = heartbeat.tick() => {
52 let now = Utc::now();
53 let result = tokio::select! {
54 _ = stop_token.cancelled() => return,
55 result = store.touch_task_heartbeat(
56 lease_guard.lease(),
57 now,
58 lease_expires_at(now),
59 ) => result,
60 };
61
62 if evaluate_heartbeat_result(&lease_guard, result).is_err() {
63 return;
64 }
65 }
66 }
67 }
68}
69
70pub fn spawn_task_heartbeat_with_interval<Store, LeaseExpiresFn>(
72 store: Store,
73 lease_guard: TaskLeaseGuard,
74 stop_token: CancellationToken,
75 interval: std::time::Duration,
76 lease_expires_at: LeaseExpiresFn,
77) -> JoinHandle<()>
78where
79 Store: TaskHeartbeatStore + 'static,
80 LeaseExpiresFn: Fn(DateTime<Utc>) -> DateTime<Utc> + Send + Sync + 'static,
81{
82 tokio::spawn(async move {
83 run_task_heartbeat_loop(store, lease_guard, stop_token, interval, lease_expires_at).await;
84 })
85}
86
87pub fn evaluate_heartbeat_result<Error>(
89 lease_guard: &TaskLeaseGuard,
90 result: std::result::Result<bool, Error>,
91) -> std::result::Result<(), Error>
92where
93 Error: From<TaskCoreError> + std::fmt::Display,
94{
95 let lease = lease_guard.lease();
96 match result {
97 Ok(true) => {
98 lease_guard.record_renewed();
99 Ok(())
100 }
101 Ok(false) => {
102 tracing::info!(
103 task_id = lease.task_id,
104 processing_token = lease.processing_token,
105 "background task lease lost; stopping outdated worker"
106 );
107 Err(lease_guard.mark_lost().into())
108 }
109 Err(error) => {
110 tracing::warn!(
111 task_id = lease.task_id,
112 processing_token = lease.processing_token,
113 error = %error,
114 "background task heartbeat update failed; continuing and retrying next heartbeat"
115 );
116 lease_guard.ensure_active().map_err(Error::from)
117 }
118 }
119}
120
121pub async fn stop_task_heartbeat(stop_token: CancellationToken, heartbeat_handle: JoinHandle<()>) {
123 stop_token.cancel();
124 if let Err(error) = heartbeat_handle.await {
125 tracing::warn!(error = %error, "background task heartbeat worker stopped unexpectedly");
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use std::sync::Arc;
132 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
133 use std::time::Duration;
134
135 use chrono::{DateTime, Utc};
136 use tokio_util::sync::CancellationToken;
137
138 use super::{
139 TaskHeartbeatStore, evaluate_heartbeat_result, run_task_heartbeat_loop,
140 spawn_task_heartbeat_with_interval, stop_task_heartbeat,
141 };
142 use crate::{TaskCoreError, TaskLease, TaskLeaseGuard, task_lease_expires_at};
143
144 struct TestHeartbeatStore {
145 touches: Arc<AtomicUsize>,
146 should_match: Arc<AtomicBool>,
147 }
148
149 #[async_trait::async_trait]
150 impl TaskHeartbeatStore for TestHeartbeatStore {
151 type Error = TaskCoreError;
152
153 async fn touch_task_heartbeat(
154 &self,
155 _lease: TaskLease,
156 _now: DateTime<Utc>,
157 _lease_expires_at: DateTime<Utc>,
158 ) -> std::result::Result<bool, Self::Error> {
159 self.touches.fetch_add(1, Ordering::SeqCst);
160 Ok(self.should_match.load(Ordering::SeqCst))
161 }
162 }
163
164 #[test]
165 fn heartbeat_result_records_successful_renewal() {
166 let guard = TaskLeaseGuard::new(TaskLease::new(7, 2), Duration::from_secs(60));
167
168 evaluate_heartbeat_result::<TaskCoreError>(&guard, Ok(true))
169 .expect("heartbeat should renew");
170
171 guard.ensure_active().expect("lease should remain active");
172 }
173
174 #[test]
175 fn heartbeat_result_marks_lost_on_false_update() {
176 let guard = TaskLeaseGuard::new(TaskLease::new(7, 2), Duration::from_secs(60));
177
178 let error = evaluate_heartbeat_result::<TaskCoreError>(&guard, Ok(false))
179 .expect_err("false update should lose lease");
180
181 assert!(error.is_task_lease_lost());
182 assert!(
183 guard
184 .ensure_active()
185 .is_err_and(|error| error.is_task_lease_lost())
186 );
187 }
188
189 #[test]
190 fn heartbeat_result_keeps_retrying_transient_error_before_timeout() {
191 let guard = TaskLeaseGuard::new(TaskLease::new(7, 2), Duration::from_secs(60));
192
193 evaluate_heartbeat_result(
194 &guard,
195 Err(TaskCoreError::codec("database temporarily unavailable")),
196 )
197 .expect("transient error should keep lease alive before timeout");
198 }
199
200 #[test]
201 fn heartbeat_result_stops_after_renewal_timeout() {
202 let guard = TaskLeaseGuard::new(TaskLease::new(7, 2), Duration::ZERO);
203
204 let error = evaluate_heartbeat_result(
205 &guard,
206 Err(TaskCoreError::codec("database temporarily unavailable")),
207 )
208 .expect_err("timed-out lease should stop after transient error");
209
210 assert!(error.is_task_lease_renewal_timed_out());
211 }
212
213 #[tokio::test]
214 async fn heartbeat_loop_runs_until_stopped() {
215 let touches = Arc::new(AtomicUsize::new(0));
216 let store = TestHeartbeatStore {
217 touches: touches.clone(),
218 should_match: Arc::new(AtomicBool::new(true)),
219 };
220 let stop_token = CancellationToken::new();
221 let guard = TaskLeaseGuard::new(TaskLease::new(7, 2), Duration::from_secs(60));
222
223 let handle = tokio::spawn(run_task_heartbeat_loop(
224 store,
225 guard,
226 stop_token.clone(),
227 Duration::from_millis(1),
228 |now| task_lease_expires_at(now, 60),
229 ));
230
231 while touches.load(Ordering::SeqCst) == 0 {
232 tokio::task::yield_now().await;
233 }
234
235 stop_task_heartbeat(stop_token, handle).await;
236 assert!(touches.load(Ordering::SeqCst) >= 1);
237 }
238
239 #[tokio::test]
240 async fn spawned_heartbeat_worker_can_be_stopped() {
241 let touches = Arc::new(AtomicUsize::new(0));
242 let store = TestHeartbeatStore {
243 touches: touches.clone(),
244 should_match: Arc::new(AtomicBool::new(true)),
245 };
246 let stop_token = CancellationToken::new();
247 let guard = TaskLeaseGuard::new(TaskLease::new(7, 2), Duration::from_secs(60));
248
249 let handle = spawn_task_heartbeat_with_interval(
250 store,
251 guard,
252 stop_token.clone(),
253 Duration::from_millis(1),
254 |now| task_lease_expires_at(now, 60),
255 );
256
257 while touches.load(Ordering::SeqCst) == 0 {
258 tokio::task::yield_now().await;
259 }
260
261 stop_task_heartbeat(stop_token, handle).await;
262 assert!(touches.load(Ordering::SeqCst) >= 1);
263 }
264
265 #[tokio::test]
266 async fn heartbeat_loop_stops_when_persisted_lease_is_lost() {
267 let touches = Arc::new(AtomicUsize::new(0));
268 let store = TestHeartbeatStore {
269 touches: touches.clone(),
270 should_match: Arc::new(AtomicBool::new(false)),
271 };
272 let guard = TaskLeaseGuard::new(TaskLease::new(7, 2), Duration::from_secs(60));
273
274 run_task_heartbeat_loop(
275 store,
276 guard,
277 CancellationToken::new(),
278 Duration::from_millis(1),
279 |now| task_lease_expires_at(now, 60),
280 )
281 .await;
282
283 assert_eq!(touches.load(Ordering::SeqCst), 1);
284 }
285}