mod common;
use chrono::Utc;
use common::TestDb;
use std::time::Duration;
use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
use tokio_postgres::{AsyncMessage, Client, NoTls};
use ulid::Ulid;
use venturi::store::{JournalAppend, JournalOutcome, NewJob, Settlement, Store};
const KIND: &str = "quick";
const CLAIMED_BY: &str = "tester";
async fn listen(dsn: &str, channel: &str) -> (Client, UnboundedReceiver<()>) {
let (client, mut connection) = tokio_postgres::connect(dsn, NoTls)
.await
.expect("connect listener");
let (tx, rx) = unbounded_channel();
tokio::spawn(async move {
loop {
match std::future::poll_fn(|cx| connection.poll_message(cx)).await {
Some(Ok(AsyncMessage::Notification(_))) => {
if tx.send(()).is_err() {
break;
}
}
Some(Ok(_)) => {}
Some(Err(_)) | None => break,
}
}
});
client
.batch_execute(&format!("LISTEN {channel}"))
.await
.expect("LISTEN");
(client, rx)
}
async fn notified_within(rx: &mut UnboundedReceiver<()>, ms: u64) -> bool {
tokio::time::timeout(Duration::from_millis(ms), rx.recv())
.await
.map(|received| received.is_some())
.unwrap_or(false)
}
async fn drain(rx: &mut UnboundedReceiver<()>) {
while notified_within(rx, 250).await {}
}
fn new_job(id: Ulid) -> NewJob {
let now = Utc::now();
NewJob {
id,
kind: KIND.to_owned(),
payload: serde_json::Value::Null,
priority: 1,
created_at: now,
visible_at: now - chrono::Duration::seconds(5),
carry: serde_json::Value::Null,
dedup_key: None,
}
}
fn journal(run_no: i32, outcome: JournalOutcome) -> JournalAppend {
JournalAppend {
kind: KIND.to_owned(),
run_no,
recorded_at: Utc::now(),
outcome,
note: None,
attachment: None,
}
}
async fn enqueue_and_claim(
store: &impl Store,
rx: &mut UnboundedReceiver<()>,
lease: Duration,
) -> (Ulid, i32) {
let id = Ulid::new();
store.enqueue(&new_job(id)).await.expect("enqueue");
let kinds = vec![KIND.to_owned()];
let job = store
.claim_next(&kinds, 0, lease, CLAIMED_BY)
.await
.expect("claim")
.expect("a claimable job");
drain(rx).await;
(id, job.run_count)
}
#[tokio::test]
#[ignore = "requires Docker"]
async fn enqueue_notifies() {
let db = TestDb::start().await;
let store = db.store("venturi").await;
let (_listener, mut rx) = listen(&db.dsn(), "venturi_jobs").await;
store.enqueue(&new_job(Ulid::new())).await.expect("enqueue");
assert!(
notified_within(&mut rx, 1000).await,
"enqueue did not emit a NOTIFY"
);
}
#[tokio::test]
#[ignore = "requires Docker"]
async fn retry_settlement_notifies() {
let db = TestDb::start().await;
let store = db.store("venturi").await;
let (_listener, mut rx) = listen(&db.dsn(), "venturi_jobs").await;
let (id, run_no) = enqueue_and_claim(&store, &mut rx, Duration::from_secs(60)).await;
store
.settle(
id,
CLAIMED_BY,
Settlement::Retry {
visible_at: Utc::now(),
failure_count: 1,
carry: serde_json::Value::Null,
},
journal(run_no, JournalOutcome::Retried),
)
.await
.expect("settle retry");
assert!(
notified_within(&mut rx, 1000).await,
"retry settlement did not emit a NOTIFY"
);
}
#[tokio::test]
#[ignore = "requires Docker"]
async fn pause_settlement_notifies() {
let db = TestDb::start().await;
let store = db.store("venturi").await;
let (_listener, mut rx) = listen(&db.dsn(), "venturi_jobs").await;
let (id, run_no) = enqueue_and_claim(&store, &mut rx, Duration::from_secs(60)).await;
store
.settle(
id,
CLAIMED_BY,
Settlement::Pause {
visible_at: Utc::now(),
carry: serde_json::Value::Null,
},
journal(run_no, JournalOutcome::Paused),
)
.await
.expect("settle pause");
assert!(
notified_within(&mut rx, 1000).await,
"pause settlement did not emit a NOTIFY"
);
}
#[tokio::test]
#[ignore = "requires Docker"]
async fn release_settlement_notifies() {
let db = TestDb::start().await;
let store = db.store("venturi").await;
let (_listener, mut rx) = listen(&db.dsn(), "venturi_jobs").await;
let (id, run_no) = enqueue_and_claim(&store, &mut rx, Duration::from_secs(60)).await;
store
.settle(
id,
CLAIMED_BY,
Settlement::Release {
visible_at: Utc::now(),
},
journal(run_no, JournalOutcome::Released),
)
.await
.expect("settle release");
assert!(
notified_within(&mut rx, 1000).await,
"release settlement did not emit a NOTIFY"
);
}
#[tokio::test]
#[ignore = "requires Docker"]
async fn complete_settlement_does_not_notify() {
let db = TestDb::start().await;
let store = db.store("venturi").await;
let (_listener, mut rx) = listen(&db.dsn(), "venturi_jobs").await;
let (id, run_no) = enqueue_and_claim(&store, &mut rx, Duration::from_secs(60)).await;
store
.settle(
id,
CLAIMED_BY,
Settlement::Complete {
finished_at: Utc::now(),
},
journal(run_no, JournalOutcome::Completed),
)
.await
.expect("settle complete");
assert!(
!notified_within(&mut rx, 500).await,
"a terminal completion must not emit a NOTIFY"
);
}
#[tokio::test]
#[ignore = "requires Docker"]
async fn dead_settlement_does_not_notify() {
let db = TestDb::start().await;
let store = db.store("venturi").await;
let (_listener, mut rx) = listen(&db.dsn(), "venturi_jobs").await;
let (id, run_no) = enqueue_and_claim(&store, &mut rx, Duration::from_secs(60)).await;
store
.settle(
id,
CLAIMED_BY,
Settlement::Dead {
finished_at: Utc::now(),
failure_count: 3,
},
journal(run_no, JournalOutcome::Dead),
)
.await
.expect("settle dead");
assert!(
!notified_within(&mut rx, 500).await,
"a terminal dead must not emit a NOTIFY"
);
}
#[tokio::test]
#[ignore = "requires Docker"]
async fn recover_notifies() {
let db = TestDb::start().await;
let store = db.store("venturi").await;
let (_listener, mut rx) = listen(&db.dsn(), "venturi_jobs").await;
let (id, run_no) = enqueue_and_claim(&store, &mut rx, Duration::from_millis(100)).await;
tokio::time::sleep(Duration::from_millis(400)).await;
let recovered = store
.recover(
id,
Utc::now(),
run_no + 1,
journal(run_no, JournalOutcome::StaleRecovered),
)
.await
.expect("recover");
assert!(recovered, "the stale claim should have been recovered");
assert!(
notified_within(&mut rx, 1000).await,
"stale-claim recovery did not emit a NOTIFY"
);
}