ruststream-sqlx 0.7.0

SQL databases for the RustStream messaging framework through sqlx: a transactional outbox over any RustStream broker and task queues in Postgres, MySQL/MariaDB and SQLite tables.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
//! Transactional mode, run as an application through `TestApp::start_live` on every stand and in
//! every form: the handler writes through the delivery's transaction, which acknowledgement commits
//! and every other outcome discards; writes through the pool commit on their own; the keys after
//! the transaction read the delivery's attempt and the pool.

use std::convert::Infallible;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;

use ruststream::testing::{Outcome, TestApp};
use ruststream_sqlx::Tx;
use ruststream_sqlx::prelude::*;
use serde::{Deserialize, Serialize};
use sqlx::Pool;

use super::{JOB, Job, LEASE, POLL, audit};
use crate::live;

/// How long a handler's delayed retry waits before its row comes back.
const LATER: Duration = Duration::from_millis(300);

/// A task a handler schedules in its delivery's transaction.
#[derive(Debug, Serialize, Deserialize, PartialEq, Outgoing)]
struct Followup {
    attempt: u64,
}

live::matrix! {
    fn broker(pool: &Pool<Db>) -> SqlxBroker<Db> {
        SqlxBroker::new(pool.clone())
            .poll_interval(POLL)
            .lease(LEASE)
            .route::<SendEmail>("emails")
            .route::<Plain>("plain")
            .route::<Fragile>("fragile")
    }

    /// The audit rows' notes, in order.
    async fn notes(db: &live::Database<Db>) -> Vec<String> {
        sqlx::query_scalar("SELECT note FROM audit ORDER BY note")
            .fetch_all(&db.pool)
            .await
            .expect("the audit reads")
    }

    async fn published<State: Send + Sync + 'static>(tb: &TestApp<State>, to: &str) {
        tb.broker::<SqlxBroker<Db>>()
            .message(&JOB)
            .to(to)
            .publish()
            .await
            .expect("the publish settles");
    }

    #[subscriber(InboxQueue::<Plain>::new("plain"))]
    async fn audited(job: &Job, Ctx(mut tx): Ctx<keys::Tx<Db>>) -> HandlerOutcome {
        sqlx::raw_sql(audit(job, "acked"))
            .execute(&mut *tx)
            .await
            .expect("the audit row writes");
        HandlerOutcome::ack()
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn ack_commits_the_handler_writes() {
        let Some(db) = database().await else { return };
        let app = RustStream::new(AppInfo::new("transactional", "0.0.0"))
            .with_broker(broker(&db.pool), |b| {
                b.include(audited.transactional());
            });
        let tb = TestApp::start_live(app).await.expect("the app starts");
        published(&tb, "plain").await;
        tb.broker::<SqlxBroker<Db>>()
            .subscriber("plain")
            .assert_called_once()
            .settled(HandlerOutcome::ack());
        assert_eq!(notes(&db).await, ["acked"], "the acknowledgement kept the handler's write");
        assert_eq!(db.count("plain_jobs").await, 0, "and finished the job with it");
        tb.shutdown().await.expect("the app stops");
        db.finish().await;
    }

    // In process the connection's database calls run off the test's clock, the delivery's own
    // transaction and its settlement among them.
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn in_process_ack_commits_the_handler_writes() {
        let Some(db) = database().await else { return };
        let app = RustStream::new(AppInfo::new("transactional", "0.0.0"))
            .with_broker(broker(&db.pool), |b| {
                b.include(audited.transactional());
            });
        let tb = TestApp::start(app).await.expect("the app starts");
        published(&tb, "plain").await;
        tb.broker::<SqlxBroker<Db>>()
            .subscriber("plain")
            .assert_called_once()
            .settled(HandlerOutcome::ack());
        assert_eq!(notes(&db).await, ["acked"], "the acknowledgement kept the handler's write");
        assert_eq!(db.count("plain_jobs").await, 0, "and finished the job with it");
        tb.shutdown().await.expect("the app stops");
        db.finish().await;
    }

    #[subscriber(InboxQueue::<SendEmail>::new("emails"))]
    async fn noted(
        job: &Job,
        Ctx(mut tx): Ctx<keys::Tx<Db>>,
        Ctx(attempt): Ctx<keys::Attempt>,
    ) -> HandlerOutcome {
        let attempt = attempt.expect("the table counts attempts");
        sqlx::raw_sql(audit(job, &attempt.to_string()))
            .execute(&mut *tx)
            .await
            .expect("the audit row writes");
        if attempt == 1 {
            HandlerOutcome::retry_after(LATER)
        } else {
            HandlerOutcome::ack()
        }
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn a_retry_discards_the_writes_and_keeps_attempt_and_retry_after() {
        let Some(db) = database().await else { return };
        let app = RustStream::new(AppInfo::new("transactional", "0.0.0"))
            .with_broker(broker(&db.pool), |b| {
                b.include(noted.transactional());
            });
        let tb = TestApp::start_live(app).await.expect("the app starts");
        published(&tb, "emails").await;
        tb.settle().await.expect("the first delivery settles");
        let rows = db.email_rows("email_jobs").await;
        assert_eq!(rows.len(), 1, "the retry kept the job: {rows:?}");
        assert_eq!(rows[0].2, 2, "the delivery counted the attempt");
        assert!(!rows[0].3, "the job is not finished");
        assert!(db.email_waits().await, "the retry wrote the time the job waits for");
        assert!(notes(&db).await.is_empty(), "the retry discarded the handler's write");
        tb.advance(LATER + Duration::from_millis(100))
            .await
            .expect("the redelivery settles");
        assert_eq!(notes(&db).await, ["2"], "the acknowledgement kept the second write alone");
        tb.shutdown().await.expect("the app stops");
        db.finish().await;
    }

    #[subscriber(InboxQueue::<SendEmail>::new("emails"))]
    async fn scheduling(
        _job: &Job,
        Ctx(mut tx): Ctx<keys::Tx<Db>>,
        Ctx(attempt): Ctx<keys::Attempt>,
    ) -> HandlerOutcome {
        let attempt = attempt.expect("the table counts attempts");
        let payload = serde_json::to_vec(&Followup { attempt }).expect("json");
        SendEmail::queued("followups", payload)
            .insert(&mut *tx)
            .await
            .expect("the follow-up writes");
        if attempt == 1 {
            HandlerOutcome::retry_after(LATER)
        } else {
            HandlerOutcome::ack()
        }
    }

    #[subscriber(InboxQueue::<SendEmail>::new("followups"))]
    async fn followed(_followup: &Followup) -> HandlerOutcome {
        HandlerOutcome::ack()
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn a_task_written_through_tx_commits_with_the_ack() {
        let Some(db) = database().await else { return };
        let app = RustStream::new(AppInfo::new("transactional", "0.0.0"))
            .with_broker(broker(&db.pool), |b| {
                b.include(scheduling.transactional());
                b.include(followed);
            });
        let tb = TestApp::start_live(app).await.expect("the app starts");
        published(&tb, "emails").await;
        tb.advance(LATER + Duration::from_millis(100))
            .await
            .expect("the redelivery settles");
        let followups: Vec<Followup> = db
            .email_rows("email_jobs")
            .await
            .into_iter()
            .filter(|row| row.0 == "followups")
            .map(|row| serde_json::from_slice(&row.1).expect("a follow-up"))
            .collect();
        assert_eq!(
            followups,
            [Followup { attempt: 2 }],
            "the retry discarded the first attempt's task, the acknowledgement kept the second's"
        );
        // A row the handler wrote is the harness's to wait for only once a claim takes it.
        tb.advance(Duration::from_millis(500))
            .await
            .expect("the follow-up settles");
        tb.broker::<SqlxBroker<Db>>()
            .subscriber("followups")
            .assert_called_once()
            .with(&Followup { attempt: 2 })
            .settled(HandlerOutcome::ack());
        tb.shutdown().await.expect("the app stops");
        db.finish().await;
    }

    // The pool's write comes first: on SQLite the delivery's transaction holds the database's one
    // write lock from its first write until it settles, and a write through the pool would wait
    // for it.
    #[subscriber(InboxQueue::<SendEmail>::new("emails"))]
    async fn twice_noted(
        job: &Job,
        Ctx(mut tx): Ctx<keys::Tx<Db>>,
        Ctx(pool): Ctx<keys::Pool<Db>>,
        Ctx(attempt): Ctx<keys::Attempt>,
    ) -> HandlerOutcome {
        let attempt = attempt.expect("the table counts attempts");
        sqlx::raw_sql(audit(job, &format!("pool {attempt}")))
            .execute(&pool)
            .await
            .expect("the pool's audit row writes");
        sqlx::raw_sql(audit(job, &format!("tx {attempt}")))
            .execute(&mut *tx)
            .await
            .expect("the transaction's audit row writes");
        if attempt == 1 {
            HandlerOutcome::retry_after(LATER)
        } else {
            HandlerOutcome::ack()
        }
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn pool_writes_commit_on_their_own() {
        let Some(db) = database().await else { return };
        let app = RustStream::new(AppInfo::new("transactional", "0.0.0"))
            .with_broker(broker(&db.pool), |b| {
                b.include(twice_noted.transactional());
            });
        let tb = TestApp::start_live(app).await.expect("the app starts");
        published(&tb, "emails").await;
        assert_eq!(notes(&db).await, ["pool 1"], "the retry kept the pool's write alone");
        tb.advance(LATER + Duration::from_millis(100))
            .await
            .expect("the redelivery settles");
        assert_eq!(
            notes(&db).await,
            ["pool 1", "pool 2", "tx 2"],
            "the pool's writes stay, the transaction's arrive once"
        );
        tb.shutdown().await.expect("the app stops");
        db.finish().await;
    }

    #[subscriber(InboxQueue::<SendEmail>::new("emails"))]
    async fn counted(
        _job: &Job,
        Ctx(_tx): Ctx<keys::Tx<Db>>,
        Ctx(attempt): Ctx<keys::Attempt>,
    ) -> HandlerOutcome {
        match attempt {
            Some(1) => HandlerOutcome::retry_after(LATER),
            Some(2) => HandlerOutcome::ack(),
            _ => HandlerOutcome::drop(),
        }
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn attempt_reads_beside_tx() {
        let Some(db) = database().await else { return };
        let app = RustStream::new(AppInfo::new("transactional", "0.0.0"))
            .with_broker(broker(&db.pool), |b| {
                b.include(counted.transactional());
            });
        let tb = TestApp::start_live(app).await.expect("the app starts");
        published(&tb, "emails").await;
        tb.advance(LATER + Duration::from_millis(100))
            .await
            .expect("the redelivery settles");
        let outcomes = tb
            .broker::<SqlxBroker<Db>>()
            .subscriber("emails")
            .assert_called(2)
            .outcomes();
        assert_eq!(
            outcomes,
            [Outcome::Nack, Outcome::Ack],
            "the handler read attempt 1, then 2"
        );
        tb.shutdown().await.expect("the app stops");
        db.finish().await;
    }

    #[subscriber(InboxQueue::<Plain>::new("plain"))]
    async fn pooled(
        job: &Job,
        Ctx(pool): Ctx<keys::Pool<Db>>,
        Ctx(attempt): Ctx<keys::Attempt>,
    ) -> HandlerOutcome {
        let attempt = attempt.expect("the table counts attempts");
        sqlx::raw_sql(audit(job, &attempt.to_string()))
            .execute(&pool)
            .await
            .expect("the audit row writes");
        HandlerOutcome::ack()
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn a_plain_handler_reads_the_pool() {
        let Some(db) = database().await else { return };
        let app = RustStream::new(AppInfo::new("transactional", "0.0.0"))
            .with_broker(broker(&db.pool), |b| {
                b.include(pooled);
            });
        let tb = TestApp::start_live(app).await.expect("the app starts");
        published(&tb, "plain").await;
        tb.broker::<SqlxBroker<Db>>()
            .subscriber("plain")
            .assert_called_once()
            .settled(HandlerOutcome::ack());
        assert_eq!(notes(&db).await, ["1"], "the handler wrote through the pool");
        assert_eq!(db.count("plain_jobs").await, 0, "and finished the job");
        tb.shutdown().await.expect("the app stops");
        db.finish().await;
    }

    #[subscriber(InboxQueue::<Fragile>::new("fragile"))]
    async fn referenced(
        job: &Job,
        Ctx(mut tx): Ctx<keys::Tx<Db>>,
        Ctx(pool): Ctx<keys::Pool<Db>>,
    ) -> HandlerOutcome {
        // Through the pool: the write stays, and counts the deliveries.
        sqlx::raw_sql(audit(job, "delivered"))
            .execute(&pool)
            .await
            .expect("the audit row writes");
        let delivered: i64 = sqlx::query_scalar("SELECT count(*) FROM audit")
            .fetch_one(&pool)
            .await
            .expect("the audit counts");
        if delivered == 1 {
            // A reference to the job, in the delivery's transaction, makes its acknowledgement
            // fail.
            sqlx::raw_sql("INSERT INTO fragile_refs (job_id) SELECT id FROM fragile_jobs")
                .execute(&mut *tx)
                .await
                .expect("the reference writes");
        }
        HandlerOutcome::ack()
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn a_failed_settlement_rolls_the_handler_back() {
        let Some(db) = database().await else { return };
        let app = RustStream::new(AppInfo::new("transactional", "0.0.0"))
            .with_broker(broker(&db.pool), |b| {
                b.include(referenced.transactional());
            });
        let tb = TestApp::start_live(app).await.expect("the app starts");
        published(&tb, "fragile").await;
        // The failed acknowledgement returns the row at once, and its next delivery settles.
        tb.advance(Duration::from_millis(300))
            .await
            .expect("the redelivery settles");
        tb.broker::<SqlxBroker<Db>>()
            .subscriber("fragile")
            .assert_called(2);
        assert_eq!(
            db.count("fragile_refs").await,
            0,
            "the failed acknowledgement rolled back what the handler wrote with it"
        );
        assert_eq!(db.count("fragile_jobs").await, 0, "the second acknowledgement finished the job");
        tb.shutdown().await.expect("the app stops");
        db.finish().await;
    }

    #[subscriber(InboxQueue::<Plain>::new("plain"))]
    async fn half_done(job: &Job, Ctx(mut tx): Ctx<keys::Tx<Db>>) -> HandlerOutcome {
        sqlx::raw_sql(audit(job, "half"))
            .execute(&mut *tx)
            .await
            .expect("the audit row writes");
        panic!("the handler fails half way through its writes");
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn a_panic_never_commits_the_handler_writes() {
        let Some(db) = database().await else { return };
        let skip = FailurePolicies::default().with_panic(FailurePolicy::Skip);
        let app = RustStream::new(AppInfo::new("transactional", "0.0.0"))
            .with_broker(broker(&db.pool), |b| {
                b.include(half_done.transactional().on_failure(skip));
            });
        let tb = TestApp::start_live(app).await.expect("the app starts");
        published(&tb, "plain").await;
        tb.broker::<SqlxBroker<Db>>()
            .subscriber("plain")
            .assert_called_once();
        assert_eq!(db.count("plain_jobs").await, 0, "the panic policy acknowledged the job");
        assert!(
            notes(&db).await.is_empty(),
            "the acknowledgement of a panicked handler committed none of its writes"
        );
        tb.shutdown().await.expect("the app stops");
        db.finish().await;
    }

    /// Where a handler keeps its delivery's transaction past its own end, and whether it did.
    #[derive(Clone, Default)]
    struct Kept(Arc<Mutex<(Option<Tx<Db>>, bool)>>);

    #[derive(Clone, FromRef)]
    struct Keeping {
        kept: Kept,
    }

    #[subscriber(InboxQueue::<Plain>::new("plain"))]
    async fn keeping(
        job: &Job,
        Ctx(mut tx): Ctx<keys::Tx<Db>>,
        State(kept): State<Kept>,
    ) -> HandlerOutcome {
        sqlx::raw_sql(audit(job, "kept"))
            .execute(&mut *tx)
            .await
            .expect("the audit row writes");
        let mut slot = kept.0.lock().unwrap_or_else(PoisonError::into_inner);
        if !slot.1 {
            // The first delivery's handler keeps its transaction, and ends.
            *slot = (Some(tx), true);
        }
        drop(slot);
        HandlerOutcome::ack()
    }

    // What the kept transaction wrote is read only once it ended: on SQLite a read of a table it
    // wrote waits for it. The one note at the end shows its write never committed.
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn a_transaction_kept_past_the_handler_settles_nothing() {
        let Some(db) = database().await else { return };
        let kept = Kept::default();
        let state = Keeping { kept: kept.clone() };
        let app = RustStream::new(AppInfo::new("transactional", "0.0.0"))
            .on_startup(async move |()| Ok::<_, Infallible>(state))
            .with_broker(broker(&db.pool), |b| {
                b.include(keeping.transactional());
            });
        let tb = TestApp::start_live(app).await.expect("the app starts");
        published(&tb, "plain").await;
        assert_eq!(db.count("plain_jobs").await, 1, "the acknowledgement took no effect");
        // The kept transaction ends as it drops, and the server rolls it back. The row returns
        // at once where the transaction or its session held it, and once its lease runs out where
        // a lease does.
        drop(kept.0.lock().unwrap_or_else(PoisonError::into_inner).0.take());
        let returns = if LEASED { LEASE * 3 } else { Duration::from_millis(300) };
        tb.advance(returns).await.expect("the redelivery settles");
        tb.broker::<SqlxBroker<Db>>()
            .subscriber("plain")
            .assert_called(2);
        assert_eq!(notes(&db).await, ["kept"], "the second delivery's write alone commits");
        assert_eq!(db.count("plain_jobs").await, 0, "with its acknowledgement");
        tb.shutdown().await.expect("the app stops");
        db.finish().await;
    }
}