use ddd_toolkit::{
AggregateRoot as _, AppendError, AppendResult, AsyncDomainEventPublisher,
AsyncIdempotentProjection, AsyncProjectionCheckpointStore, AsyncTransactionalOutbox,
AsyncTransactionalOutboxLifecycleContext, CurrentRevision, ExpectedRevision, NoStream,
OutboxClaim, OutboxMessageId, ProjectionApplication, ProjectionCheckpoint, RevisionConflict,
StreamRevision,
};
use std::{
collections::{HashSet, VecDeque},
future::Future,
pin::{Pin, pin},
task::{Context, Poll, Waker},
};
type PortFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, &'static str>> + Send + 'a>>;
fn ready<F: Future>(future: F) -> F::Output {
let mut future = pin!(future);
match future
.as_mut()
.poll(&mut Context::from_waker(Waker::noop()))
{
Poll::Ready(value) => value,
Poll::Pending => panic!("demo adapter unexpectedly suspended; use a real executor for I/O"),
}
}
#[derive(Debug, Clone, ddd_toolkit::DomainEvent)]
struct Credited(u32);
#[derive(Clone, ddd_toolkit::Entity, ddd_toolkit::AggregateRoot)]
struct Account {
#[entity(id)]
id: u64,
balance: u32,
#[aggregate(events)]
events: Vec<Credited>,
}
#[derive(Default)]
struct Outbox {
saved: Option<Account>,
revision: Option<StreamRevision>,
queue: VecDeque<(OutboxMessageId, Credited)>,
fail_ack: bool,
}
impl AsyncTransactionalOutbox for Outbox {
type Aggregate = Account;
type Error = &'static str;
fn save_and_enqueue<'a>(
&'a mut self,
account: &'a Account,
expected: ExpectedRevision,
events: Vec<Credited>,
) -> Pin<Box<dyn Future<Output = Result<AppendResult, AppendError<Self::Error>>> + Send + 'a>>
{
Box::pin(async move {
if events.is_empty() {
return Err(AppendError::EmptyBatch);
}
if self
.saved
.as_ref()
.is_some_and(|saved| saved.id != account.id)
{
return Err(AppendError::Adapter("demo stores only one account"));
}
let matches = match (expected, self.revision) {
(ExpectedRevision::NoStream(_), None) => true,
(ExpectedRevision::Revision(want), Some(have)) => want == have,
_ => false,
};
if !matches {
return Err(AppendError::Conflict(RevisionConflict {
expected,
current: self.revision.map_or(
CurrentRevision::NoStream(NoStream),
CurrentRevision::Revision,
),
}));
}
let first = match self.revision {
None => 0,
Some(current) => current
.value()
.checked_add(1)
.ok_or(AppendError::RevisionExhausted { current })?,
};
let last = first.checked_add(events.len() as u64 - 1).ok_or(
AppendError::RevisionExhausted {
current: StreamRevision::new(first),
},
)?;
let mut snapshot = account.clone();
snapshot.pull_events();
self.saved = Some(snapshot);
self.revision = Some(StreamRevision::new(last));
for (offset, event) in events.into_iter().enumerate() {
self.queue.push_back((
OutboxMessageId::new(format!("{}:{}", account.id, first + offset as u64)),
event,
));
}
Ok(AppendResult {
resulting_revision: StreamRevision::new(last),
})
})
}
fn claim<'a>(&'a mut self) -> PortFuture<'a, Option<OutboxClaim<Credited>>> {
Box::pin(async move {
Ok(self
.queue
.front()
.map(|(id, event)| OutboxClaim::new(id.clone(), event.clone())))
})
}
fn acknowledge<'a>(&'a mut self, id: &'a OutboxMessageId) -> PortFuture<'a, ()> {
Box::pin(async move {
if self.fail_ack {
return Err("acknowledgement lost");
}
if self.queue.front().is_none_or(|(head, _)| head != id) {
return Err("unknown claim");
}
self.queue.pop_front();
Ok(())
})
}
}
#[derive(Default)]
struct Broker {
fail: bool,
accepted: Vec<Credited>,
}
impl AsyncDomainEventPublisher for Broker {
type Event = Credited;
type Error = &'static str;
fn publish<'a>(&'a mut self, event: &'a Credited) -> PortFuture<'a, ()> {
Box::pin(async move {
if self.fail {
return Err("broker down");
}
self.accepted.push(event.clone());
Ok(())
})
}
}
#[derive(Default)]
struct ReadModel {
balance: u32,
applied: HashSet<String>,
}
impl AsyncIdempotentProjection for ReadModel {
type Event = Credited;
type Cursor = String;
type Error = &'static str;
fn apply_once<'a>(
&'a mut self,
event: &'a Credited,
checkpoint: &'a ProjectionCheckpoint<String>,
) -> PortFuture<'a, ProjectionApplication> {
Box::pin(async move {
if self.applied.contains(checkpoint.cursor()) {
return Ok(ProjectionApplication::AlreadyApplied);
}
let balance = self
.balance
.checked_add(event.0)
.ok_or("balance overflow")?;
self.applied.insert(checkpoint.cursor().clone());
self.balance = balance;
Ok(ProjectionApplication::Applied)
})
}
}
#[derive(Default)]
struct Checkpoints {
last: Option<ProjectionCheckpoint<String>>,
fail_save: bool,
}
impl AsyncProjectionCheckpointStore for Checkpoints {
type Cursor = String;
type Error = &'static str;
fn load<'a>(&'a self) -> PortFuture<'a, Option<ProjectionCheckpoint<String>>> {
Box::pin(async move { Ok(self.last.clone()) })
}
fn save<'a>(&'a mut self, checkpoint: ProjectionCheckpoint<String>) -> PortFuture<'a, ()> {
Box::pin(async move {
if self.fail_save {
return Err("checkpoint unavailable");
}
self.last = Some(checkpoint);
Ok(())
})
}
}
async fn deliver(
outbox: &mut Outbox,
broker: &mut Broker,
model: &mut ReadModel,
checkpoints: &mut Checkpoints,
) -> Result<ProjectionApplication, &'static str> {
let claim = outbox.claim().await?.ok_or("queue empty")?;
let (id, event) = claim.into_parts();
broker.publish(&event).await?;
let checkpoint = ProjectionCheckpoint::new(id.as_str().to_owned());
let result = model.apply_once(&event, &checkpoint).await?;
checkpoints.save(checkpoint).await?;
outbox.acknowledge(&id).await?;
Ok(result)
}
fn main() {
let mut account = Account {
id: 1,
balance: 10,
events: vec![Credited(10)],
};
let mut outbox = Outbox::default();
let mut context = AsyncTransactionalOutboxLifecycleContext::new(
&mut account,
&mut outbox,
ExpectedRevision::NoStream(NoStream),
);
ready(context.save_and_enqueue()).unwrap();
context.complete().unwrap();
assert!(account.pull_events().is_empty());
assert_eq!(outbox.saved.as_ref().unwrap().balance, 10);
let mut broker = Broker {
fail: true,
..Broker::default()
};
let mut model = ReadModel::default();
let mut checkpoints = Checkpoints::default();
assert_eq!(
ready(deliver(
&mut outbox,
&mut broker,
&mut model,
&mut checkpoints
)),
Err("broker down")
);
assert_eq!(outbox.queue.len(), 1);
assert_eq!(model.balance, 0);
broker.fail = false;
checkpoints.fail_save = true;
assert_eq!(
ready(deliver(
&mut outbox,
&mut broker,
&mut model,
&mut checkpoints
)),
Err("checkpoint unavailable")
);
assert_eq!(model.balance, 10);
assert!(ready(checkpoints.load()).unwrap().is_none());
checkpoints.fail_save = false;
outbox.fail_ack = true;
assert_eq!(
ready(deliver(
&mut outbox,
&mut broker,
&mut model,
&mut checkpoints
)),
Err("acknowledgement lost")
);
assert_eq!(model.balance, 10);
assert_eq!(outbox.queue.len(), 1);
outbox.fail_ack = false;
assert_eq!(
ready(deliver(
&mut outbox,
&mut broker,
&mut model,
&mut checkpoints
)),
Ok(ProjectionApplication::AlreadyApplied)
);
assert_eq!(model.balance, 10);
assert_eq!(broker.accepted.len(), 3); assert_eq!(ready(checkpoints.load()).unwrap().unwrap().cursor(), "1:0");
assert!(ready(outbox.claim()).unwrap().is_none());
println!("reliable_delivery: retried broker/checkpoint/ack failures; 3 deliveries, balance=10");
}