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).
256///
257/// # Examples
258///
259/// ```
260/// # #[cfg(all(feature = "postgres", feature = "chrono"))]
261/// # mod demo {
262/// use std::time::{Duration, SystemTime, UNIX_EPOCH};
263///
264/// use chrono::{DateTime, Utc};
265/// use ruststream_sqlx::prelude::*;
266/// use ruststream_sqlx::Clock;
267/// use serde::Deserialize;
268/// use sqlx::PgPool;
269///
270/// /// The host's time in whole seconds, as the service's `DATETIME(0)` columns keep it.
271/// pub struct WholeSeconds;
272///
273/// impl Clock for WholeSeconds {
274///     fn now() -> SystemTime {
275///         let since = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
276///         UNIX_EPOCH + Duration::from_secs(since.as_secs())
277///     }
278/// }
279///
280/// #[derive(Inbox, sqlx::FromRow)]
281/// #[inbox(table = "reminder_jobs", clock = WholeSeconds)]
282/// pub struct Reminder {
283///     #[field(id, generated)]
284///     id: i64,
285///     #[field(retry_after, generated)]
286///     retry_after: DateTime<Utc>,
287///     #[field(payload)]
288///     payload: Vec<u8>,
289/// }
290///
291/// #[derive(Deserialize)]
292/// struct Note {
293///     text: String,
294/// }
295///
296/// #[subscriber(InboxQueue::<Reminder>::new("reminders"))]
297/// async fn remind(note: &Note) -> HandlerOutcome {
298///     tracing::info!(text = %note.text, "reminding");
299///     // Back in an hour, counted from `WholeSeconds`.
300///     HandlerOutcome::retry_after(Duration::from_secs(3600))
301/// }
302///
303/// pub fn app(pool: PgPool) -> RustStream {
304///     RustStream::new(AppInfo::new("reminders", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
305///         b.include(remind);
306///     })
307/// }
308/// # }
309/// # fn main() {}
310/// ```
311pub trait Clock: Send + Sync + 'static {
312    /// Now.
313    fn now() -> SystemTime;
314}
315
316/// Where a table reads "now": a [`Clock`] on the host, or [`DatabaseClock`].
317///
318/// # Examples
319///
320/// ```
321/// # #[cfg(all(feature = "postgres", feature = "chrono"))]
322/// # mod demo {
323/// use std::time::Duration;
324///
325/// use chrono::{DateTime, Utc};
326/// use ruststream_sqlx::prelude::*;
327/// use ruststream_sqlx::DatabaseClock;
328/// use serde::Deserialize;
329/// use sqlx::PgPool;
330///
331/// // Each table names where it reads "now": `Charge` the host's clock, the default, and
332/// // `MonthlyStatement` the database's.
333/// #[derive(Inbox, sqlx::FromRow)]
334/// #[inbox(table = "charge_jobs")]
335/// pub struct Charge {
336///     #[field(id, generated)]
337///     id: i64,
338///     #[field(retry_after, generated)]
339///     retry_after: DateTime<Utc>,
340///     #[field(payload)]
341///     payload: Vec<u8>,
342/// }
343///
344/// #[derive(Inbox, sqlx::FromRow)]
345/// #[inbox(table = "statement_jobs", clock = DatabaseClock)]
346/// pub struct MonthlyStatement {
347///     #[field(id, generated)]
348///     id: i64,
349///     #[field(retry_after, generated)]
350///     retry_after: DateTime<Utc>,
351///     #[field(payload)]
352///     payload: Vec<u8>,
353/// }
354///
355/// #[derive(Deserialize)]
356/// struct Account {
357///     id: u64,
358/// }
359///
360/// #[subscriber(InboxQueue::<Charge>::new("charges"))]
361/// async fn charge(account: &Account) -> HandlerOutcome {
362///     tracing::info!(account.id, "charging");
363///     HandlerOutcome::retry_after(Duration::from_secs(60))
364/// }
365///
366/// #[subscriber(InboxQueue::<MonthlyStatement>::new("statements"))]
367/// async fn send_statement(account: &Account) -> HandlerOutcome {
368///     tracing::info!(account.id, "sending the statement");
369///     HandlerOutcome::retry_after(Duration::from_secs(60))
370/// }
371///
372/// pub fn app(pool: PgPool) -> RustStream {
373///     RustStream::new(AppInfo::new("billing", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
374///         b.include(charge);
375///         b.include(send_statement);
376///     })
377/// }
378/// # }
379/// # fn main() {}
380/// ```
381pub trait TimeSource: Send + Sync + 'static {
382    /// Whether the statements read the database's own clock.
383    const DATABASE: bool;
384
385    /// Now, for a statement that binds it; `None` where the database reads its own.
386    ///
387    /// The crate turns it into each column's own type.
388    fn now() -> Option<SystemTime>;
389}
390
391impl<C: Clock> TimeSource for C {
392    const DATABASE: bool = false;
393
394    fn now() -> Option<SystemTime> {
395        Some(C::now())
396    }
397}
398
399/// The host's system clock: what a table reads unless it names another.
400///
401/// # Examples
402///
403/// ```
404/// # #[cfg(all(feature = "postgres", feature = "chrono"))]
405/// # mod demo {
406/// use std::time::{Duration, SystemTime};
407///
408/// use chrono::{DateTime, Utc};
409/// use ruststream_sqlx::prelude::*;
410/// use ruststream_sqlx::{Clock, SystemClock};
411/// use serde::Deserialize;
412/// use sqlx::PgPool;
413///
414/// /// The system clock five seconds ahead: a staging host replaying tomorrow's tasks a little early.
415/// pub struct Ahead;
416///
417/// impl Clock for Ahead {
418///     fn now() -> SystemTime {
419///         SystemClock::now() + Duration::from_secs(5)
420///     }
421/// }
422///
423/// #[derive(Inbox, sqlx::FromRow)]
424/// #[inbox(table = "replay_jobs", clock = Ahead)]
425/// pub struct Replay {
426///     #[field(id, generated)]
427///     id: i64,
428///     #[field(retry_after, generated)]
429///     retry_after: DateTime<Utc>,
430///     #[field(payload)]
431///     payload: Vec<u8>,
432/// }
433///
434/// #[derive(Deserialize)]
435/// struct Task {
436///     n: u32,
437/// }
438///
439/// #[subscriber(InboxQueue::<Replay>::new("replays"))]
440/// async fn replay(task: &Task) -> HandlerOutcome {
441///     tracing::info!(task.n, "replaying");
442///     HandlerOutcome::ack()
443/// }
444///
445/// pub fn app(pool: PgPool) -> RustStream {
446///     RustStream::new(AppInfo::new("staging", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
447///         b.include(replay);
448///     })
449/// }
450/// # }
451/// # fn main() {}
452/// ```
453#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
454pub struct SystemClock;
455
456impl Clock for SystemClock {
457    fn now() -> SystemTime {
458        SystemTime::now()
459    }
460}
461
462/// The database's own clock: `#[inbox(clock = DatabaseClock)]` makes the statements read it
463/// (`statement_timestamp()` on Postgres, `UTC_TIMESTAMP(6)` on MySQL) instead of binding the
464/// host's time.
465///
466/// A table in the lease form reads the host's clock: every settlement names the expiry its claim
467/// wrote, so the crate computes that expiry, and `#[field(locked_until)]` beside
468/// `clock = DatabaseClock` does not compile.
469///
470/// # Examples
471///
472/// ```
473/// # #[cfg(all(feature = "postgres", feature = "chrono"))]
474/// # mod demo {
475/// use std::time::Duration;
476///
477/// use chrono::{DateTime, Utc};
478/// use ruststream_sqlx::prelude::*;
479/// use ruststream_sqlx::DatabaseClock;
480/// use serde::Deserialize;
481/// use sqlx::PgPool;
482///
483/// /// Tasks written by hosts whose clocks drift: every claim and every delay reads one clock.
484/// #[derive(Inbox, sqlx::FromRow)]
485/// #[inbox(table = "sync_jobs", clock = DatabaseClock)]
486/// pub struct SyncJob {
487///     #[field(id, generated)]
488///     id: i64,
489///     #[field(retry_after, generated)]
490///     retry_after: DateTime<Utc>,
491///     #[field(payload)]
492///     payload: Vec<u8>,
493/// }
494///
495/// #[derive(Deserialize)]
496/// struct Change {
497///     record: u64,
498/// }
499///
500/// # async fn push(_: &Change) -> bool { true }
501/// #[subscriber(InboxQueue::<SyncJob>::new("sync"))]
502/// async fn sync(change: &Change) -> HandlerOutcome {
503///     if push(change).await {
504///         return HandlerOutcome::ack();
505///     }
506///     // Ten seconds by the database's clock, whichever host claimed the row.
507///     HandlerOutcome::retry_after(Duration::from_secs(10))
508/// }
509///
510/// pub fn app(pool: PgPool) -> RustStream {
511///     RustStream::new(AppInfo::new("sync", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
512///         b.include(sync);
513///     })
514/// }
515/// # }
516/// # fn main() {}
517/// ```
518#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
519pub struct DatabaseClock;
520
521impl TimeSource for DatabaseClock {
522    const DATABASE: bool = true;
523
524    fn now() -> Option<SystemTime> {
525        None
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    #[cfg(feature = "chrono")]
532    #[test]
533    fn chrono_times_move_forward_and_saturate() {
534        use std::time::{Duration, SystemTime};
535
536        use chrono::{DateTime, Utc};
537
538        use super::QueueTime;
539
540        let start = DateTime::<Utc>::from_system(SystemTime::UNIX_EPOCH);
541        assert_eq!(
542            start.after(Duration::from_millis(1500)).timestamp_millis(),
543            1500
544        );
545        assert_eq!(start.after(Duration::MAX), DateTime::<Utc>::MAX_UTC);
546    }
547
548    #[cfg(feature = "chrono")]
549    #[test]
550    fn chrono_times_round_up_to_the_next_whole_second() {
551        use chrono::{DateTime, TimeZone, Utc};
552
553        use super::QueueTime;
554
555        let noon = Utc
556            .with_ymd_and_hms(2026, 10, 5, 12, 0, 0)
557            .single()
558            .expect("a valid time");
559        assert_eq!(noon.rounded_up(), noon, "a whole second stays");
560        let later = noon + chrono::TimeDelta::microseconds(1);
561        assert_eq!(later.rounded_up(), noon + chrono::TimeDelta::seconds(1));
562        assert_eq!(
563            DateTime::<Utc>::MAX_UTC.rounded_up(),
564            DateTime::<Utc>::MAX_UTC,
565            "the saturated maximum stays"
566        );
567    }
568
569    #[cfg(feature = "time")]
570    #[test]
571    fn time_values_round_up_to_the_next_whole_second() {
572        use time::{Duration, OffsetDateTime, PrimitiveDateTime};
573
574        use super::QueueTime;
575
576        // 2026-10-05 12:00:00 UTC.
577        let noon = OffsetDateTime::from_unix_timestamp(1_791_201_600).expect("a valid time");
578        assert_eq!(noon.rounded_up(), noon, "a whole second stays");
579        let later = noon + Duration::microseconds(1);
580        assert_eq!(later.rounded_up(), noon + Duration::SECOND);
581        let max = PrimitiveDateTime::MAX.assume_utc();
582        assert_eq!(max.rounded_up(), max, "the saturated maximum stays");
583    }
584
585    #[cfg(feature = "time")]
586    #[test]
587    fn time_values_move_forward_and_saturate() {
588        use std::time::{Duration, SystemTime};
589
590        use time::{OffsetDateTime, PrimitiveDateTime};
591
592        use super::QueueTime;
593
594        let start = OffsetDateTime::from_system(SystemTime::UNIX_EPOCH);
595        assert_eq!(start.after(Duration::from_secs(90)).unix_timestamp(), 90);
596        assert_eq!(
597            start.after(Duration::MAX),
598            PrimitiveDateTime::MAX.assume_utc()
599        );
600    }
601}