Skip to main content

ruststream_sqlx/inbox/
queue.rs

1//! `InboxQueue`: the subscription descriptor, and the startup work of opening one.
2
3use std::any::type_name;
4use std::borrow::Cow;
5use std::fmt;
6use std::marker::PhantomData;
7use std::time::Duration;
8
9#[cfg(feature = "asyncapi")]
10use ruststream::asyncapi::{Binding, Bindings};
11use ruststream::{BrokerMoves, DeclareRetryError, RetryDeclaration, SubscriptionSource};
12#[cfg(feature = "asyncapi")]
13use ruststream_sqlx_dialect::Role;
14use ruststream_sqlx_dialect::{Dialect, Opens};
15#[cfg(feature = "asyncapi")]
16use serde::Serialize;
17
18use super::broker::ConnectedSqlxBroker;
19use super::database::QueueDatabase;
20use super::engine::Events;
21use super::error::SqlxBrokerError;
22#[cfg(feature = "asyncapi")]
23use super::publish::table_of;
24use super::subscriber::InboxSubscriber;
25use super::threads::InboxThreads;
26use super::time::LeaseRow;
27use super::transactional::{InboxMode, Plain, Transactional};
28use super::{FormOn, InboxRow};
29
30mod build;
31mod check;
32mod description;
33mod open;
34
35pub(crate) use description::{Description, Timing, refused_declaration};
36pub use open::Queue;
37pub(crate) use open::{Registration, open};
38
39/// A subscription to a queue table: the rows of `Row` that the name addresses.
40///
41/// With a `group` field the name selects the group; without one the table is a single queue and
42/// the name is its address. By default rows are claimed with `FOR UPDATE SKIP LOCKED` in a
43/// transaction held for the whole handler: acknowledgement is the delete (or the `processed_at`
44/// mark) and the commit, a retry counts the attempt and commits, and after a crash the database
45/// rolls back and the row returns at once. That transaction opens at the table's `isolation`
46/// where the struct declares one, and a subscription to a table at a level the broker's dialect
47/// does not open ([`Opens`](crate::dialect::Opens)) does not compile.
48///
49/// Mounted with [`transactional`](crate::InboxSettings::transactional), a subscription lends its
50/// handler a transaction to write through, in every form: acknowledgement commits the handler's
51/// writes with the row's settlement, and every other outcome discards them first. A row lock
52/// subscription lends the claim's transaction.
53///
54/// A table with a `#[field(locked_until)]` field is claimed by lease instead: the claim writes the
55/// lease's expiry into the row, counts the attempt and commits at once, so the lease, not a
56/// transaction, holds the row while the handler runs. While the handler runs, the subscription
57/// extends the lease each half lease. Each settlement is one statement that takes effect only
58/// while the row still holds the lease, a delivery dropped unsettled releases its row at once,
59/// and after a crash the row returns once the lease runs out. The lease is the broker's
60/// ([`SqlxBroker::lease`](crate::SqlxBroker::lease)) unless the subscription sets its own
61/// ([`lease`](Self::lease)). The broker's dialect serves a table's form when it implements the
62/// form's trait: [`RowLock`](crate::dialect::RowLock) for the row lock form,
63/// [`Lease`](crate::dialect::Lease) for the lease form, [`Advisory`](crate::dialect::Advisory) for
64/// the advisory lock form. SQLite has no row locks, so a table there takes the lease form or the
65/// advisory lock form, and a subscription to one without `locked_until` or `advisory_lock` does not
66/// compile.
67///
68/// `max_attempts(n)` and `dead_letter(..)` at the mount site map onto the table, declared together:
69/// at the cap the row moves to the `dead_letter` group (with a `group` field) or into the
70/// `dead_letter` table (one with the same columns). With `max_attempts(1)` every failure moves the
71/// row at once. A registration that declares one without the other stops at startup.
72///
73/// A table in payload mode hands its handler the payload, decoded by a codec. A table in row mode,
74/// whose struct has no `#[field(payload)]` field, hands it the row itself: `&Row` for one
75/// delivery, `&[Row]` for a batch (see [Row mode](crate#row-mode)). The forms, transactional mode
76/// and the retry declarations work the same in both modes.
77///
78/// # Examples
79///
80/// ```
81/// # #[cfg(all(feature = "postgres", feature = "chrono"))]
82/// # mod demo {
83/// use chrono::{DateTime, Utc};
84/// use ruststream::prelude::*;
85/// use ruststream_sqlx::{Inbox, InboxQueue, SqlxBroker};
86/// use serde::Deserialize;
87/// use sqlx::PgPool;
88///
89/// #[derive(Inbox, sqlx::FromRow)]
90/// #[inbox(table = "email_jobs")]
91/// pub struct SendEmail {
92///     #[field(id, generated)]
93///     job_id: i64,
94///     #[field(group)]
95///     name: String,
96///     #[field(retry_after)]
97///     retry_after: DateTime<Utc>,
98///     #[field(attempt)]
99///     attempt: i16,
100///     #[field(payload)]
101///     payload: Vec<u8>,
102/// }
103///
104/// #[derive(Deserialize)]
105/// struct Email {
106///     to: String,
107/// }
108///
109/// #[subscriber(InboxQueue::<SendEmail>::new("emails"))]
110/// async fn send(email: &Email) -> HandlerOutcome {
111///     tracing::info!(to = %email.to, "sending");
112///     HandlerOutcome::ack()
113/// }
114///
115/// pub fn app(pool: PgPool) -> RustStream {
116///     RustStream::new(AppInfo::new("mailer", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
117///         // Five attempts, then the row moves to the `emails.failed` group.
118///         b.include(send).max_attempts(nonzero!(5u32)).dead_letter("emails.failed");
119///     })
120/// }
121/// # }
122/// # fn main() {}
123/// ```
124///
125/// In row mode the handler takes the row the subscription claimed:
126///
127/// ```
128/// # #[cfg(feature = "postgres")]
129/// # mod demo {
130/// use ruststream_sqlx::prelude::*;
131/// use sqlx::PgPool;
132///
133/// /// A page to send to the engineer on call: the row itself is the task.
134/// #[derive(Inbox, sqlx::FromRow, Clone)]
135/// #[inbox(table = "page_jobs")]
136/// pub struct Page {
137///     #[field(id, generated)]
138///     id: i64,
139///     #[field(group)]
140///     name: String,
141///     #[field(attempt, generated)]
142///     attempt: i16,
143///     on_call: String,
144///     text: String,
145/// }
146///
147/// # async fn send_page(_: &str, _: &str) -> bool { true }
148/// #[subscriber(InboxQueue::<Page>::new("pages"))]
149/// async fn notify(page: &Page) -> HandlerOutcome {
150///     if send_page(&page.on_call, &page.text).await {
151///         HandlerOutcome::ack()
152///     } else {
153///         HandlerOutcome::retry()
154///     }
155/// }
156///
157/// pub fn app(pool: PgPool) -> RustStream {
158///     RustStream::new(AppInfo::new("pager", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
159///         // Three attempts, then the row moves to the `pages.failed` group.
160///         b.include(notify).max_attempts(nonzero!(3u32)).dead_letter("pages.failed");
161///     })
162/// }
163/// # }
164/// # fn main() {}
165/// ```
166pub struct InboxQueue<Row, Mode = Plain> {
167    name: Cow<'static, str>,
168    poll_interval: Option<Duration>,
169    lease: Option<Duration>,
170    declaration: RetryDeclaration,
171    /// The dedicated threads `on_threads` mounted it on, with their pools' size.
172    threads: Option<InboxThreads>,
173    _row: PhantomData<fn() -> (Row, Mode)>,
174}
175
176impl<Row> InboxQueue<Row> {
177    /// A subscription to the queue `name` of `Row`'s table, in the plain mode: the handler leaves
178    /// the delivery's transaction alone. The mount-site step
179    /// [`transactional`](crate::InboxSettings::transactional) switches it to transactional mode.
180    ///
181    /// # Examples
182    ///
183    /// ```
184    /// # #[cfg(feature = "postgres")]
185    /// # mod demo {
186    /// use ruststream_sqlx::prelude::*;
187    /// use serde::Deserialize;
188    /// use sqlx::PgPool;
189    ///
190    /// #[derive(Inbox, sqlx::FromRow)]
191    /// #[inbox(table = "email_jobs")]
192    /// pub struct SendEmail {
193    ///     #[field(id, generated)]
194    ///     job_id: i64,
195    ///     #[field(group)]
196    ///     name: String,
197    ///     #[field(payload)]
198    ///     payload: Vec<u8>,
199    /// }
200    ///
201    /// #[derive(Deserialize)]
202    /// struct Email {
203    ///     to: String,
204    /// }
205    ///
206    /// // `emails` selects the rows of group `emails`; a table without a group column would deliver all
207    /// // its rows here.
208    /// #[subscriber(InboxQueue::<SendEmail>::new("emails"))]
209    /// async fn send(email: &Email) -> HandlerOutcome {
210    ///     tracing::info!(to = %email.to, "sending");
211    ///     HandlerOutcome::ack()
212    /// }
213    ///
214    /// pub fn app(pool: PgPool) -> RustStream {
215    ///     RustStream::new(AppInfo::new("mailer", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
216    ///         b.include(send);
217    ///     })
218    /// }
219    /// # }
220    /// # fn main() {}
221    /// ```
222    #[must_use]
223    pub fn new(name: impl Into<Cow<'static, str>>) -> Self {
224        Self {
225            name: name.into(),
226            poll_interval: None,
227            lease: None,
228            declaration: RetryDeclaration::new(),
229            threads: None,
230            _row: PhantomData,
231        }
232    }
233
234    /// How long this subscription waits between claims that found the queue empty; the broker's
235    /// interval unless set. After a full claim the next one runs at once. A publish through the
236    /// broker into the subscription's table and group ends the wait early
237    /// ([waking a subscription](crate#waking-a-subscription)).
238    ///
239    /// # Examples
240    ///
241    /// ```
242    /// # #[cfg(feature = "postgres")]
243    /// # mod demo {
244    /// use std::time::Duration;
245    ///
246    /// use ruststream_sqlx::prelude::*;
247    /// use serde::Deserialize;
248    /// use sqlx::PgPool;
249    ///
250    /// #[derive(Inbox, sqlx::FromRow)]
251    /// #[inbox(table = "alert_jobs")]
252    /// pub struct Alert {
253    ///     #[field(id, generated)]
254    ///     id: i64,
255    ///     #[field(payload)]
256    ///     payload: Vec<u8>,
257    /// }
258    ///
259    /// #[derive(Deserialize)]
260    /// struct Page {
261    ///     on_call: String,
262    /// }
263    ///
264    /// // An urgent queue looks again every 50 ms when it runs dry; the broker's other queues keep theirs.
265    /// #[subscriber(InboxQueue::<Alert>::new("alerts").poll_interval(Duration::from_millis(50)))]
266    /// async fn notify(page: &Page) -> HandlerOutcome {
267    ///     tracing::warn!(on_call = %page.on_call, "paging");
268    ///     HandlerOutcome::ack()
269    /// }
270    ///
271    /// pub fn app(pool: PgPool) -> RustStream {
272    ///     RustStream::new(AppInfo::new("alerts", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
273    ///         b.include(notify);
274    ///     })
275    /// }
276    /// # }
277    /// # fn main() {}
278    /// ```
279    #[must_use]
280    pub const fn poll_interval(mut self, interval: Duration) -> Self {
281        self.poll_interval = Some(interval);
282        self
283    }
284
285    /// How long a claim of this subscription leases each row; the broker's lease
286    /// ([`SqlxBroker::lease`](crate::SqlxBroker::lease)) unless set.
287    ///
288    /// A lease is whole seconds, at least one: a shorter one is rounded up. The subscription
289    /// extends the lease of every delivery in work each half lease, so a handler may outlast it;
290    /// the lease is how long a row stays out of the queue after its process crashed. A lease runs
291    /// out under a running handler only when its extensions fail or stop (the database out of
292    /// reach, the subscription closed, the broker shut down), and the late settlement then fails
293    /// with [`SqlxBrokerError::LeaseLost`]. Only a table with a `#[field(locked_until)]` field
294    /// takes a lease; on any other the call does not compile.
295    ///
296    /// # Examples
297    ///
298    /// ```
299    /// # #[cfg(all(feature = "postgres", feature = "chrono"))]
300    /// # mod demo {
301    /// use std::time::Duration;
302    ///
303    /// use chrono::{DateTime, Utc};
304    /// use ruststream::prelude::*;
305    /// use ruststream_sqlx::{Inbox, InboxQueue, SqlxBroker};
306    /// use serde::Deserialize;
307    /// use sqlx::PgPool;
308    ///
309    /// #[derive(Inbox, sqlx::FromRow)]
310    /// #[inbox(table = "report_jobs")]
311    /// pub struct Report {
312    ///     #[field(id, generated)]
313    ///     id: i64,
314    ///     #[field(locked_until)]
315    ///     locked_until: Option<DateTime<Utc>>,
316    ///     #[field(payload)]
317    ///     payload: Vec<u8>,
318    /// }
319    ///
320    /// #[derive(Deserialize)]
321    /// struct Request {
322    ///     month: u32,
323    /// }
324    ///
325    /// // The report of a renderer that crashed waits out a two-minute lease before another
326    /// // process takes it.
327    /// #[subscriber(InboxQueue::<Report>::new("reports").lease(Duration::from_secs(120)))]
328    /// async fn render(request: &Request) -> HandlerOutcome {
329    ///     tracing::info!(month = request.month, "rendering");
330    ///     HandlerOutcome::ack()
331    /// }
332    ///
333    /// pub fn app(pool: PgPool) -> RustStream {
334    ///     RustStream::new(AppInfo::new("reports", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
335    ///         b.include(render);
336    ///     })
337    /// }
338    /// # }
339    /// # fn main() {}
340    /// ```
341    #[must_use]
342    pub const fn lease(mut self, lease: Duration) -> Self
343    where
344        Row: LeaseRow,
345    {
346        self.lease = Some(lease);
347        self
348    }
349
350    /// The same subscription in transactional mode: what the mount-site step
351    /// [`transactional`](crate::InboxSettings::transactional) makes of it.
352    pub(crate) fn into_transactional(self) -> InboxQueue<Row, Transactional> {
353        InboxQueue {
354            name: self.name,
355            poll_interval: self.poll_interval,
356            lease: self.lease,
357            declaration: self.declaration,
358            threads: self.threads,
359            _row: PhantomData,
360        }
361    }
362}
363
364impl<Row, Mode> InboxQueue<Row, Mode> {
365    /// The same subscription on `threads`: what the mount-site step
366    /// [`on_threads`](crate::InboxSettings::on_threads) records, beside the dispatch it sets.
367    pub(crate) const fn on_threads(mut self, threads: InboxThreads) -> Self {
368        self.threads = Some(threads);
369        self
370    }
371}
372
373impl<Row, Mode> Clone for InboxQueue<Row, Mode> {
374    fn clone(&self) -> Self {
375        Self {
376            name: self.name.clone(),
377            poll_interval: self.poll_interval,
378            lease: self.lease,
379            declaration: self.declaration.clone(),
380            threads: self.threads,
381            _row: PhantomData,
382        }
383    }
384}
385
386impl<Row, Mode: InboxMode> fmt::Debug for InboxQueue<Row, Mode> {
387    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
388        f.debug_struct("InboxQueue")
389            .field("row", &type_name::<Row>())
390            .field("transactional", &Mode::TRANSACTIONAL)
391            .field("name", &self.name)
392            .field("poll_interval", &self.poll_interval)
393            .field("lease", &self.lease)
394            .field("declaration", &self.declaration)
395            .field("threads", &self.threads)
396            .finish()
397    }
398}
399
400impl<DB, D, Row, Mode> SubscriptionSource<ConnectedSqlxBroker<DB, D>> for InboxQueue<Row, Mode>
401where
402    DB: QueueDatabase,
403    D: Dialect + Opens<Row::Opening> + 'static,
404    Row: InboxRow + Events<DB>,
405    Row::Form: FormOn<D>,
406    Mode: InboxMode,
407{
408    type Subscriber = InboxSubscriber<DB, Row, Mode>;
409    type Copies = BrokerMoves;
410
411    fn name(&self) -> &str {
412        &self.name
413    }
414
415    async fn subscribe(
416        self,
417        connected: &ConnectedSqlxBroker<DB, D>,
418    ) -> Result<Self::Subscriber, SqlxBrokerError> {
419        open::<DB, Row, Mode>(
420            &connected.shared,
421            &<Row::Form as FormOn<D>>::erase(&connected.dialect),
422            &self.name,
423            Timing {
424                poll_interval: self.poll_interval,
425                lease: self.lease,
426                threads: self.threads,
427            },
428            &self.declaration,
429            &Description::of::<DB, Row>(),
430        )
431        .await
432    }
433
434    fn declare_retry(mut self, declaration: &RetryDeclaration) -> Self {
435        self.declaration = declaration.clone();
436        self
437    }
438
439    #[cfg(feature = "asyncapi")]
440    fn channel_bindings(&self) -> Bindings {
441        let group = Row::SPEC.column(Role::Group).map(|_| self.name.as_ref());
442        let table = TableBinding {
443            table: table_of(&Row::SPEC),
444            group,
445        };
446        // A binding that fails to build is a binding the document goes without.
447        Binding::extension("x-sqlx", &table)
448            .map_or_else(|_| Bindings::new(), |binding| Bindings::new().with(binding))
449    }
450
451    fn declare_retry_on(
452        &self,
453        _connected: &ConnectedSqlxBroker<DB, D>,
454        declaration: &RetryDeclaration,
455    ) -> Result<(), DeclareRetryError> {
456        // Why a startup check rather than a bound: the core's `max_attempts(..)` and
457        // `dead_letter(..)` steps do not consult the descriptor's type, so the table's roles can
458        // only answer once the declaration reaches it.
459        refused_declaration(&self.name, declaration, &Description::of::<DB, Row>())
460            .map_or(Ok(()), |error| {
461                Err(DeclareRetryError::Broker(Box::new(error)))
462            })
463    }
464}
465
466/// What a subscription adds to the `AsyncAPI` document: the table it reads, and the group its name
467/// selects where the table has one.
468#[cfg(feature = "asyncapi")]
469#[derive(Serialize)]
470struct TableBinding<'a> {
471    table: String,
472    #[serde(skip_serializing_if = "Option::is_none")]
473    group: Option<&'a str>,
474}