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}