Skip to main content

reliable_delivery/
reliable_delivery.rs

1//! Advanced: retry a credit notification without increasing the balance twice.
2//! 発展編: 入金通知を再試行しても残高を二重加算しない。async/Futureの基礎が前提。
3//!
4//! Run: cargo run -p ddd-toolkit --example reliable_delivery --locked
5//! Read main → deliver → adapter implementations. Skip ready's polling details first.
6//! main → deliver → アダプターの順に読む。最初はreadyのpollの詳細を飛ばしてよい。
7//! Outbox = saved pending notifications; projection = event-derived read state;
8//! checkpoint = processing position; stable IDs deduplicate repeated application.
9//! outbox=送信待ちの記録、projection=イベントから作る参照状態、checkpoint=処理位置。
10//! Expected: broker/checkpoint/ack failures, three deliveries, balance still 10.
11//! ブローカー・位置保存・ack失敗から再試行し、配信3回でも残高は10のまま。
12//! Memory adapters demonstrate contracts, not durable or exactly-once delivery.
13//! メモリ実装は契約の教材。永続性やexactly-once配信を提供するものではない。
14use ddd_toolkit::{
15    AggregateRoot as _, AppendError, AppendResult, AsyncDomainEventPublisher,
16    AsyncIdempotentProjection, AsyncProjectionCheckpointStore, AsyncTransactionalOutbox,
17    AsyncTransactionalOutboxLifecycleContext, CurrentRevision, ExpectedRevision, NoStream,
18    OutboxClaim, OutboxMessageId, ProjectionApplication, ProjectionCheckpoint, RevisionConflict,
19    StreamRevision,
20};
21use std::{
22    collections::{HashSet, VecDeque},
23    future::Future,
24    pin::{Pin, pin},
25    task::{Context, Poll, Waker},
26};
27
28type PortFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, &'static str>> + Send + 'a>>;
29
30// Not an executor: only these non-suspending demo adapters can use this helper.
31// Real I/O adapters need an application-selected runtime, wakeups and cancellation handling.
32fn ready<F: Future>(future: F) -> F::Output {
33    let mut future = pin!(future);
34    match future
35        .as_mut()
36        .poll(&mut Context::from_waker(Waker::noop()))
37    {
38        Poll::Ready(value) => value,
39        Poll::Pending => panic!("demo adapter unexpectedly suspended; use a real executor for I/O"),
40    }
41}
42#[derive(Debug, Clone, ddd_toolkit::DomainEvent)]
43struct Credited(u32);
44#[derive(Clone, ddd_toolkit::Entity, ddd_toolkit::AggregateRoot)]
45struct Account {
46    #[entity(id)]
47    id: u64,
48    balance: u32,
49    #[aggregate(events)]
50    events: Vec<Credited>,
51}
52
53// Single-account adapter. The queue head is reclaimed until acknowledged.
54// A production adapter must atomically persist state + queue, and define claim leases.
55#[derive(Default)]
56struct Outbox {
57    saved: Option<Account>,
58    revision: Option<StreamRevision>,
59    queue: VecDeque<(OutboxMessageId, Credited)>,
60    fail_ack: bool,
61}
62impl AsyncTransactionalOutbox for Outbox {
63    type Aggregate = Account;
64    type Error = &'static str;
65    fn save_and_enqueue<'a>(
66        &'a mut self,
67        account: &'a Account,
68        expected: ExpectedRevision,
69        events: Vec<Credited>,
70    ) -> Pin<Box<dyn Future<Output = Result<AppendResult, AppendError<Self::Error>>> + Send + 'a>>
71    {
72        Box::pin(async move {
73            if events.is_empty() {
74                return Err(AppendError::EmptyBatch);
75            }
76            if self
77                .saved
78                .as_ref()
79                .is_some_and(|saved| saved.id != account.id)
80            {
81                return Err(AppendError::Adapter("demo stores only one account"));
82            }
83            let matches = match (expected, self.revision) {
84                (ExpectedRevision::NoStream(_), None) => true,
85                (ExpectedRevision::Revision(want), Some(have)) => want == have,
86                _ => false,
87            };
88            if !matches {
89                return Err(AppendError::Conflict(RevisionConflict {
90                    expected,
91                    current: self.revision.map_or(
92                        CurrentRevision::NoStream(NoStream),
93                        CurrentRevision::Revision,
94                    ),
95                }));
96            }
97            let first = match self.revision {
98                None => 0,
99                Some(current) => current
100                    .value()
101                    .checked_add(1)
102                    .ok_or(AppendError::RevisionExhausted { current })?,
103            };
104            let last = first.checked_add(events.len() as u64 - 1).ok_or(
105                AppendError::RevisionExhausted {
106                    current: StreamRevision::new(first),
107                },
108            )?;
109            let mut snapshot = account.clone();
110            snapshot.pull_events();
111            self.saved = Some(snapshot);
112            self.revision = Some(StreamRevision::new(last));
113            for (offset, event) in events.into_iter().enumerate() {
114                self.queue.push_back((
115                    OutboxMessageId::new(format!("{}:{}", account.id, first + offset as u64)),
116                    event,
117                ));
118            }
119            Ok(AppendResult {
120                resulting_revision: StreamRevision::new(last),
121            })
122        })
123    }
124    fn claim<'a>(&'a mut self) -> PortFuture<'a, Option<OutboxClaim<Credited>>> {
125        Box::pin(async move {
126            Ok(self
127                .queue
128                .front()
129                .map(|(id, event)| OutboxClaim::new(id.clone(), event.clone())))
130        })
131    }
132    fn acknowledge<'a>(&'a mut self, id: &'a OutboxMessageId) -> PortFuture<'a, ()> {
133        Box::pin(async move {
134            if self.fail_ack {
135                return Err("acknowledgement lost");
136            }
137            if self.queue.front().is_none_or(|(head, _)| head != id) {
138                return Err("unknown claim");
139            }
140            self.queue.pop_front();
141            Ok(())
142        })
143    }
144}
145
146#[derive(Default)]
147struct Broker {
148    fail: bool,
149    accepted: Vec<Credited>,
150}
151impl AsyncDomainEventPublisher for Broker {
152    type Event = Credited;
153    type Error = &'static str;
154    fn publish<'a>(&'a mut self, event: &'a Credited) -> PortFuture<'a, ()> {
155        Box::pin(async move {
156            if self.fail {
157                return Err("broker down");
158            }
159            self.accepted.push(event.clone());
160            Ok(())
161        })
162    }
163}
164#[derive(Default)]
165struct ReadModel {
166    balance: u32,
167    applied: HashSet<String>,
168}
169impl AsyncIdempotentProjection for ReadModel {
170    type Event = Credited;
171    type Cursor = String;
172    type Error = &'static str;
173    fn apply_once<'a>(
174        &'a mut self,
175        event: &'a Credited,
176        checkpoint: &'a ProjectionCheckpoint<String>,
177    ) -> PortFuture<'a, ProjectionApplication> {
178        Box::pin(async move {
179            if self.applied.contains(checkpoint.cursor()) {
180                return Ok(ProjectionApplication::AlreadyApplied);
181            }
182            let balance = self
183                .balance
184                .checked_add(event.0)
185                .ok_or("balance overflow")?;
186            // Deduplication marker + read model update must be atomic in production too.
187            self.applied.insert(checkpoint.cursor().clone());
188            self.balance = balance;
189            Ok(ProjectionApplication::Applied)
190        })
191    }
192}
193#[derive(Default)]
194struct Checkpoints {
195    last: Option<ProjectionCheckpoint<String>>,
196    fail_save: bool,
197}
198impl AsyncProjectionCheckpointStore for Checkpoints {
199    type Cursor = String;
200    type Error = &'static str;
201    fn load<'a>(&'a self) -> PortFuture<'a, Option<ProjectionCheckpoint<String>>> {
202        Box::pin(async move { Ok(self.last.clone()) })
203    }
204    fn save<'a>(&'a mut self, checkpoint: ProjectionCheckpoint<String>) -> PortFuture<'a, ()> {
205        Box::pin(async move {
206            if self.fail_save {
207                return Err("checkpoint unavailable");
208            }
209            self.last = Some(checkpoint);
210            Ok(())
211        })
212    }
213}
214
215async fn deliver(
216    outbox: &mut Outbox,
217    broker: &mut Broker,
218    model: &mut ReadModel,
219    checkpoints: &mut Checkpoints,
220) -> Result<ProjectionApplication, &'static str> {
221    let claim = outbox.claim().await?.ok_or("queue empty")?;
222    let (id, event) = claim.into_parts();
223    broker.publish(&event).await?;
224    // This demo directly feeds a consumer after publishing; no real broker is used.
225    // Message IDs are stable across retries; these cursors are not sortable offsets.
226    let checkpoint = ProjectionCheckpoint::new(id.as_str().to_owned());
227    let result = model.apply_once(&event, &checkpoint).await?;
228    checkpoints.save(checkpoint).await?;
229    outbox.acknowledge(&id).await?;
230    Ok(result)
231}
232fn main() {
233    let mut account = Account {
234        id: 1,
235        balance: 10,
236        events: vec![Credited(10)],
237    };
238    let mut outbox = Outbox::default();
239    let mut context = AsyncTransactionalOutboxLifecycleContext::new(
240        &mut account,
241        &mut outbox,
242        ExpectedRevision::NoStream(NoStream),
243    );
244    ready(context.save_and_enqueue()).unwrap();
245    context.complete().unwrap();
246    assert!(account.pull_events().is_empty());
247    assert_eq!(outbox.saved.as_ref().unwrap().balance, 10);
248    let mut broker = Broker {
249        fail: true,
250        ..Broker::default()
251    };
252    let mut model = ReadModel::default();
253    let mut checkpoints = Checkpoints::default();
254    assert_eq!(
255        ready(deliver(
256            &mut outbox,
257            &mut broker,
258            &mut model,
259            &mut checkpoints
260        )),
261        Err("broker down")
262    );
263    assert_eq!(outbox.queue.len(), 1);
264    assert_eq!(model.balance, 0);
265
266    broker.fail = false;
267    checkpoints.fail_save = true;
268    assert_eq!(
269        ready(deliver(
270            &mut outbox,
271            &mut broker,
272            &mut model,
273            &mut checkpoints
274        )),
275        Err("checkpoint unavailable")
276    );
277    assert_eq!(model.balance, 10);
278    assert!(ready(checkpoints.load()).unwrap().is_none());
279    checkpoints.fail_save = false;
280    outbox.fail_ack = true;
281    assert_eq!(
282        ready(deliver(
283            &mut outbox,
284            &mut broker,
285            &mut model,
286            &mut checkpoints
287        )),
288        Err("acknowledgement lost")
289    );
290    assert_eq!(model.balance, 10);
291    assert_eq!(outbox.queue.len(), 1);
292    outbox.fail_ack = false;
293    assert_eq!(
294        ready(deliver(
295            &mut outbox,
296            &mut broker,
297            &mut model,
298            &mut checkpoints
299        )),
300        Ok(ProjectionApplication::AlreadyApplied)
301    );
302    assert_eq!(model.balance, 10);
303    assert_eq!(broker.accepted.len(), 3); // At-least-once delivery, not exactly-once.
304    assert_eq!(ready(checkpoints.load()).unwrap().unwrap().cursor(), "1:0");
305    assert!(ready(outbox.claim()).unwrap().is_none());
306    println!("reliable_delivery: retried broker/checkpoint/ack failures; 3 deliveries, balance=10");
307}