mod helpers;
use futures::stream::StreamExt;
use obix::{EventSequence, MailboxConfig, PartitionMaintainerConfig, out::Outbox};
use serde::{Deserialize, Serialize};
use serial_test::file_serial;
use sqlx::Row;
use helpers::{
TestTables, init_outbox, init_pool, wipeout_outbox_job_tables, wipeout_outbox_tables,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
enum TestEvent {
Ping(u64),
}
const WIDTH: u64 = obix::DEFAULT_PARTITION_WIDTH; const PREMAKE: u64 = obix::DEFAULT_PARTITION_PREMAKE;
const BOUNDARY: i64 = WIDTH as i64;
async fn reset_partitions_to_baseline(pool: &sqlx::PgPool) -> anyhow::Result<()> {
sqlx::query("DROP TABLE IF EXISTS persistent_outbox_events_default_old")
.execute(pool)
.await?;
let children = sqlx::query(
"SELECT c.relname FROM pg_inherits i \
JOIN pg_class c ON c.oid = i.inhrelid \
WHERE i.inhparent = 'persistent_outbox_events'::regclass \
AND c.relname NOT IN ('persistent_outbox_events_p0', 'persistent_outbox_events_default')",
)
.fetch_all(pool)
.await?;
for row in children {
let name: String = row.get("relname");
sqlx::query(&format!("DROP TABLE IF EXISTS {name}"))
.execute(pool)
.await?;
}
sqlx::query(
"CREATE TABLE IF NOT EXISTS persistent_outbox_events_default \
PARTITION OF persistent_outbox_events DEFAULT",
)
.execute(pool)
.await?;
Ok(())
}
async fn set_sequence(pool: &sqlx::PgPool, value: i64) -> anyhow::Result<()> {
sqlx::query("SELECT setval('persistent_outbox_events_sequence_seq', $1)")
.bind(value)
.execute(pool)
.await?;
Ok(())
}
async fn relation_exists(pool: &sqlx::PgPool, name: &str) -> anyhow::Result<bool> {
let row = sqlx::query("SELECT to_regclass($1) IS NOT NULL AS present")
.bind(name)
.fetch_one(pool)
.await?;
Ok(row.get::<bool, _>("present"))
}
async fn default_row_count(pool: &sqlx::PgPool) -> anyhow::Result<i64> {
let row = sqlx::query("SELECT COUNT(*) AS n FROM persistent_outbox_events_default")
.fetch_one(pool)
.await?;
Ok(row.get::<i64, _>("n"))
}
async fn max_sequence(pool: &sqlx::PgPool) -> anyhow::Result<Option<i64>> {
let row = sqlx::query("SELECT MAX(sequence) AS m FROM persistent_outbox_events")
.fetch_one(pool)
.await?;
Ok(row.get::<Option<i64>, _>("m"))
}
async fn reloptions(pool: &sqlx::PgPool, relname: &str) -> anyhow::Result<String> {
let row = sqlx::query(
"SELECT COALESCE(array_to_string(reloptions, ','), '') AS opts \
FROM pg_class WHERE relname = $1",
)
.bind(relname)
.fetch_one(pool)
.await?;
Ok(row.get::<String, _>("opts"))
}
async fn prepare_outbox(
pool: &sqlx::PgPool,
head: i64,
) -> anyhow::Result<Outbox<TestEvent, TestTables>> {
wipeout_outbox_tables(pool).await?;
reset_partitions_to_baseline(pool).await?;
set_sequence(pool, head).await?;
let outbox = Outbox::<TestEvent, TestTables>::init(
pool,
MailboxConfig::builder()
.build()
.expect("Couldn't build MailboxConfig"),
)
.await?;
Ok(outbox)
}
#[tokio::test]
#[file_serial]
async fn maintainer_premakes_partitions_ahead() -> anyhow::Result<()> {
let pool = init_pool().await?;
let job_type = "test-partition-maintainer";
wipeout_outbox_tables(&pool).await?;
wipeout_outbox_job_tables(&pool, job_type).await?;
reset_partitions_to_baseline(&pool).await?;
set_sequence(&pool, BOUNDARY - 10).await?;
assert!(
!relation_exists(&pool, "persistent_outbox_events_p1").await?,
"p1 must not exist before registration"
);
let job_config = job::JobSvcConfig::builder()
.pool(pool.clone())
.build()
.unwrap();
let mut jobs = job::Jobs::init(job_config).await?;
let outbox = Outbox::<TestEvent, TestTables>::init(
&pool,
MailboxConfig::builder().build().expect("config"),
)
.await?;
outbox
.register_partition_maintainer(
&mut jobs,
PartitionMaintainerConfig::new(job::JobType::new(job_type)),
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
assert!(
relation_exists(&pool, "persistent_outbox_events_p1").await?,
"p1 pre-created before the head reaches the boundary"
);
let deepest = format!("persistent_outbox_events_p{PREMAKE}");
assert!(
relation_exists(&pool, &deepest).await?,
"premake keeps {PREMAKE} partitions ahead ({deepest})"
);
let opts = reloptions(&pool, "persistent_outbox_events_p1").await?;
assert!(
opts.contains("autovacuum_freeze_min_age=0"),
"freeze param present: {opts}"
);
assert!(
opts.contains("autovacuum_vacuum_insert_scale_factor=0"),
"insert-vacuum param present: {opts}"
);
let _ = jobs.shutdown().await;
Ok(())
}
#[tokio::test]
#[file_serial]
async fn default_fill_then_recover() -> anyhow::Result<()> {
let pool = init_pool().await?;
let outbox = prepare_outbox(&pool, BOUNDARY - 2).await?;
let mut op = outbox.begin_op().await?;
outbox
.publish_all_persisted(
&mut op,
[
TestEvent::Ping(0), TestEvent::Ping(1), TestEvent::Ping(2), ],
)
.await?;
op.commit().await?;
assert_eq!(
default_row_count(&pool).await?,
2,
"two rows stranded in DEFAULT"
);
let max_before = max_sequence(&pool).await?;
assert_eq!(max_before, Some(BOUNDARY + 1));
assert_replayable(&pool).await?;
obix::out::Partitions::<TestTables>::new(&pool, PREMAKE)
.recover_default()
.await?;
assert_eq!(default_row_count(&pool).await?, 0, "DEFAULT drained");
assert!(
relation_exists(&pool, "persistent_outbox_events_p1").await?,
"stranded rows moved into explicit p1"
);
assert_eq!(
max_sequence(&pool).await?,
max_before,
"MAX(sequence) never regressed"
);
assert!(!relation_exists(&pool, "persistent_outbox_events_default_old").await?);
assert_replayable(&pool).await?;
Ok(())
}
async fn assert_replayable(pool: &sqlx::PgPool) -> anyhow::Result<()> {
let replay = Outbox::<TestEvent, TestTables>::init(
pool,
MailboxConfig::builder().build().expect("config"),
)
.await?;
let mut listener = replay.listen_persisted(Some(EventSequence::from((BOUNDARY - 2) as u64)));
for (i, expected_seq) in [BOUNDARY - 1, BOUNDARY, BOUNDARY + 1]
.into_iter()
.enumerate()
{
let ev = tokio::time::timeout(std::time::Duration::from_secs(3), listener.next())
.await?
.expect("replayed event")?;
assert_eq!(ev.sequence, EventSequence::from(expected_seq as u64));
assert_eq!(ev.payload, Some(TestEvent::Ping(i as u64)));
}
Ok(())
}
#[tokio::test]
#[file_serial]
async fn concurrent_ensure_does_not_race() -> anyhow::Result<()> {
let pool = init_pool().await?;
wipeout_outbox_tables(&pool).await?;
reset_partitions_to_baseline(&pool).await?;
set_sequence(&pool, BOUNDARY - 10).await?;
let results = futures::future::join_all((0..8).map(|_| {
let pool = pool.clone();
async move {
obix::out::Partitions::<TestTables>::new(&pool, PREMAKE)
.ensure()
.await
}
}))
.await;
for r in results {
r?;
}
let deepest = format!("persistent_outbox_events_p{PREMAKE}");
assert!(relation_exists(&pool, &deepest).await?);
Ok(())
}
#[tokio::test]
#[file_serial]
async fn replay_from_begin_still_contiguous() -> anyhow::Result<()> {
let pool = init_pool().await?;
let outbox =
init_outbox::<TestEvent>(&pool, MailboxConfig::builder().build().expect("config")).await?;
reset_partitions_to_baseline(&pool).await?;
let mut op = outbox.begin_op().await?;
outbox
.publish_all_persisted(
&mut op,
[TestEvent::Ping(0), TestEvent::Ping(1), TestEvent::Ping(2)],
)
.await?;
op.commit().await?;
let replay = Outbox::<TestEvent, TestTables>::init(
&pool,
MailboxConfig::builder().build().expect("config"),
)
.await?;
let mut listener = replay.listen_persisted(EventSequence::BEGIN);
for i in 0..3u64 {
let ev = tokio::time::timeout(std::time::Duration::from_secs(3), listener.next())
.await?
.expect("replayed event")?;
assert_eq!(ev.sequence, EventSequence::from(i + 1));
assert_eq!(ev.payload, Some(TestEvent::Ping(i)));
}
Ok(())
}