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 "now" follows the tokio clock.
8
9use std::collections::HashMap;
10use std::future::Future;
11use std::io;
12use std::panic::resume_unwind;
13use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
14use std::time::{Duration, SystemTime};
15
16use ruststream::testing::{Backlog, Coordinator, InProcess, TestableBroker};
17use ruststream::{HeaderMap, OutgoingMessage, RawMessage};
18use ruststream_sqlx_dialect::Dialect;
19use tokio::runtime::Handle;
20use tokio::sync::oneshot;
21use tokio::task::spawn_blocking;
22use tokio::time::Instant;
23
24use super::broker::{ConnectedSqlxBroker, Shared, SqlxBroker};
25use super::database::QueueDatabase;
26use super::engine::Now;
27use super::error::SqlxBrokerError;
28use super::publish::insert_routed;
29
30/// Where an in-process connection reads "now": the tokio clock, anchored to the wall clock when
31/// the broker connected, so a test that moves a paused clock moves the queue's time with it.
32#[derive(Debug, Clone, Copy)]
33pub(crate) struct TestClock {
34    system: SystemTime,
35    instant: Instant,
36}
37
38impl TestClock {
39    fn start() -> Self {
40        Self {
41            system: SystemTime::now(),
42            instant: Instant::now(),
43        }
44    }
45
46    pub(crate) fn now(self) -> SystemTime {
47        self.system + self.instant.elapsed()
48    }
49}
50
51/// Runs `work` where a paused tokio clock stands still while it waits on the database.
52///
53/// Tokio does not auto-advance a paused clock while a blocking task runs, and the database's reply
54/// arrives in real time. `None` when the runtime shut down before the work finished.
55pub(crate) async fn off_clock<T, Work>(work: Work) -> Option<T>
56where
57    T: Send + 'static,
58    Work: Future<Output = T> + Send + 'static,
59{
60    let runtime = Handle::current();
61    match spawn_blocking(move || runtime.block_on(work)).await {
62        Ok(output) => Some(output),
63        Err(join) if join.is_panic() => resume_unwind(join.into_panic()),
64        Err(_) => None,
65    }
66}
67
68/// The error of database work the runtime dropped before it finished.
69pub(crate) fn cancelled() -> sqlx::Error {
70    sqlx::Error::Io(io::Error::other(
71        "the runtime shut down during a database call",
72    ))
73}
74
75/// The harness's books of one connection: the coordinator a test installed, and every message the
76/// broker's own publishers and the test's injections wrote.
77#[derive(Debug, Default)]
78pub(crate) struct Harness {
79    coordinator: OnceLock<Coordinator>,
80    log: Mutex<Vec<RawMessage>>,
81    /// The clock of an in-process connection; unset on a connection to the server.
82    clock: OnceLock<TestClock>,
83    /// The connection's open subscriptions by name, with the messages counted ahead of a claim.
84    credits: Mutex<HashMap<String, usize>>,
85    /// The last injection's write, which the next one waits for.
86    writes: Mutex<Option<oneshot::Receiver<()>>>,
87}
88
89impl Harness {
90    /// Whether the connection runs in process.
91    pub(crate) fn in_process(&self) -> bool {
92        self.clock.get().is_some()
93    }
94
95    /// Where "now" comes from for the connection's statements.
96    pub(crate) fn now(&self) -> Now {
97        Now::test(self.clock.get().copied())
98    }
99
100    /// The coordinator, on an in-process connection: only that one keeps the books.
101    fn books(&self) -> Option<&Coordinator> {
102        self.clock.get().and_then(|_| self.coordinator.get())
103    }
104
105    fn credits(&self) -> MutexGuard<'_, HashMap<String, usize>> {
106        self.credits.lock().unwrap_or_else(PoisonError::into_inner)
107    }
108
109    /// A subscription to `name` opened on the connection.
110    pub(crate) fn opened(&self, name: &str) {
111        self.credits().entry(name.to_owned()).or_insert(0);
112    }
113
114    /// The subscription to `name` closed.
115    pub(crate) fn closed(&self, name: &str) {
116        self.credits().remove(name);
117    }
118
119    /// A message reached the queue `name`: counted now when a subscription of the connection
120    /// reads it, so the harness waits for its delivery.
121    pub(crate) fn expect(&self, name: &str) {
122        let Some(coordinator) = self.books() else {
123            return;
124        };
125        if let Some(credit) = self.credits().get_mut(name) {
126            *credit += 1;
127            coordinator.enqueued();
128        }
129    }
130
131    /// A message counted for `name` never reached the table.
132    pub(crate) fn refused(&self, name: &str) {
133        let Some(coordinator) = self.books() else {
134            return;
135        };
136        if let Some(credit) = self.credits().get_mut(name)
137            && *credit > 0
138        {
139            *credit -= 1;
140            coordinator.consumed();
141        }
142    }
143
144    /// A claim of `name` took `count` rows: each was counted ahead, or is counted now.
145    pub(crate) fn claimed(&self, name: &str, count: usize) {
146        let Some(coordinator) = self.books() else {
147            return;
148        };
149        let covered = self.credits().get_mut(name).map_or(0, |credit| {
150            let covered = (*credit).min(count);
151            *credit -= covered;
152            covered
153        });
154        for _ in covered..count {
155            coordinator.enqueued();
156        }
157    }
158
159    /// A delivery settled.
160    pub(crate) fn released(&self) {
161        if let Some(coordinator) = self.books() {
162            coordinator.consumed();
163        }
164    }
165
166    /// A delivery of `name` dropped unsettled: its row returns, and the delivery leaves the books.
167    pub(crate) fn returned(&self, name: &str) {
168        self.expect(name);
169        self.released();
170    }
171
172    /// A message one of the broker's own publishers wrote; it was counted before its insert, so
173    /// a claim that lands right after the insert finds it counted.
174    pub(crate) fn published(&self, message: &OutgoingMessage<'_>) {
175        self.record(message);
176    }
177
178    fn record(&self, message: &OutgoingMessage<'_>) {
179        self.log
180            .lock()
181            .unwrap_or_else(PoisonError::into_inner)
182            .push(
183                RawMessage::new(message.name().to_owned(), message.payload().to_vec())
184                    .with_headers(message.headers().clone()),
185            );
186    }
187}
188
189/// The row of an in-process delivery of `name` comes back after `delay`: it is counted when the
190/// delay runs out, as `TestApp::advance` fires the timer.
191pub(crate) fn returns_after<DB: QueueDatabase>(
192    connection: &Arc<Shared<DB>>,
193    name: &'static str,
194    delay: Duration,
195) {
196    let Some(coordinator) = connection.harness.books() else {
197        return;
198    };
199    let connection = Arc::clone(connection);
200    coordinator.schedule_redelivery(delay, move || connection.harness.expect(name));
201}
202
203impl<DB: QueueDatabase, D: Dialect + 'static> InProcess for SqlxBroker<DB, D> {
204    /// Connects to the database the test's pool reaches, as [`connect`](ruststream::Broker::connect)
205    /// does, with every database call of the connection kept off a paused clock.
206    async fn connect_in_process(self) -> Result<ConnectedSqlxBroker<DB, D>, SqlxBrokerError> {
207        let (pool, choice) = (self.pool.clone(), self.dialect.clone());
208        let dialect = off_clock(async move {
209            let conn = pool
210                .acquire()
211                .await
212                .map_err(|source| SqlxBrokerError::Connect { source })?;
213            choice.resolve(&conn)
214        })
215        .await
216        .unwrap_or_else(|| {
217            Err(SqlxBrokerError::Connect {
218                source: cancelled(),
219            })
220        })?;
221        let listening = match self.listen {
222            Some(start) => {
223                let pool = self.pool.clone();
224                Some(
225                    off_clock(start(pool))
226                        .await
227                        .unwrap_or_else(|| Err(cancelled()))
228                        .map_err(|source| SqlxBrokerError::Connect { source })?,
229                )
230            }
231            None => None,
232        };
233        let connected = ConnectedSqlxBroker::new(self, dialect, Handle::current(), listening);
234        let _ = connected.shared.harness.clock.set(TestClock::start());
235        connected.shared.closing.run_in_process();
236        Ok(connected)
237    }
238}
239
240impl<DB: QueueDatabase, D: Dialect + 'static> TestableBroker for ConnectedSqlxBroker<DB, D> {
241    fn install_coordinator(&self, coordinator: Coordinator) {
242        let _ = self.shared.harness.coordinator.set(coordinator);
243    }
244
245    fn inject(&self, message: OutgoingMessage<'_>) {
246        let harness = &self.shared.harness;
247        harness.record(&message);
248        harness.expect(message.name());
249        let (name, payload, headers) = message.into_parts();
250        let (name, payload) = (name.to_owned(), payload.to_vec());
251        // Each injection waits for the one before it, so they reach the table in the order the
252        // test made them.
253        let (written, next) = oneshot::channel();
254        let previous = harness
255            .writes
256            .lock()
257            .unwrap_or_else(PoisonError::into_inner)
258            .replace(next);
259        let shared = Arc::clone(&self.shared);
260        tokio::spawn(async move {
261            if let Some(previous) = previous {
262                let _ = previous.await;
263            }
264            inject(&shared, &name, &payload, headers).await;
265            let _ = written.send(());
266        });
267    }
268
269    fn published(&self, name: &str) -> Vec<RawMessage> {
270        self.shared
271            .harness
272            .log
273            .lock()
274            .unwrap_or_else(PoisonError::into_inner)
275            .iter()
276            .filter(|message| message.name() == name)
277            .cloned()
278            .collect()
279    }
280
281    fn backlog(&self) -> Backlog {
282        // A queue table keeps what is written before a subscription opens.
283        Backlog::Delivered
284    }
285}
286
287/// Writes an injected message into the table its route leads to, as an external producer would.
288async fn inject<DB: QueueDatabase>(
289    shared: &Shared<DB>,
290    name: &str,
291    payload: &[u8],
292    headers: HeaderMap,
293) {
294    let message = OutgoingMessage::new(name, payload).with_headers(headers);
295    let written = match shared.routes.find_with_wake(name) {
296        Some((route, wake)) => insert_routed(shared, route, wake, &message).await,
297        None => Err(SqlxBrokerError::NoRoute {
298            name: name.to_owned(),
299        }),
300    };
301    if let Err(error) = written {
302        shared.harness.refused(name);
303        tracing::warn!(target: "ruststream_sqlx", %error, name, "an injected message was refused");
304    }
305}
306
307#[cfg(feature = "postgres")]
308ruststream::register_testable_broker!(SqlxBroker<sqlx::Postgres>);
309#[cfg(feature = "mysql")]
310ruststream::register_testable_broker!(SqlxBroker<sqlx::MySql>);
311#[cfg(feature = "sqlite")]
312ruststream::register_testable_broker!(SqlxBroker<sqlx::Sqlite>);
313#[cfg(feature = "any")]
314ruststream::register_testable_broker!(SqlxBroker<sqlx::Any>);