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}