use std::time::Duration;
use super::{AsyncOutboxStore, ClaimOutboxMessages, OutboxClaimRef, OutboxPublishFailureAction};
use crate::bus::{AsyncMessagePublisher, Message, MessageKind, TransportError, TransportErrorKind};
use crate::outbox::OutboxMessage;
use crate::repository::RepositoryError;
impl From<RepositoryError> for TransportError {
fn from(error: RepositoryError) -> Self {
TransportError::new(TransportErrorKind::Retryable, error.to_string()).with_source(error)
}
}
const OUTBOX_CONTENT_TYPE: &str = "application/octet-stream";
pub const SOURCED_METADATA_PREFIX: &str = "x-sourced-";
impl From<&OutboxMessage> for Message {
fn from(outbox: &OutboxMessage) -> Self {
let kind = if outbox.destination.is_some() {
MessageKind::Command
} else {
MessageKind::Event
};
let mut metadata: Vec<(String, String)> = outbox
.metadata
.iter()
.filter(|(key, _)| {
!key.to_ascii_lowercase()
.starts_with(SOURCED_METADATA_PREFIX)
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
metadata.push((
format!("{SOURCED_METADATA_PREFIX}payload-codec"),
outbox.payload_codec.clone(),
));
metadata.push((
format!("{SOURCED_METADATA_PREFIX}payload-codec-version"),
outbox.payload_codec_version.to_string(),
));
if let Some(destination) = &outbox.destination {
metadata.push((
format!("{SOURCED_METADATA_PREFIX}destination"),
destination.clone(),
));
}
if let Some(source_type) = &outbox.source_aggregate_type {
metadata.push((
format!("{SOURCED_METADATA_PREFIX}source-aggregate-type"),
source_type.clone(),
));
}
if let Some(source_id) = &outbox.source_aggregate_id {
metadata.push((
format!("{SOURCED_METADATA_PREFIX}source-aggregate-id"),
source_id.clone(),
));
}
if let Some(sequence) = outbox.source_sequence {
metadata.push((
format!("{SOURCED_METADATA_PREFIX}source-sequence"),
sequence.to_string(),
));
}
Message {
id: Some(outbox.id().to_string()),
name: outbox.event_type.clone(),
kind,
payload: outbox.payload.clone(),
content_type: OUTBOX_CONTENT_TYPE.to_string(),
metadata,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct OutboxDispatchOutcome {
pub requested: usize,
pub claimed: usize,
pub published: usize,
pub released: usize,
pub failed: usize,
}
pub struct OutboxDispatcher<S, P> {
store: S,
publisher: P,
worker_id: String,
lease: Duration,
max_attempts: u32,
}
impl<S, P> OutboxDispatcher<S, P>
where
S: AsyncOutboxStore,
P: AsyncMessagePublisher,
{
pub fn new(
store: S,
publisher: P,
worker_id: impl Into<String>,
lease: Duration,
max_attempts: u32,
) -> Self {
Self {
store,
publisher,
worker_id: worker_id.into(),
lease,
max_attempts,
}
}
pub fn publisher(&self) -> &P {
&self.publisher
}
pub fn store(&self) -> &S {
&self.store
}
pub async fn dispatch_ids(
&self,
ids: &[String],
) -> Result<OutboxDispatchOutcome, TransportError> {
let request =
ClaimOutboxMessages::for_ids(self.worker_id.clone(), ids.to_vec(), self.lease);
let claimed = self.store.claim_async(request).await?;
let mut outcome = self.dispatch_claimed(claimed).await?;
outcome.requested = ids.len();
Ok(outcome)
}
pub async fn dispatch_batch(
&self,
batch_size: usize,
) -> Result<OutboxDispatchOutcome, TransportError> {
let request = ClaimOutboxMessages::new(self.worker_id.clone(), batch_size, self.lease);
let claimed = self.store.claim_async(request).await?;
let mut outcome = self.dispatch_claimed(claimed).await?;
outcome.requested = batch_size;
Ok(outcome)
}
async fn dispatch_claimed(
&self,
claimed: Vec<OutboxMessage>,
) -> Result<OutboxDispatchOutcome, TransportError> {
let mut outcome = OutboxDispatchOutcome {
claimed: claimed.len(),
..Default::default()
};
for message in claimed {
let claim = OutboxClaimRef::from_message(&message)?;
let transport_message = Message::from(&message);
match self.publisher.publish(transport_message).await {
Ok(()) => {
self.store.complete_async(&claim).await?;
outcome.published += 1;
}
Err(publish_error) => {
match self
.store
.record_failure_async(&claim, &publish_error.to_string(), self.max_attempts)
.await?
{
OutboxPublishFailureAction::Released => outcome.released += 1,
OutboxPublishFailureAction::Failed => outcome.failed += 1,
}
}
}
}
Ok(outcome)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CommitBatch, HashMapRepository, TransactionalCommit};
use std::future::Future;
use std::sync::Mutex;
fn block_on<F: Future>(future: F) -> F::Output {
use std::ptr;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
const VTABLE: RawWakerVTable = RawWakerVTable::new(
|_| RawWaker::new(ptr::null(), &VTABLE),
|_| {},
|_| {},
|_| {},
);
let waker = unsafe { Waker::from_raw(RawWaker::new(ptr::null(), &VTABLE)) };
let mut cx = Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
loop {
if let Poll::Ready(output) = future.as_mut().poll(&mut cx) {
return output;
}
}
}
struct RecordingPublisher {
published: Mutex<Vec<String>>,
fail: bool,
}
impl RecordingPublisher {
fn new(fail: bool) -> Self {
Self {
published: Mutex::new(Vec::new()),
fail,
}
}
fn ids(&self) -> Vec<String> {
self.published.lock().unwrap().clone()
}
}
impl AsyncMessagePublisher for RecordingPublisher {
async fn publish(&self, message: Message) -> Result<(), TransportError> {
if self.fail {
return Err(TransportError::retryable("publish failed"));
}
self.published
.lock()
.unwrap()
.push(message.id().unwrap_or_default().to_string());
Ok(())
}
}
fn store_message(repo: &HashMapRepository, message: OutboxMessage) -> String {
let id = message.id().to_string();
let mut batch = CommitBatch::empty();
batch.outbox_messages.push(message);
block_on(repo.commit_batch(batch)).unwrap();
id
}
fn outbox(id: &str) -> OutboxMessage {
OutboxMessage::create_with_metadata(
id,
"OrderCreated",
b"\x01\x02".to_vec(),
[("correlation_id".to_string(), "corr-1".to_string())]
.into_iter()
.collect(),
)
.unwrap()
}
#[test]
fn maps_outbox_row_to_canonical_message() {
let message = Message::from(&outbox("evt-1"));
assert_eq!(message.id(), Some("evt-1"));
assert_eq!(message.name(), "OrderCreated");
assert_eq!(message.kind, MessageKind::Event);
assert_eq!(message.payload(), b"\x01\x02");
assert_eq!(message.content_type, "application/octet-stream");
assert_eq!(message.correlation_id(), Some("corr-1"));
assert_eq!(message.metadata("x-sourced-payload-codec"), Some("bytes"));
assert_eq!(
message.metadata("x-sourced-payload-codec-version"),
Some("1")
);
}
#[test]
fn user_metadata_cannot_shadow_reserved_framework_keys() {
let outbox = OutboxMessage::create_with_metadata(
"evt-1",
"OrderCreated",
b"\x01".to_vec(),
[("x-sourced-payload-codec".to_string(), "evil".to_string())]
.into_iter()
.collect(),
)
.unwrap();
let message = Message::from(&outbox);
assert_eq!(message.metadata("x-sourced-payload-codec"), Some("bytes"));
assert_eq!(
message
.metadata
.iter()
.filter(|(k, _)| k == "x-sourced-payload-codec")
.count(),
1
);
}
#[test]
fn destination_maps_to_command_kind() {
let outbox =
OutboxMessage::create_to("cmd-1", "ShipOrder", "shipping", b"{}".to_vec()).unwrap();
let message = Message::from(&outbox);
assert_eq!(message.kind, MessageKind::Command);
assert_eq!(message.metadata("x-sourced-destination"), Some("shipping"));
}
fn dispatcher(
repo: &HashMapRepository,
fail: bool,
max_attempts: u32,
) -> OutboxDispatcher<crate::HashMapOutboxStore, RecordingPublisher> {
OutboxDispatcher::new(
repo.outbox_store(),
RecordingPublisher::new(fail),
"immediate:test",
Duration::from_secs(60),
max_attempts,
)
}
fn load(repo: &HashMapRepository, id: &str) -> OutboxMessage {
repo.outbox_storage()
.read()
.unwrap()
.get(id)
.unwrap()
.clone()
}
#[test]
fn dispatch_ids_claims_then_publishes_then_completes() {
let repo = HashMapRepository::new();
let id = store_message(&repo, outbox("evt-1"));
let dispatcher = dispatcher(&repo, false, 3);
let outcome = block_on(dispatcher.dispatch_ids(std::slice::from_ref(&id))).unwrap();
assert_eq!(
outcome,
OutboxDispatchOutcome {
requested: 1,
claimed: 1,
published: 1,
released: 0,
failed: 0,
}
);
assert_eq!(dispatcher.publisher.ids(), vec!["evt-1".to_string()]);
assert!(load(&repo, &id).is_published());
}
#[test]
fn unknown_publish_outcome_leaves_row_retryable() {
let repo = HashMapRepository::new();
let id = store_message(&repo, outbox("evt-1"));
let dispatcher = dispatcher(&repo, true, 3);
let outcome = block_on(dispatcher.dispatch_ids(std::slice::from_ref(&id))).unwrap();
assert_eq!(outcome.published, 0);
assert_eq!(outcome.released, 1);
assert_eq!(outcome.failed, 0);
let row = load(&repo, &id);
assert!(row.is_pending());
assert_eq!(row.attempts, 1);
}
#[test]
fn publish_failure_fails_row_at_attempt_ceiling() {
let repo = HashMapRepository::new();
let id = store_message(&repo, outbox("evt-1"));
let dispatcher = dispatcher(&repo, true, 1);
let outcome = block_on(dispatcher.dispatch_ids(std::slice::from_ref(&id))).unwrap();
assert_eq!(outcome.failed, 1);
assert_eq!(outcome.released, 0);
assert!(load(&repo, &id).is_failed());
}
#[test]
fn dispatch_ids_only_claims_requested_ids() {
let repo = HashMapRepository::new();
let wanted = store_message(&repo, outbox("evt-1"));
let other = store_message(&repo, outbox("evt-2"));
let dispatcher = dispatcher(&repo, false, 3);
let outcome = block_on(dispatcher.dispatch_ids(std::slice::from_ref(&wanted))).unwrap();
assert_eq!(outcome.claimed, 1);
assert_eq!(outcome.published, 1);
assert!(load(&repo, &wanted).is_published());
assert!(load(&repo, &other).is_pending());
}
#[test]
fn raced_id_is_not_an_error() {
let repo = HashMapRepository::new();
let outcome =
block_on(dispatcher(&repo, false, 3).dispatch_ids(&["missing".to_string()])).unwrap();
assert_eq!(
outcome,
OutboxDispatchOutcome {
requested: 1,
claimed: 0,
published: 0,
released: 0,
failed: 0,
}
);
}
#[test]
fn worker_and_immediate_dispatch_share_state_transitions() {
let repo = HashMapRepository::new();
let immediate_id = store_message(&repo, outbox("evt-1"));
let _worker_id = store_message(&repo, outbox("evt-2"));
let dispatcher = dispatcher(&repo, false, 3);
let immediate =
block_on(dispatcher.dispatch_ids(std::slice::from_ref(&immediate_id))).unwrap();
assert_eq!(immediate.published, 1);
let drained = block_on(dispatcher.dispatch_batch(10)).unwrap();
assert_eq!(drained.claimed, 1, "only evt-2 remains claimable");
assert_eq!(drained.published, 1);
assert_eq!(dispatcher.publisher.ids().len(), 2);
}
}