1use std::future::Future;
11use std::time::Duration;
12
13use chrono::{DateTime, Utc};
14use tokio_util::sync::CancellationToken;
15
16use crate::{
17 TaskDispatchOutcome, TaskExecutionContext, TaskHeartbeatStore, TaskLease, TaskRecord,
18 TaskRetryClass, run_claimed_task_batch, spawn_task_heartbeat_with_interval,
19 stop_task_heartbeat,
20};
21
22pub trait ExecutableTaskRecord<Kind>: TaskRecord<Kind> {
24 fn attempt_count(&self) -> i32;
26
27 fn max_attempts(&self) -> i32;
29}
30
31pub struct TaskPermanentFailure<'a> {
33 pub attempt_count: i32,
35 pub storage_error: &'a str,
37 pub display_error: &'a str,
39 pub failed_steps_json: Option<&'a str>,
41 pub failure_can_retry: bool,
43 pub finished_at: DateTime<Utc>,
45}
46
47pub struct TaskRetryUpdate<'a> {
49 pub attempt_count: i32,
51 pub retry_at: DateTime<Utc>,
53 pub storage_error: &'a str,
55 pub display_error: &'a str,
57 pub failed_steps_json: Option<&'a str>,
59}
60
61#[async_trait::async_trait]
63pub trait ClaimedTaskExecutionStore<Task, Kind>: TaskHeartbeatStore + Clone + Send + Sync {
64 async fn process_task(
66 &self,
67 task: &Task,
68 context: TaskExecutionContext,
69 ) -> std::result::Result<(), Self::Error>;
70
71 fn is_lease_lost_error(&self, error: &Self::Error) -> bool;
73
74 fn is_lease_renewal_timed_out_error(&self, error: &Self::Error) -> bool;
76
77 fn is_worker_shutdown_requested_error(&self, error: &Self::Error) -> bool;
79
80 fn retry_class(&self, task: &Task, error: &Self::Error) -> TaskRetryClass;
82
83 fn storage_error(&self, error: &Self::Error) -> String;
85
86 fn display_error(&self, storage_error: &str) -> String;
88
89 async fn failed_steps_json(&self, task: &Task, display_error: &str) -> Option<String>;
91
92 async fn mark_task_failed(
94 &self,
95 task: &Task,
96 lease: TaskLease,
97 failure: TaskPermanentFailure<'_>,
98 ) -> std::result::Result<bool, Self::Error>;
99
100 async fn mark_task_retry(
102 &self,
103 task: &Task,
104 lease: TaskLease,
105 retry: TaskRetryUpdate<'_>,
106 ) -> std::result::Result<bool, Self::Error>;
107
108 async fn release_task_for_shutdown(
110 &self,
111 task: &Task,
112 lease: TaskLease,
113 ) -> std::result::Result<bool, Self::Error>;
114
115 fn record_task_transition(&self, task: &Task, status: &'static str);
117
118 fn wake_dispatcher(&self);
120}
121
122#[derive(Debug, Clone, Copy)]
124pub struct ClaimedTaskExecutionConfig<LeaseExpiresFn, RetryDelayFn> {
125 pub renewal_timeout: Duration,
127 pub heartbeat_interval: Duration,
129 pub lease_expires_at: LeaseExpiresFn,
131 pub retry_delay_secs: RetryDelayFn,
133}
134
135pub async fn run_claimed_task_batch_with_store<
141 Store,
142 Task,
143 Kind,
144 SortKey,
145 SortFn,
146 LeaseExpiresFn,
147 RetryDelayFn,
148>(
149 store: Store,
150 claimed_tasks: Vec<(Task, TaskLease)>,
151 sort_key: SortFn,
152 shutdown_token: CancellationToken,
153 config: ClaimedTaskExecutionConfig<LeaseExpiresFn, RetryDelayFn>,
154) -> std::result::Result<crate::DispatchStats, Store::Error>
155where
156 Store: ClaimedTaskExecutionStore<Task, Kind> + 'static,
157 Task: ExecutableTaskRecord<Kind> + Clone + Send + Sync + 'static,
158 Kind: Copy + std::fmt::Display + Send + Sync + 'static,
159 SortKey: Ord,
160 SortFn: FnMut(&(Task, TaskLease)) -> SortKey,
161 LeaseExpiresFn: Fn(DateTime<Utc>) -> DateTime<Utc> + Copy + Send + Sync + 'static,
162 RetryDelayFn: Fn(i32) -> i64 + Copy + Send + Sync + 'static,
163{
164 run_claimed_task_batch(claimed_tasks, sort_key, |(task, lease)| {
165 let store = store.clone();
166 let shutdown_token = shutdown_token.clone();
167 async move { process_claimed_task(store, task, lease, shutdown_token, config).await }
168 })
169 .await
170}
171
172#[expect(
178 clippy::too_many_lines,
179 reason = "Heartbeat shutdown, lease fencing, retry, and permanent failure form one claimed-task state transition."
180)]
181pub async fn process_claimed_task<Store, Task, Kind, LeaseExpiresFn, RetryDelayFn>(
182 store: Store,
183 task: Task,
184 lease: TaskLease,
185 shutdown_token: CancellationToken,
186 config: ClaimedTaskExecutionConfig<LeaseExpiresFn, RetryDelayFn>,
187) -> std::result::Result<TaskDispatchOutcome, Store::Error>
188where
189 Store: ClaimedTaskExecutionStore<Task, Kind> + 'static,
190 Task: ExecutableTaskRecord<Kind> + Send + Sync + 'static,
191 Kind: Copy + std::fmt::Display + Send + Sync + 'static,
192 LeaseExpiresFn: Fn(DateTime<Utc>) -> DateTime<Utc> + Copy + Send + Sync + 'static,
193 RetryDelayFn: Fn(i32) -> i64 + Copy + Send + Sync + 'static,
194{
195 let context = TaskExecutionContext::new(lease, config.renewal_timeout, shutdown_token);
196 let lease_guard = context.lease_guard().clone();
197 let heartbeat_stop = CancellationToken::new();
198 let heartbeat_handle = spawn_task_heartbeat_with_interval(
199 store.clone(),
200 lease_guard.clone(),
201 heartbeat_stop.clone(),
202 config.heartbeat_interval,
203 config.lease_expires_at,
204 );
205 let heartbeat_cancel_guard = heartbeat_stop.clone().drop_guard();
206
207 let task_result = match context.ensure_active() {
208 Ok(()) => store.process_task(&task, context).await,
209 Err(error) => Err(Store::Error::from(error)),
210 };
211 drop(heartbeat_cancel_guard);
212 stop_task_heartbeat(heartbeat_stop, heartbeat_handle).await;
213
214 match task_result {
215 Ok(()) => {
216 store.record_task_transition(&task, "succeeded");
217 Ok(TaskDispatchOutcome::succeeded())
218 }
219 Err(error)
220 if store.is_lease_lost_error(&error)
221 || store.is_lease_renewal_timed_out_error(&error)
222 || store.is_worker_shutdown_requested_error(&error) =>
223 {
224 if store.is_worker_shutdown_requested_error(&error)
225 && store.release_task_for_shutdown(&task, lease).await?
226 {
227 store.wake_dispatcher();
228 }
229 tracing::info!(
230 task_id = task.id(),
231 processing_token = lease.processing_token,
232 "background task worker stopped before completion; skipping stale completion"
233 );
234 Ok(TaskDispatchOutcome::default())
235 }
236 Err(error) => {
237 let attempt_count = task.attempt_count().saturating_add(1);
238 let storage_error = store.storage_error(&error);
239 let display_error = store.display_error(&storage_error);
240 let failed_steps_json = store.failed_steps_json(&task, &display_error).await;
241 let retry_class = store.retry_class(&task, &error);
242 let should_auto_retry =
243 retry_class.should_auto_retry() && attempt_count < task.max_attempts();
244
245 if should_auto_retry {
246 let retry_at = Utc::now()
247 + chrono::Duration::seconds((config.retry_delay_secs)(attempt_count));
248 let retried = store
249 .mark_task_retry(
250 &task,
251 lease,
252 TaskRetryUpdate {
253 attempt_count,
254 retry_at,
255 storage_error: &storage_error,
256 display_error: &display_error,
257 failed_steps_json: failed_steps_json.as_deref(),
258 },
259 )
260 .await?;
261 if !retried {
262 tracing::info!(
263 task_id = task.id(),
264 processing_token = lease.processing_token,
265 "background task lease moved before retry state update; ignoring stale worker"
266 );
267 return Ok(TaskDispatchOutcome::default());
268 }
269
270 tracing::warn!(
271 task_id = task.id(),
272 kind = %task.kind(),
273 attempt_count,
274 retry_at = %retry_at,
275 error = %display_error,
276 "background task failed; scheduled retry"
277 );
278 store.wake_dispatcher();
279 store.record_task_transition(&task, "retry");
280 Ok(TaskDispatchOutcome::retried())
281 } else {
282 let finished_at = Utc::now();
283 let failed = store
284 .mark_task_failed(
285 &task,
286 lease,
287 TaskPermanentFailure {
288 attempt_count,
289 storage_error: &storage_error,
290 display_error: &display_error,
291 failed_steps_json: failed_steps_json.as_deref(),
292 failure_can_retry: retry_class.can_manual_retry(),
293 finished_at,
294 },
295 )
296 .await?;
297 if !failed {
298 tracing::info!(
299 task_id = task.id(),
300 processing_token = lease.processing_token,
301 "background task lease moved before failure state update; ignoring stale worker"
302 );
303 return Ok(TaskDispatchOutcome::default());
304 }
305
306 tracing::warn!(
307 task_id = task.id(),
308 kind = %task.kind(),
309 attempt_count,
310 error = %display_error,
311 "background task permanently failed"
312 );
313 store.record_task_transition(&task, "failed");
314 Ok(TaskDispatchOutcome::failed())
315 }
316 }
317 }
318}
319
320pub fn boxed_task_future<'a, T, Error, Fut>(future: Fut) -> crate::TaskProcessFuture<'a, Error>
322where
323 Fut: Future<Output = std::result::Result<T, Error>> + Send + 'a,
324 Error: Send + 'a,
325{
326 Box::pin(async move {
327 future.await?;
328 Ok(())
329 })
330}
331
332#[cfg(test)]
333mod tests {
334 use std::collections::VecDeque;
335 use std::sync::{Arc, Mutex};
336 use std::time::Duration;
337
338 use chrono::{DateTime, Utc};
339 use tokio_util::sync::CancellationToken;
340
341 use super::{
342 ClaimedTaskExecutionConfig, ClaimedTaskExecutionStore, ExecutableTaskRecord,
343 TaskPermanentFailure, TaskRetryUpdate, process_claimed_task,
344 run_claimed_task_batch_with_store,
345 };
346 use crate::{
347 DispatchStats, TaskCoreError, TaskExecutionContext, TaskHeartbeatStore, TaskLease,
348 TaskRecord, TaskRetryClass, default_task_retry_delay_secs, task_lease_expires_at,
349 };
350
351 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
352 enum TestKind {
353 Example,
354 }
355
356 impl std::fmt::Display for TestKind {
357 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
358 formatter.write_str("example")
359 }
360 }
361
362 #[derive(Debug, Clone)]
363 struct TestTask {
364 id: i64,
365 attempt_count: i32,
366 max_attempts: i32,
367 order: i32,
368 }
369
370 impl TaskRecord<TestKind> for TestTask {
371 fn id(&self) -> i64 {
372 self.id
373 }
374
375 fn kind(&self) -> TestKind {
376 TestKind::Example
377 }
378
379 fn payload_json(&self) -> &'static str {
380 "{}"
381 }
382
383 fn result_json(&self) -> Option<&str> {
384 None
385 }
386 }
387
388 impl ExecutableTaskRecord<TestKind> for TestTask {
389 fn attempt_count(&self) -> i32 {
390 self.attempt_count
391 }
392
393 fn max_attempts(&self) -> i32 {
394 self.max_attempts
395 }
396 }
397
398 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
399 enum TestError {
400 #[error("{0}")]
401 Core(#[from] TaskCoreError),
402 #[error("{0}")]
403 Business(String),
404 }
405
406 #[derive(Debug, Default)]
407 struct StoreState {
408 process_results: VecDeque<std::result::Result<(), TestError>>,
409 failed: usize,
410 retried: usize,
411 released: usize,
412 wakes: usize,
413 transitions: Vec<&'static str>,
414 processed_order: Vec<i64>,
415 last_failed_steps: Option<String>,
416 }
417
418 #[derive(Clone)]
419 struct TestStore {
420 state: Arc<Mutex<StoreState>>,
421 }
422
423 impl TestStore {
424 fn with_results(results: Vec<std::result::Result<(), TestError>>) -> Self {
425 Self {
426 state: Arc::new(Mutex::new(StoreState {
427 process_results: results.into(),
428 ..StoreState::default()
429 })),
430 }
431 }
432
433 fn state(&self) -> std::sync::MutexGuard<'_, StoreState> {
434 self.state.lock().expect("store state should lock")
435 }
436 }
437
438 #[async_trait::async_trait]
439 impl TaskHeartbeatStore for TestStore {
440 type Error = TestError;
441
442 async fn touch_task_heartbeat(
443 &self,
444 _lease: TaskLease,
445 _now: DateTime<Utc>,
446 _lease_expires_at: DateTime<Utc>,
447 ) -> std::result::Result<bool, Self::Error> {
448 Ok(true)
449 }
450 }
451
452 #[async_trait::async_trait]
453 impl ClaimedTaskExecutionStore<TestTask, TestKind> for TestStore {
454 async fn process_task(
455 &self,
456 task: &TestTask,
457 _context: TaskExecutionContext,
458 ) -> std::result::Result<(), Self::Error> {
459 let mut state = self.state();
460 state.processed_order.push(task.id);
461 state.process_results.pop_front().unwrap_or(Ok(()))
462 }
463
464 fn is_lease_lost_error(&self, error: &Self::Error) -> bool {
465 matches!(error, TestError::Core(error) if error.is_task_lease_lost())
466 }
467
468 fn is_lease_renewal_timed_out_error(&self, error: &Self::Error) -> bool {
469 matches!(error, TestError::Core(error) if error.is_task_lease_renewal_timed_out())
470 }
471
472 fn is_worker_shutdown_requested_error(&self, error: &Self::Error) -> bool {
473 matches!(error, TestError::Core(error) if error.is_task_worker_shutdown_requested())
474 }
475
476 fn retry_class(&self, _task: &TestTask, error: &Self::Error) -> TaskRetryClass {
477 match error {
478 TestError::Business(message) if message == "never" => TaskRetryClass::Never,
479 _ => TaskRetryClass::Auto,
480 }
481 }
482
483 fn storage_error(&self, error: &Self::Error) -> String {
484 error.to_string()
485 }
486
487 fn display_error(&self, storage_error: &str) -> String {
488 storage_error.to_string()
489 }
490
491 async fn failed_steps_json(&self, _task: &TestTask, display_error: &str) -> Option<String> {
492 Some(format!("failed:{display_error}"))
493 }
494
495 async fn mark_task_failed(
496 &self,
497 _task: &TestTask,
498 _lease: TaskLease,
499 failure: TaskPermanentFailure<'_>,
500 ) -> std::result::Result<bool, Self::Error> {
501 let mut state = self.state();
502 state.failed += 1;
503 state.last_failed_steps = failure.failed_steps_json.map(str::to_string);
504 Ok(true)
505 }
506
507 async fn mark_task_retry(
508 &self,
509 _task: &TestTask,
510 _lease: TaskLease,
511 _retry: TaskRetryUpdate<'_>,
512 ) -> std::result::Result<bool, Self::Error> {
513 self.state().retried += 1;
514 Ok(true)
515 }
516
517 async fn release_task_for_shutdown(
518 &self,
519 _task: &TestTask,
520 _lease: TaskLease,
521 ) -> std::result::Result<bool, Self::Error> {
522 self.state().released += 1;
523 Ok(true)
524 }
525
526 fn record_task_transition(&self, _task: &TestTask, status: &'static str) {
527 self.state().transitions.push(status);
528 }
529
530 fn wake_dispatcher(&self) {
531 self.state().wakes += 1;
532 }
533 }
534
535 type TestExecutionConfig =
536 ClaimedTaskExecutionConfig<fn(DateTime<Utc>) -> DateTime<Utc>, fn(i32) -> i64>;
537
538 fn config() -> TestExecutionConfig {
539 ClaimedTaskExecutionConfig {
540 renewal_timeout: Duration::from_mins(1),
541 heartbeat_interval: Duration::from_mins(1),
542 lease_expires_at: |now| task_lease_expires_at(now, 60),
543 retry_delay_secs: default_task_retry_delay_secs,
544 }
545 }
546
547 fn task(id: i64, attempt_count: i32, max_attempts: i32) -> TestTask {
548 TestTask {
549 id,
550 attempt_count,
551 max_attempts,
552 order: i32::try_from(id).expect("test id should fit in i32"),
553 }
554 }
555
556 #[tokio::test]
557 async fn process_claimed_task_records_success() {
558 let store = TestStore::with_results(vec![Ok(())]);
559
560 let outcome = process_claimed_task(
561 store.clone(),
562 task(7, 0, 3),
563 TaskLease::new(7, 2),
564 CancellationToken::new(),
565 config(),
566 )
567 .await
568 .expect("task should succeed");
569
570 assert_eq!(outcome.succeeded, 1);
571 assert_eq!(store.state().transitions, vec!["succeeded"]);
572 }
573
574 #[tokio::test]
575 async fn process_claimed_task_retries_auto_failure_with_budget() {
576 let store = TestStore::with_results(vec![Err(TestError::Business("retry".to_string()))]);
577
578 let outcome = process_claimed_task(
579 store.clone(),
580 task(7, 0, 3),
581 TaskLease::new(7, 2),
582 CancellationToken::new(),
583 config(),
584 )
585 .await
586 .expect("task should retry");
587
588 assert_eq!(outcome.retried, 1);
589 let state = store.state();
590 assert_eq!(state.retried, 1);
591 assert_eq!(state.wakes, 1);
592 assert_eq!(state.transitions, vec!["retry"]);
593 }
594
595 #[tokio::test]
596 async fn process_claimed_task_fails_when_retry_budget_is_exhausted() {
597 let store = TestStore::with_results(vec![Err(TestError::Business("retry".to_string()))]);
598
599 let outcome = process_claimed_task(
600 store.clone(),
601 task(7, 2, 3),
602 TaskLease::new(7, 2),
603 CancellationToken::new(),
604 config(),
605 )
606 .await
607 .expect("task should fail permanently");
608
609 assert_eq!(outcome.failed, 1);
610 let state = store.state();
611 assert_eq!(state.failed, 1);
612 assert_eq!(state.last_failed_steps.as_deref(), Some("failed:retry"));
613 assert_eq!(state.transitions, vec!["failed"]);
614 }
615
616 #[tokio::test]
617 async fn process_claimed_task_releases_shutdown_without_failure() {
618 let lease = TaskLease::new(7, 2);
619 let store = TestStore::with_results(vec![Err(TestError::Core(
620 TaskCoreError::WorkerShutdownRequested {
621 task_id: lease.task_id,
622 processing_token: lease.processing_token,
623 },
624 ))]);
625
626 let outcome = process_claimed_task(
627 store.clone(),
628 task(7, 0, 3),
629 lease,
630 CancellationToken::new(),
631 config(),
632 )
633 .await
634 .expect("shutdown should release");
635
636 assert_eq!(outcome, crate::TaskDispatchOutcome::default());
637 let state = store.state();
638 assert_eq!(state.released, 1);
639 assert_eq!(state.wakes, 1);
640 assert_eq!(state.failed, 0);
641 assert_eq!(state.retried, 0);
642 assert!(state.transitions.is_empty());
643 }
644
645 #[tokio::test]
646 async fn batch_runner_sorts_and_aggregates_claimed_tasks() {
647 let store = TestStore::with_results(vec![Ok(()), Ok(())]);
648 let claimed = vec![
649 (task(2, 0, 3), TaskLease::new(2, 1)),
650 (task(1, 0, 3), TaskLease::new(1, 1)),
651 ];
652
653 let stats = run_claimed_task_batch_with_store(
654 store.clone(),
655 claimed,
656 |(task, _)| task.order,
657 CancellationToken::new(),
658 config(),
659 )
660 .await
661 .expect("batch should succeed");
662
663 assert_eq!(
664 stats,
665 DispatchStats {
666 claimed: 0,
667 succeeded: 2,
668 retried: 0,
669 failed: 0,
670 }
671 );
672 assert_eq!(store.state().processed_order, vec![1, 2]);
673 }
674}