use chrono::{SubsecRound, Utc};
use skilj_core::bootstrap::ContextCreator;
use skilj_core::command_batcher::CommandBatcher;
use skilj_core::db::{self, Pool, SubmitCommandOutcome};
use skilj_core::event_cache::EventCache;
use skilj_core::event_store::{
BoundedContext, BoundedContextStatus, CommandType, Event, EventBroadcaster, EventType,
};
use skilj_core::plugin::{CommandDispatcher, ProjectionDispatcher};
use skilj_core::shared::{generate_token_id, CommandDecision, EventSpec, TagMapping};
use std::sync::Arc;
struct ShipOrderDispatcher;
impl CommandDispatcher for ShipOrderDispatcher {
fn dispatch(
&self,
_bounded_context: &str,
_command_type: &str,
payload: &str,
matching_events: &[Event],
) -> Option<skilj_core::error::Result<CommandDecision>> {
if matching_events.is_empty() {
Some(Ok(CommandDecision::Accepted {
events: vec![EventSpec {
event_type: "OrderShipped".to_string(),
payload: serde_json::from_str(payload).expect("test payload is always JSON"),
}],
}))
} else {
Some(Ok(CommandDecision::Rejected {
reason: "already shipped".to_string(),
kind: "already_shipped".to_string(),
}))
}
}
fn required_role(
&self,
_bounded_context: &str,
_command_type: &str,
) -> Option<Option<&'static str>> {
Some(None)
}
fn snapshot_name(
&self,
_bounded_context: &str,
_command_type: &str,
) -> Option<Option<&'static str>> {
Some(None)
}
fn dispatch_from_snapshot(
&self,
_bounded_context: &str,
_command_type: &str,
_payload: &str,
_snapshot_state_json: &str,
_events_since_snapshot: &[Event],
_context: &skilj_core::plugin::DecisionContext,
) -> Option<skilj_core::error::Result<CommandDecision>> {
unreachable!("this fixture never opts into snapshot()")
}
}
struct NoopProjectionDispatcher;
impl ProjectionDispatcher for NoopProjectionDispatcher {
fn keys(&self, _: &str, _: &str, _: &Event) -> Option<Vec<String>> {
None
}
fn project(
&self,
_: &str,
_: &str,
_: &str,
_: &Event,
_: &str,
) -> Option<skilj_core::error::Result<String>> {
None
}
fn default_state(&self, _: &str, _: &str) -> Option<String> {
None
}
fn owner_tag_key(&self, _: &str, _: &str) -> Option<Option<&'static str>> {
None
}
fn requires_capability(&self, _: &str, _: &str) -> Option<Option<&'static str>> {
None
}
}
struct TestDb {
pool: Pool,
}
static TEST_DB: tokio::sync::OnceCell<Option<TestDb>> = tokio::sync::OnceCell::const_new();
fn runtime() -> &'static tokio::runtime::Runtime {
static RUNTIME: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
RUNTIME.get_or_init(|| {
tokio::runtime::Runtime::new()
.expect("failed to build a tokio runtime for command_batcher tests")
})
}
async fn test_pool() -> Option<Pool> {
TEST_DB
.get_or_init(provision)
.await
.as_ref()
.map(|db| db.pool.clone())
}
async fn provision() -> Option<TestDb> {
let pool_options = db::PgPoolOptions::new().max_connections(50);
let url = skilj_test_support::database_url("skilj_command_batcher_test").await?;
let pool = match db::connect_with(&url, pool_options).await {
Ok(pool) => pool,
Err(e) => {
eprintln!("skipping: connecting to the test database failed: {e}");
return None;
}
};
if let Err(e) = db::migrate(&pool).await {
eprintln!("skipping: migrating the test database failed: {e}");
return None;
}
Some(TestDb { pool })
}
fn unique_name(prefix: &str) -> String {
format!("{prefix}_{}", generate_token_id())
}
fn test_now() -> chrono::DateTime<Utc> {
Utc::now().trunc_subsecs(6)
}
async fn seed_bounded_context(pool: &Pool) -> BoundedContext {
let bc = BoundedContext {
name: unique_name("bc"),
status: BoundedContextStatus::Active,
created_at: test_now(),
created_by: ContextCreator::SystemCreator,
template: None,
};
db::insert_bounded_context(pool, &bc).await.unwrap();
bc
}
async fn seed_order_shipped_event_type(pool: &Pool, bc: &BoundedContext) -> EventType {
let et = EventType {
bounded_context: bc.clone(),
name: "OrderShipped".to_string(),
schema: r#"{"properties":{"order_id":{"type":"string"}}}"#.to_string(),
schema_version: 1,
tag_mappings: vec![TagMapping {
key: "order".to_string(),
field: "order_id".to_string(),
blind: false,
}],
owner_tag_key: None,
sensitive_fields: Vec::new(),
private_fields: Vec::new(),
external_creation_allowed: true,
direct_creation_allowed: true,
system_triggered_allowed: false,
system_triggered_schedule: None,
missed_occurrence_policy: None,
schedule_position: None,
last_fired_at: None,
event_read_allowed: true,
};
db::upsert_event_type(pool, &et).await.unwrap();
et
}
async fn seed_ship_order_command_type(pool: &Pool, bc: &BoundedContext) -> CommandType {
let ct = CommandType {
bounded_context: bc.clone(),
name: "ShipOrder".to_string(),
schema: r#"{"properties":{"order_id":{"type":"string"}}}"#.to_string(),
schema_version: 1,
tag_mappings: vec![TagMapping {
key: "order".to_string(),
field: "order_id".to_string(),
blind: false,
}],
owner_tag_key: None,
sensitive_fields: Vec::new(),
private_fields: Vec::new(),
rest_trigger_allowed: true,
};
db::upsert_command_type(pool, &ct).await.unwrap();
ct
}
async fn submit(
pool: &Pool,
batcher: &CommandBatcher,
dispatcher: &ShipOrderDispatcher,
broadcaster: &EventBroadcaster,
event_cache: &EventCache,
command_type: &CommandType,
payload: &str,
) -> skilj_core::error::Result<SubmitCommandOutcome> {
let consistency_tags =
skilj_core::event_store::derive_plain_tags(&command_type.tag_mappings, payload);
let bounded_context_events = db::list_events_for_bounded_context_matching_tags(
pool,
&command_type.bounded_context.name,
&consistency_tags,
None,
)
.await
.unwrap();
let (_boundary, matching_events) =
skilj_core::event_store::consistency_boundary_and_matching_events(
&bounded_context_events,
&consistency_tags,
);
let decision = dispatcher
.dispatch(
&command_type.bounded_context.name,
&command_type.name,
payload,
&matching_events,
)
.unwrap()
.unwrap();
let command_dispatcher: Arc<dyn CommandDispatcher> = Arc::new(ShipOrderDispatcher);
let projection_dispatcher: Arc<dyn ProjectionDispatcher> = Arc::new(NoopProjectionDispatcher);
batcher
.submit(
pool,
&command_dispatcher,
&projection_dispatcher,
broadcaster,
event_cache,
command_type,
payload,
"client-1",
None,
None,
&bounded_context_events,
&consistency_tags,
&matching_events,
decision,
None,
test_now(),
None,
&[],
None,
None,
)
.await
}
#[test]
fn concurrent_submits_for_the_same_order_accept_exactly_one_and_reject_the_rest() {
runtime().block_on(async {
let Some(pool) = test_pool().await else {
return;
};
let bc = seed_bounded_context(&pool).await;
seed_order_shipped_event_type(&pool, &bc).await;
let ct = seed_ship_order_command_type(&pool, &bc).await;
let batcher = CommandBatcher::new();
let dispatcher = Arc::new(ShipOrderDispatcher);
let broadcaster = Arc::new(EventBroadcaster::new(64));
let event_cache = Arc::new(EventCache::new(1000));
const CONCURRENCY: usize = 25;
let mut handles = Vec::with_capacity(CONCURRENCY);
for _ in 0..CONCURRENCY {
let pool = pool.clone();
let batcher = batcher.clone();
let dispatcher = dispatcher.clone();
let broadcaster = broadcaster.clone();
let event_cache = event_cache.clone();
let ct = ct.clone();
handles.push(tokio::spawn(async move {
submit(
&pool,
&batcher,
&dispatcher,
&broadcaster,
&event_cache,
&ct,
r#"{"order_id":"A"}"#,
)
.await
}));
}
let mut accepted = 0;
let mut rejected = 0;
for handle in handles {
match handle.await.unwrap().unwrap() {
SubmitCommandOutcome::Accepted { .. } => accepted += 1,
SubmitCommandOutcome::Rejected { reason, .. } => {
assert_eq!(reason, "already shipped");
rejected += 1;
}
other => panic!("ShipOrder never produces {other:?}"),
}
}
assert_eq!(accepted, 1);
assert_eq!(rejected, CONCURRENCY - 1);
let events = db::list_events_for_bounded_context(&pool, &bc.name)
.await
.unwrap();
assert_eq!(events.len(), 1);
});
}
#[test]
fn concurrent_submits_for_different_orders_all_accept_with_gapless_distinct_sequences() {
runtime().block_on(async {
let Some(pool) = test_pool().await else {
return;
};
let bc = seed_bounded_context(&pool).await;
seed_order_shipped_event_type(&pool, &bc).await;
let ct = seed_ship_order_command_type(&pool, &bc).await;
let batcher = CommandBatcher::new();
let dispatcher = Arc::new(ShipOrderDispatcher);
let broadcaster = Arc::new(EventBroadcaster::new(64));
let event_cache = Arc::new(EventCache::new(1000));
const CONCURRENCY: usize = 25;
let mut handles = Vec::with_capacity(CONCURRENCY);
for i in 0..CONCURRENCY {
let pool = pool.clone();
let batcher = batcher.clone();
let dispatcher = dispatcher.clone();
let broadcaster = broadcaster.clone();
let event_cache = event_cache.clone();
let ct = ct.clone();
handles.push(tokio::spawn(async move {
submit(
&pool,
&batcher,
&dispatcher,
&broadcaster,
&event_cache,
&ct,
&format!(r#"{{"order_id":"order-{i}"}}"#),
)
.await
}));
}
let mut sequences: Vec<i64> = Vec::with_capacity(CONCURRENCY);
for handle in handles {
match handle.await.unwrap().unwrap() {
SubmitCommandOutcome::Accepted { events, .. } => {
assert_eq!(events.len(), 1);
sequences.push(events[0].sequence);
}
other => panic!("every distinct order must be accepted, got {other:?}"),
}
}
sequences.sort_unstable();
let expected: Vec<i64> = (0..CONCURRENCY as i64).collect();
assert_eq!(
sequences, expected,
"every accepted event's own sequence must be distinct and gapless, \
regardless of how many landed in the same batch"
);
let events = db::list_events_for_bounded_context(&pool, &bc.name)
.await
.unwrap();
assert_eq!(events.len(), CONCURRENCY);
});
}
#[test]
fn a_batch_leader_stuck_past_the_idle_in_transaction_timeout_is_killed_and_releases_the_lock() {
runtime().block_on(async {
let Some(pool) = test_pool().await else {
return;
};
let bc = seed_bounded_context(&pool).await;
seed_order_shipped_event_type(&pool, &bc).await;
let ct = seed_ship_order_command_type(&pool, &bc).await;
let stuck_leader_tx = db::begin_command_batch_leader_tx(
&pool,
&bc.name,
Some(std::time::Duration::from_millis(200)),
)
.await
.expect("acquiring the lock for the stuck leader must succeed");
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
let batcher = CommandBatcher::new();
let dispatcher = ShipOrderDispatcher;
let broadcaster = EventBroadcaster::new(64);
let event_cache = EventCache::new(1000);
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(5),
submit(
&pool,
&batcher,
&dispatcher,
&broadcaster,
&event_cache,
&ct,
r#"{"order_id":"A"}"#,
),
)
.await
.expect(
"a fresh submission to the same bounded context must not hang behind a killed \
leader's stale lock - Postgres terminating the idle session must have released it",
)
.unwrap();
assert!(
matches!(outcome, SubmitCommandOutcome::Accepted { .. }),
"expected the fresh submission to be accepted, got {outcome:?}"
);
drop(stuck_leader_tx);
});
}
#[test]
fn more_queued_commands_than_one_batch_holds_are_all_still_answered() {
runtime().block_on(async {
let Some(pool) = test_pool().await else {
return;
};
let bc = seed_bounded_context(&pool).await;
seed_order_shipped_event_type(&pool, &bc).await;
let ct = seed_ship_order_command_type(&pool, &bc).await;
let held = db::begin_command_batch_leader_tx(&pool, &bc.name, None)
.await
.unwrap();
let batcher = CommandBatcher::new();
let dispatcher = Arc::new(ShipOrderDispatcher);
let broadcaster = Arc::new(EventBroadcaster::new(64));
let event_cache = Arc::new(EventCache::new(1000));
const CONCURRENCY: usize = 300;
let mut handles = Vec::with_capacity(CONCURRENCY);
for i in 0..CONCURRENCY {
let pool = pool.clone();
let batcher = batcher.clone();
let dispatcher = dispatcher.clone();
let broadcaster = broadcaster.clone();
let event_cache = event_cache.clone();
let ct = ct.clone();
handles.push(tokio::spawn(async move {
submit(
&pool,
&batcher,
&dispatcher,
&broadcaster,
&event_cache,
&ct,
&format!(r#"{{"order_id":"order-{i}"}}"#),
)
.await
}));
}
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
drop(held);
for handle in handles {
let outcome = tokio::time::timeout(std::time::Duration::from_secs(60), handle)
.await
.expect("a queued command was stranded past the batch-size cap")
.unwrap()
.unwrap();
assert!(matches!(outcome, SubmitCommandOutcome::Accepted { .. }));
}
let events = db::list_events_for_bounded_context(&pool, &bc.name)
.await
.unwrap();
assert_eq!(events.len(), CONCURRENCY);
});
}
#[test]
fn a_cancelled_batch_leader_does_not_strand_followers_or_wedge_the_queue() {
runtime().block_on(async {
let Some(pool) = test_pool().await else {
return;
};
let bc = seed_bounded_context(&pool).await;
seed_order_shipped_event_type(&pool, &bc).await;
let ct = seed_ship_order_command_type(&pool, &bc).await;
let held = db::begin_command_batch_leader_tx(&pool, &bc.name, None)
.await
.unwrap();
let batcher = CommandBatcher::new();
let dispatcher = Arc::new(ShipOrderDispatcher);
let broadcaster = Arc::new(EventBroadcaster::new(64));
let event_cache = Arc::new(EventCache::new(1000));
let spawn_submit = |order: &'static str| {
let pool = pool.clone();
let batcher = batcher.clone();
let dispatcher = dispatcher.clone();
let broadcaster = broadcaster.clone();
let event_cache = event_cache.clone();
let ct = ct.clone();
tokio::spawn(async move {
submit(
&pool,
&batcher,
&dispatcher,
&broadcaster,
&event_cache,
&ct,
&format!(r#"{{"order_id":"{order}"}}"#),
)
.await
})
};
let leader = spawn_submit("A");
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let follower = spawn_submit("B");
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
leader.abort();
let _ = leader.await;
drop(held);
let follower_outcome = tokio::time::timeout(std::time::Duration::from_secs(10), follower)
.await
.expect("a follower must be answered when its leader is cancelled")
.unwrap();
assert!(
matches!(follower_outcome, Ok(SubmitCommandOutcome::Accepted { .. })),
"unexpected follower outcome: {follower_outcome:?}"
);
let fresh = tokio::time::timeout(std::time::Duration::from_secs(10), spawn_submit("C"))
.await
.expect("the queue must not stay wedged after a cancelled leader")
.unwrap()
.unwrap();
assert!(matches!(fresh, SubmitCommandOutcome::Accepted { .. }));
});
}
#[test]
fn a_small_configured_max_batch_size_still_answers_every_queued_command() {
runtime().block_on(async {
let Some(pool) = test_pool().await else {
return;
};
let bc = seed_bounded_context(&pool).await;
seed_order_shipped_event_type(&pool, &bc).await;
let ct = seed_ship_order_command_type(&pool, &bc).await;
let held = db::begin_command_batch_leader_tx(&pool, &bc.name, None)
.await
.unwrap();
let batcher = CommandBatcher::new()
.with_max_batch_size(3)
.with_max_concurrent_leaders(1);
let dispatcher = Arc::new(ShipOrderDispatcher);
let broadcaster = Arc::new(EventBroadcaster::new(64));
let event_cache = Arc::new(EventCache::new(1000));
const CONCURRENCY: usize = 20;
let mut handles = Vec::new();
for i in 0..CONCURRENCY {
let (pool, batcher, dispatcher, broadcaster, event_cache, ct) = (
pool.clone(),
batcher.clone(),
dispatcher.clone(),
broadcaster.clone(),
event_cache.clone(),
ct.clone(),
);
handles.push(tokio::spawn(async move {
submit(
&pool,
&batcher,
&dispatcher,
&broadcaster,
&event_cache,
&ct,
&format!(r#"{{"order_id":"order-{i}"}}"#),
)
.await
}));
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
drop(held);
for handle in handles {
let outcome = tokio::time::timeout(std::time::Duration::from_secs(30), handle)
.await
.expect("a queued command was stranded")
.unwrap()
.unwrap();
assert!(matches!(outcome, SubmitCommandOutcome::Accepted { .. }));
}
let events = db::list_events_for_bounded_context(&pool, &bc.name)
.await
.unwrap();
assert_eq!(events.len(), CONCURRENCY);
});
}
#[test]
fn random_client_disconnects_under_load_never_wedge_the_queue() {
runtime().block_on(async {
let Some(pool) = test_pool().await else {
return;
};
let bc = seed_bounded_context(&pool).await;
seed_order_shipped_event_type(&pool, &bc).await;
let ct = seed_ship_order_command_type(&pool, &bc).await;
let batcher = CommandBatcher::new().with_max_batch_size(8);
let dispatcher = Arc::new(ShipOrderDispatcher);
let broadcaster = Arc::new(EventBroadcaster::new(64));
let event_cache = Arc::new(EventCache::new(1000));
const CONCURRENCY: usize = 90;
let mut survivors = Vec::new();
for i in 0..CONCURRENCY {
let (pool, batcher, dispatcher, broadcaster, event_cache, ct) = (
pool.clone(),
batcher.clone(),
dispatcher.clone(),
broadcaster.clone(),
event_cache.clone(),
ct.clone(),
);
let handle = tokio::spawn(async move {
submit(
&pool,
&batcher,
&dispatcher,
&broadcaster,
&event_cache,
&ct,
&format!(r#"{{"order_id":"churn-{i}"}}"#),
)
.await
});
if i % 3 == 0 {
let abort = handle.abort_handle();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis((i as u64 % 7) * 4)).await;
abort.abort();
});
} else {
survivors.push(handle);
}
}
for handle in survivors {
let outcome = tokio::time::timeout(std::time::Duration::from_secs(60), handle)
.await
.expect("a surviving submitter hung after other clients disconnected")
.unwrap();
assert!(
matches!(outcome, Ok(SubmitCommandOutcome::Accepted { .. })),
"unexpected outcome: {outcome:?}"
);
}
let fresh = tokio::time::timeout(
std::time::Duration::from_secs(15),
submit(
&pool,
&batcher,
&dispatcher,
&broadcaster,
&event_cache,
&ct,
r#"{"order_id":"after-churn"}"#,
),
)
.await
.expect("the queue is wedged after client-disconnect churn")
.unwrap();
assert!(matches!(fresh, SubmitCommandOutcome::Accepted { .. }));
});
}
#[test]
fn a_leader_cancelled_mid_batch_still_commits_and_answers_its_followers() {
runtime().block_on(async {
let Some(pool) = test_pool().await else {
return;
};
let bc = seed_bounded_context(&pool).await;
seed_order_shipped_event_type(&pool, &bc).await;
let ct = seed_ship_order_command_type(&pool, &bc).await;
let schema = format!("\"bc_{}\"", bc.name);
sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
"CREATE FUNCTION {schema}.slow_insert() RETURNS trigger AS $$ \
BEGIN PERFORM pg_sleep(0.4); RETURN NEW; END $$ LANGUAGE plpgsql; \
CREATE TRIGGER slow_insert BEFORE INSERT ON {schema}.events \
FOR EACH ROW EXECUTE FUNCTION {schema}.slow_insert();"
)))
.execute(&pool)
.await
.unwrap();
let held = db::begin_command_batch_leader_tx(&pool, &bc.name, None)
.await
.unwrap();
let batcher = CommandBatcher::new();
let dispatcher = Arc::new(ShipOrderDispatcher);
let broadcaster = Arc::new(EventBroadcaster::new(64));
let event_cache = Arc::new(EventCache::new(1000));
let spawn_submit = |order: &'static str| {
let pool = pool.clone();
let batcher = batcher.clone();
let dispatcher = dispatcher.clone();
let broadcaster = broadcaster.clone();
let event_cache = event_cache.clone();
let ct = ct.clone();
tokio::spawn(async move {
submit(
&pool,
&batcher,
&dispatcher,
&broadcaster,
&event_cache,
&ct,
&format!(r#"{{"order_id":"{order}"}}"#),
)
.await
})
};
let leader = spawn_submit("A");
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let follower = spawn_submit("B");
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
drop(held);
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
leader.abort();
let _ = leader.await;
let follower_outcome = tokio::time::timeout(std::time::Duration::from_secs(10), follower)
.await
.expect("the follower must be answered")
.unwrap();
assert!(
matches!(follower_outcome, Ok(SubmitCommandOutcome::Accepted { .. })),
"expected the follower's command to be accepted, got {follower_outcome:?}"
);
let events = db::list_events_for_bounded_context(&pool, &bc.name)
.await
.unwrap();
let mut orders: Vec<String> = events
.iter()
.map(|e| {
serde_json::from_str::<serde_json::Value>(&e.payload).unwrap()["order_id"]
.as_str()
.unwrap()
.to_string()
})
.collect();
orders.sort();
assert_eq!(orders, vec!["A", "B"]);
});
}
struct NoSnapshots;
impl skilj_core::plugin::SnapshotDispatcher for NoSnapshots {
fn snapshot_names(&self, _: &str) -> Vec<&'static str> {
Vec::new()
}
fn tag_key(&self, _: &str, _: &str) -> Option<&'static str> {
None
}
fn owner_tag_key(&self, _: &str, _: &str) -> Option<Option<&'static str>> {
None
}
fn version(&self, _: &str, _: &str) -> Option<u64> {
None
}
fn fold(
&self,
_: &str,
_: &str,
_: &str,
_: &Event,
) -> Option<skilj_core::error::Result<String>> {
None
}
fn default_state(&self, _: &str, _: &str) -> Option<String> {
None
}
}
#[derive(Clone, Default)]
struct Warnings(Arc<std::sync::Mutex<Vec<String>>>);
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Warnings {
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
struct Fields(String);
impl tracing::field::Visit for Fields {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0.push_str(&format!("{}={value:?} ", field.name()));
}
}
if *event.metadata().level() == tracing::Level::WARN
&& event.metadata().target().starts_with("skilj_core")
{
let mut fields = Fields(String::new());
event.record(&mut fields);
self.0.lock().unwrap().push(fields.0);
}
}
}
#[test]
fn a_slow_read_or_decide_before_the_lock_warns_once_per_command_type_and_interval() {
use tracing_subscriber::layer::SubscriberExt;
let warnings = Warnings::default();
let _guard =
tracing::subscriber::set_default(tracing_subscriber::registry().with(warnings.clone()));
runtime().block_on(async {
let Some(pool) = test_pool().await else {
return;
};
let bc = seed_bounded_context(&pool).await;
seed_order_shipped_event_type(&pool, &bc).await;
let ct = seed_ship_order_command_type(&pool, &bc).await;
let command_dispatcher: Arc<dyn CommandDispatcher> = Arc::new(ShipOrderDispatcher);
let projection_dispatcher: Arc<dyn ProjectionDispatcher> =
Arc::new(NoopProjectionDispatcher);
let broadcaster = EventBroadcaster::new(64);
let event_cache = EventCache::new(1000);
let submit_with = |batcher: CommandBatcher, order: &'static str| {
let (pool, ct) = (pool.clone(), ct.clone());
let (command_dispatcher, projection_dispatcher) =
(command_dispatcher.clone(), projection_dispatcher.clone());
let (broadcaster, event_cache) = (&broadcaster, &event_cache);
async move {
batcher
.decide_and_submit(
&pool,
&command_dispatcher,
&projection_dispatcher,
&NoSnapshots,
broadcaster,
event_cache,
&ct,
&format!(r#"{{"order_id":"{order}"}}"#),
"test-client",
None,
None,
None,
Utc::now().trunc_subsecs(6),
None,
)
.await
.unwrap();
}
};
warnings.0.lock().unwrap().clear();
let quiet = CommandBatcher::new()
.with_slow_read_warning(None)
.with_slow_decide_warning(None);
submit_with(quiet, "quiet").await;
let logged = warnings.0.lock().unwrap().clone();
assert!(logged.is_empty(), "{logged:?}");
let batcher = CommandBatcher::new()
.with_slow_read_warning(Some(std::time::Duration::ZERO))
.with_slow_decide_warning(Some(std::time::Duration::ZERO));
submit_with(batcher.clone(), "first").await;
submit_with(batcher.clone(), "second").await;
let logged = warnings.0.lock().unwrap().clone();
assert_eq!(logged.len(), 2, "{logged:?}");
let read = logged
.iter()
.find(|w| w.contains("read_ms"))
.expect("a read warning");
assert!(read.contains("command_type=ShipOrder"), "{read}");
assert!(read.contains("events_read=0"), "{read}");
assert!(read.contains("more_since_last_warning=0"), "{read}");
let decide = logged
.iter()
.find(|w| w.contains("decide_ms"))
.expect("a decide warning");
assert!(decide.contains("under_lock=false"), "{decide}");
});
}
async fn submit_many(
pool: &Pool,
batcher: &CommandBatcher,
command_type: &CommandType,
orders: &[&str],
) -> Vec<skilj_core::error::Result<SubmitCommandOutcome>> {
let command_dispatcher: Arc<dyn CommandDispatcher> = Arc::new(ShipOrderDispatcher);
let projection_dispatcher: Arc<dyn ProjectionDispatcher> = Arc::new(NoopProjectionDispatcher);
let items = orders
.iter()
.map(|order| skilj_core::command_batcher::CommandBatchItem {
command_type: command_type.clone(),
payload: format!(r#"{{"order_id":"{order}"}}"#),
client_id: "client-1".to_string(),
correlation_id: None,
causation_id: None,
idempotency_key: None,
})
.collect();
batcher
.decide_and_submit_many(
pool,
&command_dispatcher,
&projection_dispatcher,
&NoSnapshots,
&EventBroadcaster::new(64),
&EventCache::new(1000),
None,
&command_type.bounded_context.name,
items,
false,
)
.await
}
#[test]
fn a_batch_is_decided_in_the_order_given() {
runtime().block_on(async {
let Some(pool) = test_pool().await else {
return;
};
let bc = seed_bounded_context(&pool).await;
seed_order_shipped_event_type(&pool, &bc).await;
let ct = seed_ship_order_command_type(&pool, &bc).await;
let outcomes = submit_many(&pool, &CommandBatcher::new(), &ct, &["A", "B", "A", "C"]).await;
let accepted: Vec<bool> = outcomes
.iter()
.map(|o| matches!(o, Ok(SubmitCommandOutcome::Accepted { .. })))
.collect();
assert_eq!(accepted, vec![true, true, false, true]);
assert!(matches!(
&outcomes[2],
Ok(SubmitCommandOutcome::Rejected { kind, .. }) if kind == "already_shipped"
));
let events = db::list_events_for_bounded_context(&pool, &bc.name)
.await
.unwrap();
let shipped: Vec<String> = events
.iter()
.map(|e| {
serde_json::from_str::<serde_json::Value>(&e.payload).unwrap()["order_id"]
.as_str()
.unwrap()
.to_string()
})
.collect();
assert_eq!(shipped, vec!["A", "B", "C"]);
assert_eq!(
events.iter().map(|e| e.sequence).collect::<Vec<_>>(),
vec![0, 1, 2]
);
});
}
#[test]
fn a_batch_larger_than_the_max_batch_size_keeps_its_order() {
runtime().block_on(async {
let Some(pool) = test_pool().await else {
return;
};
let bc = seed_bounded_context(&pool).await;
seed_order_shipped_event_type(&pool, &bc).await;
let ct = seed_ship_order_command_type(&pool, &bc).await;
let orders = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "A"];
let batcher = CommandBatcher::new().with_max_batch_size(3);
let outcomes = submit_many(&pool, &batcher, &ct, &orders).await;
assert_eq!(outcomes.len(), orders.len());
for outcome in &outcomes[..9] {
assert!(matches!(outcome, Ok(SubmitCommandOutcome::Accepted { .. })));
}
assert!(matches!(
&outcomes[9],
Ok(SubmitCommandOutcome::Rejected { .. })
));
});
}
#[test]
fn an_empty_batch_answers_nothing() {
runtime().block_on(async {
let Some(pool) = test_pool().await else {
return;
};
let bc = seed_bounded_context(&pool).await;
seed_order_shipped_event_type(&pool, &bc).await;
let ct = seed_ship_order_command_type(&pool, &bc).await;
let outcomes = submit_many(&pool, &CommandBatcher::new(), &ct, &[]).await;
assert!(outcomes.is_empty());
});
}
#[test]
fn a_command_for_another_bounded_context_fails_alone() {
runtime().block_on(async {
let Some(pool) = test_pool().await else {
return;
};
let bc = seed_bounded_context(&pool).await;
seed_order_shipped_event_type(&pool, &bc).await;
let ct = seed_ship_order_command_type(&pool, &bc).await;
let other_bc = seed_bounded_context(&pool).await;
seed_order_shipped_event_type(&pool, &other_bc).await;
let other_ct = seed_ship_order_command_type(&pool, &other_bc).await;
let command_dispatcher: Arc<dyn CommandDispatcher> = Arc::new(ShipOrderDispatcher);
let projection_dispatcher: Arc<dyn ProjectionDispatcher> =
Arc::new(NoopProjectionDispatcher);
let item = |command_type: &CommandType, order: &str| {
skilj_core::command_batcher::CommandBatchItem {
command_type: command_type.clone(),
payload: format!(r#"{{"order_id":"{order}"}}"#),
client_id: "client-1".to_string(),
correlation_id: None,
causation_id: None,
idempotency_key: None,
}
};
let outcomes = CommandBatcher::new()
.decide_and_submit_many(
&pool,
&command_dispatcher,
&projection_dispatcher,
&NoSnapshots,
&EventBroadcaster::new(64),
&EventCache::new(1000),
None,
&bc.name,
vec![item(&ct, "A"), item(&other_ct, "B"), item(&ct, "C")],
false,
)
.await;
assert!(matches!(
&outcomes[0],
Ok(SubmitCommandOutcome::Accepted { .. })
));
assert!(outcomes[1].is_err());
assert!(matches!(
&outcomes[2],
Ok(SubmitCommandOutcome::Accepted { .. })
));
assert!(db::list_events_for_bounded_context(&pool, &other_bc.name)
.await
.unwrap()
.is_empty());
});
}