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}