Skip to main content

ruststream_sqlx/inbox/
testing.rs

1//! The broker's place in the test harness: the in-process transition, the view the harness
2//! drives, and what an in-process connection does differently.
3//!
4//! The inbox has no model of a database to run in process: its in-process mode reaches the
5//! database the test's pool names, as `connect` does. Every database call of an in-process
6//! connection runs through [`off_clock`], so a test on a paused tokio clock does not see its
7//! timers fire while a reply is on its way, and the system clock follows the tokio clock.
8
9use std::collections::HashMap;
10use std::future::Future;
11use std::io;
12use std::mem;
13use std::panic::resume_unwind;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
16use std::time::{Duration, SystemTime};
17
18use ruststream::testing::{Backlog, Coordinator, InProcess, TestableBroker};
19use ruststream::{HeaderMap, OutgoingMessage, RawMessage};
20use ruststream_sqlx_dialect::{Dialect, Role};
21use tokio::runtime::Handle;
22use tokio::sync::oneshot;
23use tokio::task::spawn_blocking;
24use tokio::time::Instant;
25
26use super::broker::{ConnectedSqlxBroker, Shared, SqlxBroker};
27use super::database::QueueDatabase;
28use super::engine::{Claiming, Event, Events, Now, Values, arguments, intern};
29use super::error::SqlxBrokerError;
30use super::publish::{TableWake, insert_routed};
31use super::queue::Queue;
32
33/// What an in-process connection reads in place of [`SystemClock`](crate::SystemClock): the tokio
34/// clock, anchored to the wall clock when the broker connected, so a test that moves a paused
35/// clock moves the queue's time with it. A clock of the service's own and the database's clock
36/// are read as they are live.
37#[derive(Debug, Clone, Copy)]
38pub(crate) struct TestClock {
39    system: SystemTime,
40    instant: Instant,
41}
42
43impl TestClock {
44    fn start() -> Self {
45        Self {
46            system: SystemTime::now(),
47            instant: Instant::now(),
48        }
49    }
50
51    pub(crate) fn now(self) -> SystemTime {
52        self.system + self.instant.elapsed()
53    }
54}
55
56/// Runs `work` where a paused tokio clock stands still while it waits on the database.
57///
58/// Tokio does not auto-advance a paused clock while a blocking task runs, and the database's reply
59/// arrives in real time. `None` when the runtime shut down before the work finished.
60pub(crate) async fn off_clock<T, Work>(work: Work) -> Option<T>
61where
62    T: Send + 'static,
63    Work: Future<Output = T> + Send + 'static,
64{
65    let runtime = Handle::current();
66    match spawn_blocking(move || runtime.block_on(work)).await {
67        Ok(output) => Some(output),
68        Err(join) if join.is_panic() => resume_unwind(join.into_panic()),
69        Err(_) => None,
70    }
71}
72
73/// The error of database work the runtime dropped before it finished.
74pub(crate) fn cancelled() -> sqlx::Error {
75    sqlx::Error::Io(io::Error::other(
76        "the runtime shut down during a database call",
77    ))
78}
79
80/// A queue the harness counts rows for: a table, and the group of a table that keeps groups.
81///
82/// A row counts for the subscription that reads where it landed, whatever name it was written
83/// under: a table without groups is one queue, and its subscription and its routes name it
84/// freely.
85#[derive(Debug, Clone, Copy)]
86pub(crate) struct QueueAt<'a> {
87    /// The table, qualified with its schema.
88    table: &'a str,
89    /// The group; empty for a table without groups.
90    group: &'a str,
91}
92
93impl<'a> QueueAt<'a> {
94    /// Where a write of `name` into the table of `wake` lands.
95    pub(crate) fn written(wake: &'a TableWake, name: &'a str) -> Self {
96        Self {
97            table: wake.table(),
98            group: wake.group(name),
99        }
100    }
101
102    /// The queue the subscription `queue` reads.
103    pub(crate) fn read(queue: &'a Queue) -> Self {
104        Self {
105            table: queue.table,
106            group: queue.spec.column(Role::Group).map_or("", |_| queue.name),
107        }
108    }
109
110    /// Where a spent row of `queue` moves to `destination`: that group of its table, or, from a
111    /// table without groups, that table.
112    pub(crate) fn dead_letter(queue: &'a Queue, destination: &'a str) -> Self {
113        match queue.spec.column(Role::Group) {
114            Some(_) => Self {
115                table: queue.table,
116                group: destination,
117            },
118            None => Self {
119                table: destination,
120                group: "",
121            },
122        }
123    }
124
125    /// The queue a subscription registers for: `group` is `None` in a table without groups.
126    pub(crate) fn registered(table: &'a str, group: Option<&'a str>) -> Self {
127        Self {
128            table,
129            group: group.unwrap_or_default(),
130        }
131    }
132}
133
134/// The books of one open subscription.
135#[derive(Debug, Default)]
136struct Tally {
137    /// The messages counted ahead of a claim.
138    ahead: usize,
139    /// The rows delayed retries hold back, not counted yet. Each is counted once, by its timer or
140    /// by the claim that takes it first: on a paused clock the subscription's poll and the timer
141    /// come due at the same instant, in either order.
142    returning: Vec<Return>,
143}
144
145impl Tally {
146    /// Whether the row `ticket` booked was still held back; it is not any more.
147    fn take_return(&mut self, ticket: Ticket) -> bool {
148        let Some(at) = self.returning.iter().position(|back| back.ticket == ticket) else {
149            return false;
150        };
151        self.returning.swap_remove(at);
152        true
153    }
154
155    /// The held-back row due first leaves the books: a claim took it.
156    fn take_earliest_return(&mut self) {
157        if let Some(at) = (0..self.returning.len()).min_by_key(|&at| self.returning[at].due) {
158            self.returning.swap_remove(at);
159        }
160    }
161}
162
163/// A row a delayed retry holds back until `due`.
164#[derive(Debug, Clone, Copy)]
165struct Return {
166    due: Instant,
167    ticket: Ticket,
168}
169
170/// Which held-back row a redelivery timer counts.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub(crate) struct Ticket(u64);
173
174/// The open subscriptions of a connection by table, then by group, each with its books.
175type Credits = HashMap<Box<str>, HashMap<Box<str>, Tally>>;
176
177/// The harness's books of one connection: the coordinator a test installed, and every message the
178/// broker's own publishers and the test's injections wrote.
179#[derive(Debug, Default)]
180pub(crate) struct Harness {
181    coordinator: OnceLock<Coordinator>,
182    log: Mutex<Vec<RawMessage>>,
183    /// The clock of an in-process connection; unset on a connection to the server.
184    clock: OnceLock<TestClock>,
185    /// The connection's open subscriptions by queue, with the messages counted ahead of a claim
186    /// and the rows delayed retries hold back.
187    credits: Mutex<Credits>,
188    /// The last ticket a held-back row took.
189    tickets: AtomicU64,
190    /// The last injection's write, which the next one waits for.
191    writes: Mutex<Option<oneshot::Receiver<()>>>,
192}
193
194impl Harness {
195    /// Whether the connection runs in process.
196    pub(crate) fn in_process(&self) -> bool {
197        self.clock.get().is_some()
198    }
199
200    /// Where "now" comes from for the connection's statements.
201    pub(crate) fn now(&self) -> Now {
202        Now::test(self.clock.get().copied())
203    }
204
205    /// The coordinator, on an in-process connection: only that one keeps the books.
206    fn books(&self) -> Option<&Coordinator> {
207        self.clock.get().and_then(|_| self.coordinator.get())
208    }
209
210    fn credits(&self) -> MutexGuard<'_, Credits> {
211        self.credits.lock().unwrap_or_else(PoisonError::into_inner)
212    }
213
214    /// The books of the open subscription that reads `queue`, if one does.
215    fn credit<'books>(
216        credits: &'books mut Credits,
217        queue: QueueAt<'_>,
218    ) -> Option<&'books mut Tally> {
219        credits.get_mut(queue.table)?.get_mut(queue.group)
220    }
221
222    /// A subscription to `queue` opened on the connection, which found `waiting` rows it can claim
223    /// already there: each is counted now, so the harness waits for its delivery.
224    pub(crate) fn opened(&self, queue: QueueAt<'_>, waiting: usize) {
225        self.credits()
226            .entry(queue.table.into())
227            .or_default()
228            .entry(queue.group.into())
229            .or_default()
230            .ahead += waiting;
231        if let Some(coordinator) = self.books() {
232            for _ in 0..waiting {
233                coordinator.enqueued();
234            }
235        }
236    }
237
238    /// The subscription to `queue` closed.
239    pub(crate) fn closed(&self, queue: QueueAt<'_>) {
240        let mut credits = self.credits();
241        if let Some(groups) = credits.get_mut(queue.table) {
242            groups.remove(queue.group);
243            if groups.is_empty() {
244                credits.remove(queue.table);
245            }
246        }
247    }
248
249    /// A message reached `queue`: counted now when a subscription of the connection reads it, so
250    /// the harness waits for its delivery.
251    pub(crate) fn expect(&self, queue: QueueAt<'_>) {
252        let Some(coordinator) = self.books() else {
253            return;
254        };
255        if let Some(tally) = Self::credit(&mut self.credits(), queue) {
256            tally.ahead += 1;
257            coordinator.enqueued();
258        }
259    }
260
261    /// A message counted for `queue` never reached the table.
262    pub(crate) fn refused(&self, queue: QueueAt<'_>) {
263        let Some(coordinator) = self.books() else {
264            return;
265        };
266        if let Some(tally) = Self::credit(&mut self.credits(), queue)
267            && tally.ahead > 0
268        {
269            tally.ahead -= 1;
270            coordinator.consumed();
271        }
272    }
273
274    /// A delayed retry is about to hold a row of `queue` back for `delay`: the row is booked as
275    /// returning, uncounted until its timer fires or a claim takes it. `None` when no subscription
276    /// of the connection reads `queue`.
277    pub(crate) fn holds_back(&self, queue: QueueAt<'_>, delay: Duration) -> Option<Ticket> {
278        self.books()?;
279        let ticket = Ticket(self.tickets.fetch_add(1, Ordering::Relaxed));
280        let due = Instant::now() + delay;
281        Self::credit(&mut self.credits(), queue)?
282            .returning
283            .push(Return { due, ticket });
284        Some(ticket)
285    }
286
287    /// The row `ticket` booked stays where it was: the statement that would hold it back failed.
288    pub(crate) fn withdraw(&self, queue: QueueAt<'_>, ticket: Ticket) {
289        if let Some(tally) = Self::credit(&mut self.credits(), queue) {
290            tally.take_return(ticket);
291        }
292    }
293
294    /// The timer of the row `ticket` booked fired: the row is counted now, unless a claim took it
295    /// first.
296    fn comes_due(&self, queue: QueueAt<'_>, ticket: Ticket) {
297        let Some(coordinator) = self.books() else {
298            return;
299        };
300        if let Some(tally) = Self::credit(&mut self.credits(), queue)
301            && tally.take_return(ticket)
302        {
303            tally.ahead += 1;
304            coordinator.enqueued();
305        }
306    }
307
308    /// A claim of `queue` took `count` rows: each was counted ahead, or is counted now. A row
309    /// counted now may be one a delayed retry held back, taken before its timer fired: the
310    /// earliest one due leaves the books, so its timer counts nothing.
311    pub(crate) fn claimed(&self, queue: QueueAt<'_>, count: usize) {
312        let Some(coordinator) = self.books() else {
313            return;
314        };
315        let covered = Self::credit(&mut self.credits(), queue).map_or(0, |tally| {
316            let covered = tally.ahead.min(count);
317            tally.ahead -= covered;
318            for _ in covered..count {
319                tally.take_earliest_return();
320            }
321            covered
322        });
323        for _ in covered..count {
324            coordinator.enqueued();
325        }
326    }
327
328    /// A delivery settled.
329    pub(crate) fn released(&self) {
330        if let Some(coordinator) = self.books() {
331            coordinator.consumed();
332        }
333    }
334
335    /// A delivery of `queue` dropped unsettled: its row returns, and the delivery leaves the books.
336    pub(crate) fn returned(&self, queue: QueueAt<'_>) {
337        self.expect(queue);
338        self.released();
339    }
340
341    /// A message one of the broker's own publishers wrote; it was counted before its insert, so
342    /// a claim that lands right after the insert finds it counted.
343    pub(crate) fn published(&self, message: &OutgoingMessage<'_>) {
344        self.record(message);
345    }
346
347    fn record(&self, message: &OutgoingMessage<'_>) {
348        self.log
349            .lock()
350            .unwrap_or_else(PoisonError::into_inner)
351            .push(
352                RawMessage::new(message.name().to_owned(), message.payload().to_vec())
353                    .with_headers(message.headers().clone()),
354            );
355    }
356}
357
358/// The dead-letter moves the harness counted for the deliveries of one row lock batch, where each
359/// row would move. The batch's transaction decides whether they took place: a commit keeps them,
360/// and a rollback leaves every row where it was, so the harness counts none of them.
361pub(crate) struct BatchMoves<DB: QueueDatabase> {
362    /// The in-process connection whose books counted the moves; unset outside the harness.
363    connection: OnceLock<Arc<Shared<DB>>>,
364    counted: Mutex<Vec<QueueAt<'static>>>,
365}
366
367impl<DB: QueueDatabase> Default for BatchMoves<DB> {
368    fn default() -> Self {
369        Self {
370            connection: OnceLock::new(),
371            counted: Mutex::default(),
372        }
373    }
374}
375
376impl<DB: QueueDatabase> BatchMoves<DB> {
377    /// Keeps the moves for the books of `connection`, where it keeps them.
378    pub(crate) fn watch(&self, connection: &Arc<Shared<DB>>) {
379        if connection.harness.books().is_some() {
380            let _ = self.connection.set(Arc::clone(connection));
381        }
382    }
383
384    fn counted(&self) -> MutexGuard<'_, Vec<QueueAt<'static>>> {
385        self.counted.lock().unwrap_or_else(PoisonError::into_inner)
386    }
387
388    /// A delivery of the batch counted its row at `at`, where it moves; the move stands once the
389    /// batch commits.
390    pub(crate) fn counted_at(&self, at: QueueAt<'static>) {
391        if self.connection.get().is_some() {
392            self.counted().push(at);
393        }
394    }
395
396    /// The batch's transaction ended: committed, so every move took place, or not.
397    pub(crate) fn ended(&self, committed: bool) {
398        if committed {
399            self.counted().clear();
400        } else {
401            self.undone();
402        }
403    }
404
405    /// The batch's transaction rolled back: no row moved, and none is counted where it would have.
406    pub(crate) fn undone(&self) {
407        let Some(connection) = self.connection.get() else {
408            return;
409        };
410        let undone = mem::take(&mut *self.counted());
411        for at in undone {
412            connection.harness.refused(at);
413        }
414    }
415}
416
417/// The row of an in-process delivery of `queue`, booked under `ticket`, comes back after `delay`:
418/// it is counted when the delay runs out, as `TestApp::advance` fires the timer, unless a claim
419/// took it first.
420pub(crate) fn returns_after<DB: QueueDatabase>(
421    connection: &Arc<Shared<DB>>,
422    queue: &'static Queue,
423    delay: Duration,
424    ticket: Ticket,
425) {
426    let Some(coordinator) = connection.harness.books() else {
427        return;
428    };
429    let connection = Arc::clone(connection);
430    coordinator.schedule_redelivery(delay, move || {
431        connection.harness.comes_due(QueueAt::read(queue), ticket);
432    });
433}
434
435/// Opens the harness's books for the subscription `queue`, which reads `group` (`None` in a table
436/// without groups), with the rows it can claim already counted.
437///
438/// The rows are counted before the books open: a message published meanwhile is counted once, by
439/// this count or by the claim that takes it, never by both.
440///
441/// # Errors
442///
443/// As [`claimable`].
444pub(crate) async fn open_books<DB, Row>(
445    connection: &Arc<Shared<DB>>,
446    dialect: &dyn Dialect,
447    queue: &'static Queue,
448    group: Option<&str>,
449) -> Result<(), SqlxBrokerError>
450where
451    DB: QueueDatabase,
452    Row: Events<DB>,
453{
454    let waiting = claimable::<DB, Row>(connection, dialect, queue).await?;
455    connection
456        .harness
457        .opened(QueueAt::registered(queue.table, group), waiting);
458    Ok(())
459}
460
461/// The rows the subscription `queue` of an in-process connection can claim as it opens: rows the
462/// service wrote with its own SQL before the test, which the harness waits for as for published
463/// messages. Counted with the dialect's [`count_claimable`](Dialect::count_claimable), bound as the
464/// claim binds; none on a connection that keeps no books, for a claim of the service's own, or
465/// where the dialect counts none.
466///
467/// # Errors
468///
469/// The dialect's refusal, or the database's error.
470async fn claimable<DB, Row>(
471    connection: &Arc<Shared<DB>>,
472    dialect: &dyn Dialect,
473    queue: &'static Queue,
474) -> Result<usize, SqlxBrokerError>
475where
476    DB: QueueDatabase,
477    Row: Events<DB>,
478{
479    // A claim of the service's own reads the rows its way, which a count cannot follow.
480    if connection.harness.books().is_none() || Row::SHAPE.custom_claim {
481        return Ok(0);
482    }
483    let counting =
484        dialect
485            .count_claimable(&queue.spec)
486            .map_err(|source| SqlxBrokerError::Dialect {
487                subscription: queue.name.to_owned(),
488                table: queue.table.to_owned(),
489                row: queue.row,
490                source,
491            })?;
492    let Some(counting) = counting else {
493        return Ok(0);
494    };
495    let statement = intern(&counting);
496    let (pool, now) = (connection.pool.clone(), connection.harness.now());
497    let counted = off_clock(async move {
498        // Bound as the claim binds: a lease table's rows are free by the lease's own "now".
499        let lease = queue.lease.map(|_| Row::lease(queue, now)).transpose()?;
500        let cx = Claiming {
501            queue,
502            limit: 0,
503            now,
504        };
505        let values = Values::claiming(cx, Event::Claim, lease.as_ref());
506        let arguments = arguments::<DB, Row>(statement, &values)?;
507        let mut conn = pool.acquire().await?;
508        DB::fetch_count(&mut conn, statement.sql, arguments).await
509    })
510    .await
511    .unwrap_or_else(|| Err(cancelled()))
512    .map_err(|source| SqlxBrokerError::Sqlx {
513        subscription: queue.name.to_owned(),
514        table: queue.table.to_owned(),
515        row: queue.row,
516        statement: statement.sql,
517        source: Box::new(source),
518    })?;
519    Ok(usize::try_from(counted).unwrap_or_default())
520}
521
522impl<DB: QueueDatabase, D: Dialect + 'static> InProcess for SqlxBroker<DB, D> {
523    /// Connects to the database the test's pool reaches, as [`connect`](ruststream::Broker::connect)
524    /// does, with every database call of the connection kept off a paused clock.
525    async fn connect_in_process(self) -> Result<ConnectedSqlxBroker<DB, D>, SqlxBrokerError> {
526        let (pool, owner) = self.pool.open()?;
527        let choice = self.dialect.clone();
528        let checked = pool.clone();
529        let dialect = off_clock(async move {
530            let conn = checked
531                .acquire()
532                .await
533                .map_err(|source| SqlxBrokerError::Connect { source })?;
534            choice.resolve(&conn)
535        })
536        .await
537        .unwrap_or_else(|| {
538            Err(SqlxBrokerError::Connect {
539                source: cancelled(),
540            })
541        })?;
542        let listening = match self.listen {
543            Some(start) => Some(
544                off_clock(start(pool.clone()))
545                    .await
546                    .unwrap_or_else(|| Err(cancelled()))
547                    .map_err(|source| SqlxBrokerError::Connect { source })?,
548            ),
549            None => None,
550        };
551        let connected =
552            ConnectedSqlxBroker::new(self, (pool, owner), dialect, Handle::current(), listening);
553        let _ = connected.shared.harness.clock.set(TestClock::start());
554        connected.shared.closing.run_in_process();
555        Ok(connected)
556    }
557}
558
559impl<DB: QueueDatabase, D: Dialect + 'static> TestableBroker for ConnectedSqlxBroker<DB, D> {
560    fn install_coordinator(&self, coordinator: Coordinator) {
561        let _ = self.shared.harness.coordinator.set(coordinator);
562    }
563
564    fn inject(&self, message: OutgoingMessage<'_>) {
565        let harness = &self.shared.harness;
566        harness.record(&message);
567        // A name no route leads to a table is refused below, and counts nowhere.
568        let wake = self
569            .shared
570            .routes
571            .find_with_wake(message.name())
572            .map(|(_, wake)| wake);
573        if let Some(wake) = wake {
574            harness.expect(QueueAt::written(wake, message.name()));
575        }
576        let (name, payload, headers) = message.into_parts();
577        let (name, payload) = (name.to_owned(), payload.to_vec());
578        // Each injection waits for the one before it, so they reach the table in the order the
579        // test made them.
580        let (written, next) = oneshot::channel();
581        let previous = harness
582            .writes
583            .lock()
584            .unwrap_or_else(PoisonError::into_inner)
585            .replace(next);
586        let shared = Arc::clone(&self.shared);
587        tokio::spawn(async move {
588            if let Some(previous) = previous {
589                let _ = previous.await;
590            }
591            inject(&shared, &name, &payload, headers).await;
592            let _ = written.send(());
593        });
594    }
595
596    fn published(&self, name: &str) -> Vec<RawMessage> {
597        self.shared
598            .harness
599            .log
600            .lock()
601            .unwrap_or_else(PoisonError::into_inner)
602            .iter()
603            .filter(|message| message.name() == name)
604            .cloned()
605            .collect()
606    }
607
608    fn backlog(&self) -> Backlog {
609        // A queue table keeps what is written before a subscription opens.
610        Backlog::Delivered
611    }
612}
613
614/// Writes an injected message into the table its route leads to, as an external producer would.
615async fn inject<DB: QueueDatabase>(
616    shared: &Shared<DB>,
617    name: &str,
618    payload: &[u8],
619    headers: HeaderMap,
620) {
621    let message = OutgoingMessage::new(name, payload).with_headers(headers);
622    let written = match shared.routes.find_with_wake(name) {
623        Some((route, wake)) => insert_routed(shared, route, wake, &message).await,
624        None => Err(SqlxBrokerError::NoRoute {
625            name: name.to_owned(),
626        }),
627    };
628    if let Err(error) = written {
629        if let Some((_, wake)) = shared.routes.find_with_wake(name) {
630            shared.harness.refused(QueueAt::written(wake, name));
631        }
632        tracing::warn!(target: "ruststream_sqlx", %error, name, "an injected message was refused");
633    }
634}
635
636#[cfg(feature = "postgres")]
637ruststream::register_testable_broker!(SqlxBroker<sqlx::Postgres>);
638#[cfg(feature = "mysql")]
639ruststream::register_testable_broker!(SqlxBroker<sqlx::MySql>);
640#[cfg(feature = "sqlite")]
641ruststream::register_testable_broker!(SqlxBroker<sqlx::Sqlite>);
642#[cfg(feature = "any")]
643ruststream::register_testable_broker!(SqlxBroker<sqlx::Any>);