use crate::db::{
BatchedCommand, BatchedCommandResult, OwnedSnapshotContext, Pool, SnapshotContext,
SubmitCommandOutcome,
};
use crate::encryption::KeyService;
use crate::error::{Error, Result};
use crate::event_cache::EventCache;
use crate::event_store::{CommandType, Event, EventBroadcaster};
use crate::plugin::{CommandDispatcher, ProjectionDispatcher};
use crate::shared::{CommandDecision, Tag};
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use tokio::sync::{oneshot, RwLock, Semaphore};
use tracing::Instrument;
pub const DEFAULT_MAX_BATCH_SIZE: usize = 256;
pub const PREPARE_CONCURRENCY: usize = 8;
pub const DEFAULT_MAX_ITEMS_PER_BATCH: usize = 100;
#[derive(Debug, Clone)]
pub struct CommandBatchItem {
pub command_type: CommandType,
pub payload: String,
pub client_id: String,
pub correlation_id: Option<String>,
pub causation_id: Option<String>,
pub idempotency_key: Option<String>,
}
struct PendingCommand {
command: BatchedCommand,
reply: oneshot::Sender<BatchedCommandResult>,
}
type Queue = Arc<Mutex<Vec<PendingCommand>>>;
const DEFAULT_IDLE_IN_TRANSACTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[derive(Clone)]
pub struct CommandBatcher {
queues: Arc<RwLock<HashMap<String, Queue>>>,
idle_in_transaction_timeout: std::time::Duration,
leader_permits: Arc<std::sync::OnceLock<Arc<Semaphore>>>,
max_batch_size: usize,
max_concurrent_leaders: Option<usize>,
max_items_per_batch: usize,
slow: crate::slow_command::SlowCommandWarnings,
}
impl Default for CommandBatcher {
fn default() -> Self {
Self::new()
}
}
impl CommandBatcher {
pub fn new() -> Self {
Self {
queues: Arc::new(RwLock::new(HashMap::new())),
idle_in_transaction_timeout: DEFAULT_IDLE_IN_TRANSACTION_TIMEOUT,
leader_permits: Arc::new(std::sync::OnceLock::new()),
max_batch_size: DEFAULT_MAX_BATCH_SIZE,
max_concurrent_leaders: None,
max_items_per_batch: DEFAULT_MAX_ITEMS_PER_BATCH,
slow: crate::slow_command::SlowCommandWarnings::default(),
}
}
pub fn with_slow_read_warning(mut self, threshold: Option<std::time::Duration>) -> Self {
self.slow = self.slow.with_read(threshold);
self
}
pub fn with_slow_decide_warning(mut self, threshold: Option<std::time::Duration>) -> Self {
self.slow = self.slow.with_decide(threshold);
self
}
pub fn with_max_batch_size(mut self, max_batch_size: usize) -> Self {
self.max_batch_size = max_batch_size.max(1);
self
}
pub fn with_max_items_per_batch(mut self, max_items: usize) -> Self {
self.max_items_per_batch = max_items.max(1);
self
}
pub fn max_items_per_batch(&self) -> usize {
self.max_items_per_batch
}
pub fn with_max_concurrent_leaders(mut self, max_concurrent_leaders: usize) -> Self {
self.max_concurrent_leaders = Some(max_concurrent_leaders.max(1));
self
}
fn leader_permits(&self, pool: &Pool) -> Arc<Semaphore> {
self.leader_permits
.get_or_init(|| {
let permits = self
.max_concurrent_leaders
.unwrap_or((pool.options().get_max_connections() as usize / 2).max(1));
Arc::new(Semaphore::new(permits))
})
.clone()
}
pub fn with_idle_in_transaction_timeout(mut self, timeout: std::time::Duration) -> Self {
self.idle_in_transaction_timeout = timeout;
self
}
async fn queue_for(&self, bounded_context: &str) -> Queue {
if let Some(queue) = self.queues.read().await.get(bounded_context) {
return queue.clone();
}
self.queues
.write()
.await
.entry(bounded_context.to_string())
.or_insert_with(|| Arc::new(Mutex::new(Vec::new())))
.clone()
}
#[allow(clippy::too_many_arguments)]
pub async fn submit(
&self,
pool: &Pool,
dispatcher: &Arc<dyn CommandDispatcher>,
projection_dispatcher: &Arc<dyn ProjectionDispatcher>,
broadcaster: &EventBroadcaster,
event_cache: &EventCache,
command_type: &CommandType,
payload: &str,
client_id: &str,
correlation_id: Option<&str>,
causation_id: Option<&str>,
bounded_context_events: &[Event],
consistency_tags: &[Tag],
matching_events: &[Event],
initial_decision: CommandDecision,
key_service: Option<&KeyService>,
now: DateTime<Utc>,
snapshot: Option<SnapshotContext<'_>>,
snapshot_states: &[crate::plugin::SnapshotState],
idempotency_key: Option<&str>,
covered_through: Option<i64>,
) -> Result<SubmitCommandOutcome> {
let command = Self::prepare(
pool,
command_type,
payload,
client_id,
correlation_id,
causation_id,
bounded_context_events,
consistency_tags,
matching_events,
initial_decision,
key_service,
now,
snapshot,
snapshot_states,
idempotency_key,
covered_through,
)
.await?;
let reply = self
.enqueue(
pool,
dispatcher,
projection_dispatcher,
broadcaster,
event_cache,
key_service,
&command_type.bounded_context.name,
vec![command],
)
.await
.pop()
.expect("one command queued, one reply");
await_reply(reply).await
}
#[allow(clippy::too_many_arguments)]
async fn prepare(
pool: &Pool,
command_type: &CommandType,
payload: &str,
client_id: &str,
correlation_id: Option<&str>,
causation_id: Option<&str>,
bounded_context_events: &[Event],
consistency_tags: &[Tag],
matching_events: &[Event],
initial_decision: CommandDecision,
key_service: Option<&KeyService>,
now: DateTime<Utc>,
snapshot: Option<SnapshotContext<'_>>,
snapshot_states: &[crate::plugin::SnapshotState],
idempotency_key: Option<&str>,
covered_through: Option<i64>,
) -> Result<BatchedCommand> {
let (event_types_by_name, resolved) = crate::db::warm_up_event_types_and_encryption_keys(
pool,
&command_type.bounded_context.name,
command_type,
payload,
&initial_decision,
key_service,
)
.await?;
Ok(BatchedCommand {
command_type: command_type.clone(),
payload: payload.to_string(),
client_id: client_id.to_string(),
correlation_id: correlation_id.map(str::to_string),
causation_id: causation_id.map(str::to_string),
bounded_context_events: bounded_context_events.to_vec(),
covered_through,
consistency_tags: consistency_tags.to_vec(),
matching_events: matching_events.to_vec(),
initial_decision,
now,
snapshot: snapshot.map(|s| OwnedSnapshotContext {
state_json: s.state_json.to_string(),
as_of_sequence: s.as_of_sequence,
}),
snapshot_states: snapshot_states.to_vec(),
idempotency_key: idempotency_key.map(str::to_string),
event_types_by_name,
resolved,
})
}
#[allow(clippy::too_many_arguments)]
async fn enqueue(
&self,
pool: &Pool,
dispatcher: &Arc<dyn CommandDispatcher>,
projection_dispatcher: &Arc<dyn ProjectionDispatcher>,
broadcaster: &EventBroadcaster,
event_cache: &EventCache,
key_service: Option<&KeyService>,
bounded_context_name: &str,
commands: Vec<BatchedCommand>,
) -> Vec<oneshot::Receiver<BatchedCommandResult>> {
if commands.is_empty() {
return Vec::new();
}
let queue = self.queue_for(bounded_context_name).await;
let count = commands.len();
let mut replies = Vec::with_capacity(count);
let is_leader = {
let mut queue = lock_queue(&queue);
for command in commands {
let (reply, reply_rx) = oneshot::channel();
queue.push(PendingCommand { command, reply });
replies.push(reply_rx);
}
queue.len() == count
};
if is_leader {
let leader = self.clone();
let pool = pool.clone();
let dispatcher = dispatcher.clone();
let projection_dispatcher = projection_dispatcher.clone();
let broadcaster = broadcaster.clone();
let event_cache = event_cache.clone();
let key_service = key_service.cloned();
let bounded_context_name = bounded_context_name.to_string();
tokio::spawn(
async move {
leader
.run_as_leader(
&pool,
dispatcher.as_ref(),
projection_dispatcher.as_ref(),
&broadcaster,
&event_cache,
key_service.as_ref(),
&bounded_context_name,
&queue,
)
.await
}
.instrument(tracing::Span::current()),
);
}
replies
}
#[allow(clippy::too_many_arguments)]
pub async fn decide_and_submit_many(
&self,
pool: &Pool,
dispatcher: &Arc<dyn CommandDispatcher>,
projection_dispatcher: &Arc<dyn ProjectionDispatcher>,
snapshot_dispatcher: &dyn crate::plugin::SnapshotDispatcher,
broadcaster: &EventBroadcaster,
event_cache: &EventCache,
key_service: Option<&KeyService>,
bounded_context_name: &str,
commands: Vec<CommandBatchItem>,
stop_at_first_failure: bool,
) -> Vec<Result<SubmitCommandOutcome>> {
use futures_util::StreamExt;
let prepared: Vec<Result<BatchedCommand>> = futures_util::stream::iter(commands)
.map(|item| async move {
if item.command_type.bounded_context.name != bounded_context_name {
return Err(crate::access_control::Error::GrantBoundedContextMismatch.into());
}
let now = Utc::now();
let resolved = crate::db::resolve_command_submission(
pool,
dispatcher.as_ref(),
snapshot_dispatcher,
event_cache,
&item.command_type,
&item.payload,
&crate::plugin::DecisionContext::at(now),
)
.await?;
self.slow.note_resolved(&item.command_type, &resolved);
Self::prepare(
pool,
&item.command_type,
&item.payload,
&item.client_id,
item.correlation_id.as_deref(),
item.causation_id.as_deref(),
&resolved.bounded_context_events,
&resolved.consistency_tags,
&resolved.matching_events,
resolved.decision,
key_service,
now,
resolved
.snapshot_context
.as_ref()
.map(|ctx| SnapshotContext {
state_json: &ctx.state_json,
as_of_sequence: ctx.as_of_sequence,
}),
&resolved.snapshot_states,
item.idempotency_key.as_deref(),
Some(resolved.covered_through),
)
.await
})
.buffered(PREPARE_CONCURRENCY)
.collect()
.await;
let mut queued = Vec::new();
let mut failed = false;
let mut results: Vec<Option<Result<SubmitCommandOutcome>>> = prepared
.into_iter()
.map(|command| match command {
Ok(_) if failed && stop_at_first_failure => Some(Err(Error::NotProcessed)),
Ok(command) => {
queued.push(command);
None
}
Err(e) => {
failed = true;
Some(Err(e))
}
})
.collect();
let mut replies = self
.enqueue(
pool,
dispatcher,
projection_dispatcher,
broadcaster,
event_cache,
key_service,
bounded_context_name,
queued,
)
.await
.into_iter();
for result in results.iter_mut().filter(|r| r.is_none()) {
let reply = replies.next().expect("one reply per queued command");
*result = Some(await_reply(reply).await);
}
results
.into_iter()
.map(|r| r.expect("every command has a result"))
.collect()
}
#[allow(clippy::too_many_arguments)]
pub async fn decide_and_submit(
&self,
pool: &Pool,
dispatcher: &Arc<dyn CommandDispatcher>,
projection_dispatcher: &Arc<dyn ProjectionDispatcher>,
snapshot_dispatcher: &dyn crate::plugin::SnapshotDispatcher,
broadcaster: &EventBroadcaster,
event_cache: &EventCache,
command_type: &CommandType,
payload: &str,
client_id: &str,
correlation_id: Option<&str>,
causation_id: Option<&str>,
key_service: Option<&KeyService>,
now: DateTime<Utc>,
idempotency_key: Option<&str>,
) -> Result<SubmitCommandOutcome> {
let mut rotated_once = false;
loop {
let outcome = self
.decide_and_submit_once(
pool,
dispatcher,
projection_dispatcher,
snapshot_dispatcher,
broadcaster,
event_cache,
command_type,
payload,
client_id,
correlation_id,
causation_id,
key_service,
now,
idempotency_key,
)
.await;
match outcome {
Err(err) if crate::db::blind_tags_rotated(&err) && !rotated_once => {
rotated_once = true
}
outcome => return outcome,
}
}
}
#[allow(clippy::too_many_arguments)]
async fn decide_and_submit_once(
&self,
pool: &Pool,
dispatcher: &Arc<dyn CommandDispatcher>,
projection_dispatcher: &Arc<dyn ProjectionDispatcher>,
snapshot_dispatcher: &dyn crate::plugin::SnapshotDispatcher,
broadcaster: &EventBroadcaster,
event_cache: &EventCache,
command_type: &CommandType,
payload: &str,
client_id: &str,
correlation_id: Option<&str>,
causation_id: Option<&str>,
key_service: Option<&KeyService>,
now: DateTime<Utc>,
idempotency_key: Option<&str>,
) -> Result<SubmitCommandOutcome> {
let resolved = crate::db::resolve_command_submission(
pool,
dispatcher.as_ref(),
snapshot_dispatcher,
event_cache,
command_type,
payload,
&crate::plugin::DecisionContext::at(now),
)
.await?;
self.slow.note_resolved(command_type, &resolved);
self.submit(
pool,
dispatcher,
projection_dispatcher,
broadcaster,
event_cache,
command_type,
payload,
client_id,
correlation_id,
causation_id,
&resolved.bounded_context_events,
&resolved.consistency_tags,
&resolved.matching_events,
resolved.decision,
key_service,
now,
resolved
.snapshot_context
.as_ref()
.map(|ctx| SnapshotContext {
state_json: &ctx.state_json,
as_of_sequence: ctx.as_of_sequence,
}),
&resolved.snapshot_states,
idempotency_key,
Some(resolved.covered_through),
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn run_as_leader(
&self,
pool: &Pool,
dispatcher: &dyn CommandDispatcher,
projection_dispatcher: &dyn ProjectionDispatcher,
broadcaster: &EventBroadcaster,
event_cache: &EventCache,
key_service: Option<&KeyService>,
bounded_context_name: &str,
queue: &Queue,
) {
let mut guard = LeaderGuard {
queue: queue.clone(),
armed: true,
};
let permits = self.leader_permits(pool);
let mut first_pass = true;
loop {
let _permit = permits
.acquire()
.await
.expect("leader semaphore is never closed");
let lock_wait_started = std::time::Instant::now();
let leader_tx = crate::db::begin_command_batch_leader_tx(
pool,
bounded_context_name,
Some(self.idle_in_transaction_timeout),
)
.await;
tracing::debug!(
bounded_context = %bounded_context_name,
lock_wait_us = lock_wait_started.elapsed().as_micros(),
"command batch leader lock wait"
);
let (pending, more_remaining) = drain_up_to(queue, self.max_batch_size);
if !more_remaining {
guard.armed = false;
}
let (batch, replies): (
Vec<BatchedCommand>,
Vec<oneshot::Sender<BatchedCommandResult>>,
) = pending.into_iter().map(|p| (p.command, p.reply)).unzip();
let outcome = match leader_tx {
Err(e) => Err(e),
Ok(leader_tx) => {
crate::db::commit_command_batch(
leader_tx,
dispatcher,
projection_dispatcher,
key_service,
batch,
Some(&self.slow),
)
.await
}
};
match outcome {
Err(e) => {
let mut replies = replies.into_iter();
let original = if first_pass { replies.next() } else { None };
for reply in replies {
let _ = reply.send(Err(Error::batch_failed(&e)));
}
if let Some(reply) = original {
let _ = reply.send(Err(e));
}
}
Ok(results) => {
crate::db::broadcast_appended_batch(
pool,
broadcaster,
event_cache,
results.iter().flatten(),
)
.await;
for (reply, result) in replies.into_iter().zip(results) {
let _ = reply.send(result);
}
}
}
first_pass = false;
if !more_remaining {
break;
}
}
}
}
async fn await_reply(reply: oneshot::Receiver<BatchedCommandResult>) -> BatchedCommandResult {
reply.await.unwrap_or_else(|_| {
Err(Error::batch_failed_msg(
"command batch leader task ended without producing a result",
))
})
}
fn lock_queue(queue: &Queue) -> std::sync::MutexGuard<'_, Vec<PendingCommand>> {
queue.lock().unwrap_or_else(|e| e.into_inner())
}
fn drain_up_to(queue: &Queue, max: usize) -> (Vec<PendingCommand>, bool) {
let mut queue = lock_queue(queue);
if queue.len() <= max {
(std::mem::take(&mut *queue), false)
} else {
(queue.drain(..max).collect(), true)
}
}
struct LeaderGuard {
queue: Queue,
armed: bool,
}
impl Drop for LeaderGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
let stranded = std::mem::take(&mut *lock_queue(&self.queue));
for pending in stranded {
let _ = pending.reply.send(Err(Error::batch_failed_msg(
"the command batch leader stopped before processing this command; retry",
)));
}
}
}