zeph_durable/timer.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Durable timers: wakes that survive a process restart.
5//!
6//! [`DurableContext::sleep_until`](crate::DurableContext::sleep_until) journals a `durable_timers`
7//! row at a deterministic [`TimerId`](crate::TimerId) for its program position and parks until the
8//! instant arrives. The [`DurableTimerService`] is a background task that polls the backend for due
9//! timers, marks them fired, and wakes their parked waiters.
10//!
11//! # Restart semantics (FR-DE-06)
12//!
13//! A timer is persisted with its `due_at`, so a process that was down when the instant elapsed
14//! recovers correctly: on the first poll after restart the service sees the timer's `due_at` is in
15//! the past and fires it immediately. The awaiting execution, replaying to the same `sleep_until`
16//! call, re-derives the timer id, finds it already fired, and returns at once instead of sleeping
17//! again.
18
19use std::sync::Arc;
20use std::time::Duration;
21
22use tracing::Instrument as _;
23
24use crate::backend::DurableBackendEnum;
25use crate::backend::local::now_unix_millis;
26
27/// Background task that fires durable timers whose instant has arrived.
28///
29/// Spawn [`DurableTimerService::run`] on a supervised task. It owns no timer state of its own — the
30/// `durable_timers` table is the source of truth — so it is safe to stop and restart: a restarted
31/// service re-reads due timers and fires any that elapsed while it was down.
32#[derive(Debug)]
33pub struct DurableTimerService {
34 backend: Arc<DurableBackendEnum>,
35 poll_interval: Duration,
36}
37
38impl DurableTimerService {
39 /// Build the service from the shared backend and a poll cadence.
40 ///
41 /// `poll_interval` is the worst-case latency between a timer's instant and its firing; the
42 /// `promise_poll_interval_secs` config value (default 2 s) is the natural source.
43 ///
44 /// # Examples
45 ///
46 /// ```no_run
47 /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
48 /// use std::sync::Arc;
49 /// use std::time::Duration;
50 /// use zeph_durable::{DurableBackendEnum, DurableTimerService, LocalBackend};
51 ///
52 /// let backend = Arc::new(DurableBackendEnum::Local(Arc::new(
53 /// LocalBackend::open("durable.db", 1_048_576).await?,
54 /// )));
55 /// let service = DurableTimerService::new(backend, Duration::from_secs(2));
56 /// let task = tokio::spawn(service.run());
57 /// # let _ = task;
58 /// # Ok(()) }
59 /// ```
60 #[must_use]
61 pub fn new(backend: Arc<DurableBackendEnum>, poll_interval: Duration) -> Self {
62 Self {
63 backend,
64 // Tokio's interval panics on a zero period; clamp to at least 1 ms.
65 poll_interval: poll_interval.max(Duration::from_millis(1)),
66 }
67 }
68
69 /// Run the timer poll loop until the task is aborted.
70 ///
71 /// Each tick fires every timer whose `due_at` has elapsed (including, on the first tick after a
72 /// restart, timers that came due during downtime — FR-DE-06). A backend error is logged and the
73 /// loop continues so a transient failure does not strand future timers.
74 #[tracing::instrument(name = "durable.timer.run", skip_all)]
75 pub async fn run(self) {
76 let mut tick = tokio::time::interval(self.poll_interval);
77 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
78 loop {
79 tick.tick().await;
80 self.fire_due()
81 .instrument(tracing::info_span!("durable.timer.run.iter"))
82 .await;
83 }
84 }
85
86 /// Fire every currently-due timer once. Exposed for a deterministic test poll.
87 #[tracing::instrument(name = "durable.timer.fire_due", skip_all)]
88 pub(crate) async fn fire_due(&self) {
89 let now = now_unix_millis();
90 let due = match self.backend.due_timers(now).await {
91 Ok(due) => due,
92 Err(error) => {
93 tracing::warn!(%error, "durable timer poll failed; will retry next tick");
94 return;
95 }
96 };
97 for timer in due {
98 match self.backend.mark_timer_fired(timer).await {
99 Ok(true) => tracing::debug!(timer_id = %timer.as_uuid(), "durable timer fired"),
100 // Already fired by a concurrent poll — nothing to do.
101 Ok(false) => {}
102 Err(error) => {
103 tracing::warn!(%error, timer_id = %timer.as_uuid(), "failed to mark timer fired");
104 }
105 }
106 }
107 }
108}