Skip to main content

ruststream_sqlx/inbox/
time.rs

1//! Time on a queue: the types a time column holds, and where "now" comes from.
2
3use std::fmt::Debug;
4use std::time::{Duration, SystemTime};
5
6use super::InboxRow;
7
8/// A time a queue column holds: `retry_after`, `processed_at` or `locked_until`.
9///
10/// The crate reads "now" and moves it forward in the column's own type, so sqlx encodes it
11/// exactly as the service writes times itself. `chrono::DateTime<Utc>` implements it under the
12/// `chrono` feature and `time::OffsetDateTime` under `time`.
13///
14/// SQLite keeps times as text, and the claim compares them as text. The text sqlx writes for a
15/// `chrono` time (RFC 3339 with `+00:00`) sorts as the times themselves. The text it writes for a
16/// `time` value ends in `Z` and drops the fraction's trailing zeros, so it sorts two times right
17/// only when they fall in different seconds: on SQLite a lease of such a table may end up to a
18/// second late, and a delayed retry may come back up to a second early or late.
19///
20/// # Examples
21///
22/// ```
23/// # #[cfg(all(feature = "postgres", feature = "chrono"))]
24/// # mod demo {
25/// use std::time::Duration;
26///
27/// use chrono::{DateTime, Utc};
28/// use ruststream_sqlx::prelude::*;
29/// use serde::Deserialize;
30/// use sqlx::PgPool;
31///
32/// // `retry_after` holds a `DateTime<Utc>`: the crate writes a delayed retry in that type, as the
33/// // service's own inserts write the column.
34/// #[derive(Inbox, sqlx::FromRow)]
35/// #[inbox(table = "webhook_jobs")]
36/// pub struct Webhook {
37///     #[field(id, generated)]
38///     id: i64,
39///     #[field(retry_after, generated)]
40///     retry_after: DateTime<Utc>,
41///     #[field(payload)]
42///     payload: Vec<u8>,
43/// }
44///
45/// #[derive(Deserialize)]
46/// struct Call {
47///     url: String,
48/// }
49///
50/// # async fn post(_: &str) -> bool { true }
51/// #[subscriber(InboxQueue::<Webhook>::new("webhooks"))]
52/// async fn notify(call: &Call) -> HandlerOutcome {
53///     if post(&call.url).await {
54///         return HandlerOutcome::ack();
55///     }
56///     HandlerOutcome::retry_after(Duration::from_secs(30))
57/// }
58///
59/// pub fn app(pool: PgPool) -> RustStream {
60///     RustStream::new(AppInfo::new("webhooks", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
61///         b.include(notify);
62///     })
63/// }
64/// # }
65/// # fn main() {}
66/// ```
67pub trait QueueTime: Copy + Debug + Send + Sync + 'static {
68    /// The time `at`, in this type.
69    fn from_system(at: SystemTime) -> Self;
70
71    /// This time moved `delay` later; the latest time the type holds when that overflows.
72    #[must_use]
73    fn after(self, delay: Duration) -> Self;
74
75    /// This time rounded up to the next whole second; a whole second stays, and so does the
76    /// latest time the type holds.
77    ///
78    /// A lease ends on a whole second: every temporal column stores one exactly, so the expiry a
79    /// claim writes reads back unchanged as the delivery's ownership token.
80    #[must_use]
81    fn rounded_up(self) -> Self;
82}
83
84#[cfg(feature = "chrono")]
85impl QueueTime for chrono::DateTime<chrono::Utc> {
86    fn from_system(at: SystemTime) -> Self {
87        Self::from(at)
88    }
89
90    fn after(self, delay: Duration) -> Self {
91        chrono::TimeDelta::from_std(delay)
92            .ok()
93            .and_then(|delta| self.checked_add_signed(delta))
94            .unwrap_or(Self::MAX_UTC)
95    }
96
97    fn rounded_up(self) -> Self {
98        if self.timestamp_subsec_nanos() == 0 {
99            return self;
100        }
101        chrono::Timelike::with_nanosecond(&self, 0)
102            .and_then(|whole| whole.checked_add_signed(chrono::TimeDelta::seconds(1)))
103            .unwrap_or(self)
104    }
105}
106
107#[cfg(feature = "time")]
108impl QueueTime for time::OffsetDateTime {
109    fn from_system(at: SystemTime) -> Self {
110        Self::from(at)
111    }
112
113    fn after(self, delay: Duration) -> Self {
114        self.checked_add(time::Duration::saturating_seconds_f64(delay.as_secs_f64()))
115            .unwrap_or_else(|| time::PrimitiveDateTime::MAX.assume_utc())
116    }
117
118    fn rounded_up(self) -> Self {
119        if self.nanosecond() == 0 {
120            return self;
121        }
122        self.replace_nanosecond(0)
123            .ok()
124            .and_then(|whole| whole.checked_add(time::Duration::SECOND))
125            .unwrap_or(self)
126    }
127}
128
129/// The type of a field playing a time role: a [`QueueTime`] or an `Option` of one.
130///
131/// # Examples
132///
133/// ```
134/// # #[cfg(all(feature = "postgres", feature = "chrono"))]
135/// # mod demo {
136/// use chrono::{DateTime, Utc};
137/// use ruststream_sqlx::prelude::*;
138/// use serde::Deserialize;
139/// use sqlx::PgPool;
140///
141/// // `processed_at` is empty until the row is finished; an acknowledgement writes a
142/// // `DateTime<Utc>` into it and the row stays in the table.
143/// #[derive(Inbox, sqlx::FromRow)]
144/// #[inbox(table = "invoice_jobs")]
145/// pub struct InvoiceJob {
146///     #[field(id, generated)]
147///     id: i64,
148///     #[field(processed_at, generated)]
149///     processed_at: Option<DateTime<Utc>>,
150///     #[field(payload)]
151///     payload: Vec<u8>,
152/// }
153///
154/// #[derive(Deserialize)]
155/// struct Invoice {
156///     number: u64,
157/// }
158///
159/// #[subscriber(InboxQueue::<InvoiceJob>::new("invoices"))]
160/// async fn issue(invoice: &Invoice) -> HandlerOutcome {
161///     tracing::info!(invoice.number, "issued");
162///     HandlerOutcome::ack()
163/// }
164///
165/// pub fn app(pool: PgPool) -> RustStream {
166///     RustStream::new(AppInfo::new("billing", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
167///         b.include(issue);
168///     })
169/// }
170/// # }
171/// # fn main() {}
172/// ```
173pub trait TimeColumn {
174    /// The time the column holds.
175    type Time: QueueTime;
176}
177
178impl<T: QueueTime> TimeColumn for T {
179    type Time = T;
180}
181
182impl<T: QueueTime> TimeColumn for Option<T> {
183    type Time = T;
184}
185
186/// A queue row taken by lease: its struct has a `#[field(locked_until)]` field.
187///
188/// A claim writes the lease's expiry into `locked_until` and commits at once, so the lease, not a
189/// transaction, holds the row while the handler runs. The expiry it wrote is the delivery's
190/// ownership token: a settlement takes effect only while the row still holds it. While the
191/// handler runs, the subscription extends the lease each half lease, and each extension's expiry
192/// becomes the token. The crate implements it for every table in the lease form, a struct with the
193/// field or a description that sets [`InboxSpec::lease`](crate::InboxSpec::lease), and a
194/// subscription of such a table can set its own lease
195/// ([`InboxQueue::lease`](crate::InboxQueue::lease)).
196///
197/// # Examples
198///
199/// ```
200/// # #[cfg(all(feature = "postgres", feature = "chrono"))]
201/// # mod demo {
202/// use std::time::Duration;
203///
204/// use chrono::{DateTime, Utc};
205/// use ruststream_sqlx::prelude::*;
206/// use serde::Deserialize;
207/// use sqlx::PgPool;
208///
209/// // `locked_until` makes `Report` a lease row: a claim writes the lease's expiry there and commits.
210/// #[derive(Inbox, sqlx::FromRow)]
211/// #[inbox(table = "report_jobs")]
212/// pub struct Report {
213///     #[field(id, generated)]
214///     id: i64,
215///     #[field(locked_until)]
216///     locked_until: Option<DateTime<Utc>>,
217///     #[field(payload)]
218///     payload: Vec<u8>,
219/// }
220///
221/// #[derive(Deserialize)]
222/// struct Request {
223///     month: u32,
224/// }
225///
226/// // A lease row's subscription sets its own lease; the handler may run for minutes without a
227/// // transaction open.
228/// #[subscriber(InboxQueue::<Report>::new("reports").lease(Duration::from_secs(300)))]
229/// async fn render(request: &Request) -> HandlerOutcome {
230///     tracing::info!(month = request.month, "rendering");
231///     HandlerOutcome::ack()
232/// }
233///
234/// pub fn app(pool: PgPool) -> RustStream {
235///     RustStream::new(AppInfo::new("reports", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
236///         b.include(render);
237///     })
238/// }
239/// # }
240/// # fn main() {}
241/// ```
242#[diagnostic::on_unimplemented(
243    message = "`{Self}` has no `locked_until` field, so its subscription holds no lease",
244    note = "add `#[field(locked_until)] locked_until: Option<..>` to take rows by lease (by hand: \
245            `.lease(..)` and `Lease<..>` in `type Table`)"
246)]
247pub trait LeaseRow: InboxRow {
248    /// The time `locked_until` holds: the lease's expiry, and the delivery's ownership token.
249    type Lease: QueueTime;
250}
251
252/// A clock on the host: what "now" is for the statements that bind it.
253///
254/// `#[inbox(clock = MyClock)]` makes a table read it. Hosts' clocks must agree to well within the
255/// delays the queue works with; keeping them in step is the service's concern (NTP). A test in
256/// process reads a clock of the service's own as production does: only [`SystemClock`] follows
257/// the test's clock.
258///
259/// # Examples
260///
261/// ```
262/// # #[cfg(all(feature = "postgres", feature = "chrono"))]
263/// # mod demo {
264/// use std::time::{Duration, SystemTime, UNIX_EPOCH};
265///
266/// use chrono::{DateTime, Utc};
267/// use ruststream_sqlx::prelude::*;
268/// use ruststream_sqlx::Clock;
269/// use serde::Deserialize;
270/// use sqlx::PgPool;
271///
272/// /// The host's time in whole seconds, as the service's `DATETIME(0)` columns keep it.
273/// pub struct WholeSeconds;
274///
275/// impl Clock for WholeSeconds {
276///     fn now() -> SystemTime {
277///         let since = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
278///         UNIX_EPOCH + Duration::from_secs(since.as_secs())
279///     }
280/// }
281///
282/// #[derive(Inbox, sqlx::FromRow)]
283/// #[inbox(table = "reminder_jobs", clock = WholeSeconds)]
284/// pub struct Reminder {
285///     #[field(id, generated)]
286///     id: i64,
287///     #[field(retry_after, generated)]
288///     retry_after: DateTime<Utc>,
289///     #[field(payload)]
290///     payload: Vec<u8>,
291/// }
292///
293/// #[derive(Deserialize)]
294/// struct Note {
295///     text: String,
296/// }
297///
298/// #[subscriber(InboxQueue::<Reminder>::new("reminders"))]
299/// async fn remind(note: &Note) -> HandlerOutcome {
300///     tracing::info!(text = %note.text, "reminding");
301///     // Back in an hour, counted from `WholeSeconds`.
302///     HandlerOutcome::retry_after(Duration::from_secs(3600))
303/// }
304///
305/// pub fn app(pool: PgPool) -> RustStream {
306///     RustStream::new(AppInfo::new("reminders", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
307///         b.include(remind);
308///     })
309/// }
310/// # }
311/// # fn main() {}
312/// ```
313pub trait Clock: Send + Sync + 'static {
314    /// Now.
315    fn now() -> SystemTime;
316}
317
318/// Where a table reads "now": a [`Clock`] on the host, or [`DatabaseClock`].
319///
320/// # Examples
321///
322/// ```
323/// # #[cfg(all(feature = "postgres", feature = "chrono"))]
324/// # mod demo {
325/// use std::time::Duration;
326///
327/// use chrono::{DateTime, Utc};
328/// use ruststream_sqlx::prelude::*;
329/// use ruststream_sqlx::DatabaseClock;
330/// use serde::Deserialize;
331/// use sqlx::PgPool;
332///
333/// // Each table names where it reads "now": `Charge` the host's clock, the default, and
334/// // `MonthlyStatement` the database's.
335/// #[derive(Inbox, sqlx::FromRow)]
336/// #[inbox(table = "charge_jobs")]
337/// pub struct Charge {
338///     #[field(id, generated)]
339///     id: i64,
340///     #[field(retry_after, generated)]
341///     retry_after: DateTime<Utc>,
342///     #[field(payload)]
343///     payload: Vec<u8>,
344/// }
345///
346/// #[derive(Inbox, sqlx::FromRow)]
347/// #[inbox(table = "statement_jobs", clock = DatabaseClock)]
348/// pub struct MonthlyStatement {
349///     #[field(id, generated)]
350///     id: i64,
351///     #[field(retry_after, generated)]
352///     retry_after: DateTime<Utc>,
353///     #[field(payload)]
354///     payload: Vec<u8>,
355/// }
356///
357/// #[derive(Deserialize)]
358/// struct Account {
359///     id: u64,
360/// }
361///
362/// #[subscriber(InboxQueue::<Charge>::new("charges"))]
363/// async fn charge(account: &Account) -> HandlerOutcome {
364///     tracing::info!(account.id, "charging");
365///     HandlerOutcome::retry_after(Duration::from_secs(60))
366/// }
367///
368/// #[subscriber(InboxQueue::<MonthlyStatement>::new("statements"))]
369/// async fn send_statement(account: &Account) -> HandlerOutcome {
370///     tracing::info!(account.id, "sending the statement");
371///     HandlerOutcome::retry_after(Duration::from_secs(60))
372/// }
373///
374/// pub fn app(pool: PgPool) -> RustStream {
375///     RustStream::new(AppInfo::new("billing", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
376///         b.include(charge);
377///         b.include(send_statement);
378///     })
379/// }
380/// # }
381/// # fn main() {}
382/// ```
383pub trait TimeSource: Send + Sync + 'static {
384    /// Whether the statements read the database's own clock.
385    const DATABASE: bool;
386
387    /// Now, for a statement that binds it; `None` where the database reads its own.
388    ///
389    /// The crate turns it into each column's own type.
390    fn now() -> Option<SystemTime>;
391}
392
393impl<C: Clock> TimeSource for C {
394    const DATABASE: bool = false;
395
396    fn now() -> Option<SystemTime> {
397        Some(C::now())
398    }
399}
400
401/// The host's system clock: what a table reads unless it names another.
402///
403/// # Examples
404///
405/// ```
406/// # #[cfg(all(feature = "postgres", feature = "chrono"))]
407/// # mod demo {
408/// use std::time::{Duration, SystemTime};
409///
410/// use chrono::{DateTime, Utc};
411/// use ruststream_sqlx::prelude::*;
412/// use ruststream_sqlx::{Clock, SystemClock};
413/// use serde::Deserialize;
414/// use sqlx::PgPool;
415///
416/// /// The system clock five seconds ahead: a staging host replaying tomorrow's tasks a little early.
417/// pub struct Ahead;
418///
419/// impl Clock for Ahead {
420///     fn now() -> SystemTime {
421///         SystemClock::now() + Duration::from_secs(5)
422///     }
423/// }
424///
425/// #[derive(Inbox, sqlx::FromRow)]
426/// #[inbox(table = "replay_jobs", clock = Ahead)]
427/// pub struct Replay {
428///     #[field(id, generated)]
429///     id: i64,
430///     #[field(retry_after, generated)]
431///     retry_after: DateTime<Utc>,
432///     #[field(payload)]
433///     payload: Vec<u8>,
434/// }
435///
436/// #[derive(Deserialize)]
437/// struct Task {
438///     n: u32,
439/// }
440///
441/// #[subscriber(InboxQueue::<Replay>::new("replays"))]
442/// async fn replay(task: &Task) -> HandlerOutcome {
443///     tracing::info!(task.n, "replaying");
444///     HandlerOutcome::ack()
445/// }
446///
447/// pub fn app(pool: PgPool) -> RustStream {
448///     RustStream::new(AppInfo::new("staging", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
449///         b.include(replay);
450///     })
451/// }
452/// # }
453/// # fn main() {}
454/// ```
455#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
456pub struct SystemClock;
457
458impl Clock for SystemClock {
459    fn now() -> SystemTime {
460        SystemTime::now()
461    }
462}
463
464/// The database's own clock: `#[inbox(clock = DatabaseClock)]` makes the statements read it
465/// (`statement_timestamp()` on Postgres, `UTC_TIMESTAMP(6)` on MySQL) instead of binding the
466/// host's time.
467///
468/// A table in the lease form reads the host's clock: every settlement names the expiry its claim
469/// wrote, so the crate computes that expiry, and `#[field(locked_until)]` beside
470/// `clock = DatabaseClock` does not compile.
471///
472/// # Examples
473///
474/// ```
475/// # #[cfg(all(feature = "postgres", feature = "chrono"))]
476/// # mod demo {
477/// use std::time::Duration;
478///
479/// use chrono::{DateTime, Utc};
480/// use ruststream_sqlx::prelude::*;
481/// use ruststream_sqlx::DatabaseClock;
482/// use serde::Deserialize;
483/// use sqlx::PgPool;
484///
485/// /// Tasks written by hosts whose clocks drift: every claim and every delay reads one clock.
486/// #[derive(Inbox, sqlx::FromRow)]
487/// #[inbox(table = "sync_jobs", clock = DatabaseClock)]
488/// pub struct SyncJob {
489///     #[field(id, generated)]
490///     id: i64,
491///     #[field(retry_after, generated)]
492///     retry_after: DateTime<Utc>,
493///     #[field(payload)]
494///     payload: Vec<u8>,
495/// }
496///
497/// #[derive(Deserialize)]
498/// struct Change {
499///     record: u64,
500/// }
501///
502/// # async fn push(_: &Change) -> bool { true }
503/// #[subscriber(InboxQueue::<SyncJob>::new("sync"))]
504/// async fn sync(change: &Change) -> HandlerOutcome {
505///     if push(change).await {
506///         return HandlerOutcome::ack();
507///     }
508///     // Ten seconds by the database's clock, whichever host claimed the row.
509///     HandlerOutcome::retry_after(Duration::from_secs(10))
510/// }
511///
512/// pub fn app(pool: PgPool) -> RustStream {
513///     RustStream::new(AppInfo::new("sync", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
514///         b.include(sync);
515///     })
516/// }
517/// # }
518/// # fn main() {}
519/// ```
520#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
521pub struct DatabaseClock;
522
523impl TimeSource for DatabaseClock {
524    const DATABASE: bool = true;
525
526    fn now() -> Option<SystemTime> {
527        None
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    #[cfg(feature = "chrono")]
534    #[test]
535    fn chrono_times_move_forward_and_saturate() {
536        use std::time::{Duration, SystemTime};
537
538        use chrono::{DateTime, Utc};
539
540        use super::QueueTime;
541
542        let start = DateTime::<Utc>::from_system(SystemTime::UNIX_EPOCH);
543        assert_eq!(
544            start.after(Duration::from_millis(1500)).timestamp_millis(),
545            1500
546        );
547        assert_eq!(start.after(Duration::MAX), DateTime::<Utc>::MAX_UTC);
548    }
549
550    #[cfg(feature = "chrono")]
551    #[test]
552    fn chrono_times_round_up_to_the_next_whole_second() {
553        use chrono::{DateTime, TimeZone, Utc};
554
555        use super::QueueTime;
556
557        let noon = Utc
558            .with_ymd_and_hms(2026, 10, 5, 12, 0, 0)
559            .single()
560            .expect("a valid time");
561        assert_eq!(noon.rounded_up(), noon, "a whole second stays");
562        let later = noon + chrono::TimeDelta::microseconds(1);
563        assert_eq!(later.rounded_up(), noon + chrono::TimeDelta::seconds(1));
564        assert_eq!(
565            DateTime::<Utc>::MAX_UTC.rounded_up(),
566            DateTime::<Utc>::MAX_UTC,
567            "the saturated maximum stays"
568        );
569    }
570
571    #[cfg(feature = "time")]
572    #[test]
573    fn time_values_round_up_to_the_next_whole_second() {
574        use time::{Duration, OffsetDateTime, PrimitiveDateTime};
575
576        use super::QueueTime;
577
578        // 2026-10-05 12:00:00 UTC.
579        let noon = OffsetDateTime::from_unix_timestamp(1_791_201_600).expect("a valid time");
580        assert_eq!(noon.rounded_up(), noon, "a whole second stays");
581        let later = noon + Duration::microseconds(1);
582        assert_eq!(later.rounded_up(), noon + Duration::SECOND);
583        let max = PrimitiveDateTime::MAX.assume_utc();
584        assert_eq!(max.rounded_up(), max, "the saturated maximum stays");
585    }
586
587    #[cfg(feature = "time")]
588    #[test]
589    fn time_values_move_forward_and_saturate() {
590        use std::time::{Duration, SystemTime};
591
592        use time::{OffsetDateTime, PrimitiveDateTime};
593
594        use super::QueueTime;
595
596        let start = OffsetDateTime::from_system(SystemTime::UNIX_EPOCH);
597        assert_eq!(start.after(Duration::from_secs(90)).unix_timestamp(), 90);
598        assert_eq!(
599            start.after(Duration::MAX),
600            PrimitiveDateTime::MAX.assume_utc()
601        );
602    }
603}