1use std::{
4 collections::{HashMap, HashSet},
5 marker::PhantomData,
6 sync::Mutex,
7 time::SystemTime,
8};
9
10use soaprs_core::{BoxFuture, Command, MessageId, SoapError, SoapResult};
11use soaprs_cqrs::{
12 AppendResult, ClaimedOutboxRecord, ClaimedSagaAction, DeadLetter, DeliveryClaimId,
13 EncodedEvent, EventStore, ExpectedSagaVersion, ExpectedVersion, GlobalPosition, InboxClaim,
14 InboxStore, OutboxRecord, OutboxStore, ProjectionApplyOutcome, ProjectionCheckpointStore,
15 ProjectionId, RecordedEvent, SagaAction, SagaActionId, SagaActionRecord, SagaActionStore,
16 SagaCommitResult, SagaId, SagaStore, SagaVersion, Snapshot, SnapshotStore, StoredEvent,
17 StoredEventStore, StoredSaga, StreamId, StreamVersion, TransactionalEventOutbox,
18 TransactionalProjection, TransactionalSagaOutbox,
19};
20use soaprs_events::{DomainEvent, IntegrationEvent};
21
22#[derive(Debug)]
23struct EventStoreState<E> {
24 streams: HashMap<StreamId, Vec<RecordedEvent<E>>>,
25 global: Vec<RecordedEvent<E>>,
26 next_global_position: u64,
27}
28
29#[derive(Debug)]
30struct StoredEventState<P> {
31 streams: HashMap<StreamId, Vec<StoredEvent<P>>>,
32 global: Vec<StoredEvent<P>>,
33 next_global_position: u64,
34}
35
36impl<P> Default for StoredEventState<P> {
37 fn default() -> Self {
38 Self {
39 streams: HashMap::new(),
40 global: Vec::new(),
41 next_global_position: 1,
42 }
43 }
44}
45
46impl<E> Default for EventStoreState<E> {
47 fn default() -> Self {
48 Self {
49 streams: HashMap::new(),
50 global: Vec::new(),
51 next_global_position: 1,
52 }
53 }
54}
55
56#[derive(Debug)]
58pub struct MemoryEventStore<E> {
59 state: Mutex<EventStoreState<E>>,
60}
61
62impl<E> MemoryEventStore<E> {
63 pub fn new() -> Self {
65 Self {
66 state: Mutex::new(EventStoreState::default()),
67 }
68 }
69}
70
71impl<E> Default for MemoryEventStore<E> {
72 fn default() -> Self {
73 Self::new()
74 }
75}
76
77impl<E> EventStore<E> for MemoryEventStore<E>
78where
79 E: DomainEvent + Clone,
80{
81 fn append<'a>(
82 &'a self,
83 stream_id: &'a StreamId,
84 expected: ExpectedVersion,
85 events: Vec<soaprs_events::EventEnvelope<E>>,
86 ) -> BoxFuture<'a, SoapResult<AppendResult>> {
87 Box::pin(async move {
88 if events.is_empty() {
89 return Err(SoapError::validation(
90 "event store append requires at least one event",
91 ));
92 }
93
94 let mut state = self
95 .state
96 .lock()
97 .map_err(|_| SoapError::infrastructure("in-memory event store lock poisoned"))?;
98 let current = state
99 .streams
100 .get(stream_id)
101 .and_then(|stream| stream.last())
102 .map(|event| event.stream_version);
103 validate_expected_version(expected, current)?;
104
105 let mut stream_version = current
106 .map_or(Some(StreamVersion::FIRST), StreamVersion::checked_next)
107 .ok_or_else(|| SoapError::infrastructure("event stream version overflow"))?;
108 let event_count = events.len();
109 let mut records = Vec::with_capacity(event_count);
110 for (index, envelope) in events.into_iter().enumerate() {
111 let global_position = GlobalPosition::new(state.next_global_position);
112 state.next_global_position = state
113 .next_global_position
114 .checked_add(1)
115 .ok_or_else(|| SoapError::infrastructure("global event position overflow"))?;
116 records.push(RecordedEvent {
117 stream_id: stream_id.clone(),
118 stream_version,
119 global_position,
120 envelope,
121 });
122 if index + 1 < event_count {
123 stream_version = stream_version.checked_next().ok_or_else(|| {
124 SoapError::infrastructure("event stream version overflow")
125 })?;
126 }
127 }
128
129 let Some(last) = records.last() else {
130 return Err(SoapError::infrastructure(
131 "event append lost its non-empty record set",
132 ));
133 };
134 let result = AppendResult {
135 stream_version: last.stream_version,
136 global_position: last.global_position,
137 };
138 state
139 .streams
140 .entry(stream_id.clone())
141 .or_default()
142 .extend(records.iter().cloned());
143 state.global.extend(records);
144 Ok(result)
145 })
146 }
147
148 fn load<'a>(
149 &'a self,
150 stream_id: &'a StreamId,
151 after: Option<StreamVersion>,
152 ) -> BoxFuture<'a, SoapResult<Vec<RecordedEvent<E>>>> {
153 Box::pin(async move {
154 let state = self
155 .state
156 .lock()
157 .map_err(|_| SoapError::infrastructure("in-memory event store lock poisoned"))?;
158 Ok(state
159 .streams
160 .get(stream_id)
161 .into_iter()
162 .flatten()
163 .filter(|event| after.is_none_or(|version| event.stream_version > version))
164 .cloned()
165 .collect())
166 })
167 }
168
169 fn read_all(
170 &self,
171 after: Option<GlobalPosition>,
172 limit: usize,
173 ) -> BoxFuture<'_, SoapResult<Vec<RecordedEvent<E>>>> {
174 Box::pin(async move {
175 if limit == 0 {
176 return Err(SoapError::validation(
177 "global event read limit must be greater than zero",
178 ));
179 }
180 let state = self
181 .state
182 .lock()
183 .map_err(|_| SoapError::infrastructure("in-memory event store lock poisoned"))?;
184 Ok(state
185 .global
186 .iter()
187 .filter(|event| after.is_none_or(|position| event.global_position > position))
188 .take(limit)
189 .cloned()
190 .collect())
191 })
192 }
193}
194
195#[derive(Debug)]
197pub struct MemoryStoredEventStore<P> {
198 state: Mutex<StoredEventState<P>>,
199}
200
201impl<P> MemoryStoredEventStore<P> {
202 pub fn new() -> Self {
204 Self {
205 state: Mutex::new(StoredEventState::default()),
206 }
207 }
208}
209
210impl<P> Default for MemoryStoredEventStore<P> {
211 fn default() -> Self {
212 Self::new()
213 }
214}
215
216impl<P> StoredEventStore<P> for MemoryStoredEventStore<P>
217where
218 P: Clone + Send,
219{
220 fn append<'a>(
221 &'a self,
222 stream_id: &'a StreamId,
223 expected: ExpectedVersion,
224 events: Vec<EncodedEvent<P>>,
225 ) -> BoxFuture<'a, SoapResult<AppendResult>> {
226 Box::pin(async move {
227 if events.is_empty() {
228 return Err(SoapError::validation(
229 "stored event append requires at least one event",
230 ));
231 }
232 let mut state = self
233 .state
234 .lock()
235 .map_err(|_| SoapError::infrastructure("stored event store lock poisoned"))?;
236 let current = state
237 .streams
238 .get(stream_id)
239 .and_then(|stream| stream.last())
240 .map(|event| event.stream_version);
241 validate_expected_version(expected, current)?;
242
243 let mut stream_version = current
244 .map_or(Some(StreamVersion::FIRST), StreamVersion::checked_next)
245 .ok_or_else(|| SoapError::infrastructure("stored event stream version overflow"))?;
246 let event_count = events.len();
247 let mut records = Vec::with_capacity(event_count);
248 for (index, encoded) in events.into_iter().enumerate() {
249 let global_position = GlobalPosition::new(state.next_global_position);
250 state.next_global_position = state
251 .next_global_position
252 .checked_add(1)
253 .ok_or_else(|| SoapError::infrastructure("global event position overflow"))?;
254 records.push(StoredEvent {
255 stream_id: stream_id.clone(),
256 stream_version,
257 global_position,
258 encoded,
259 });
260 if index + 1 < event_count {
261 stream_version = stream_version.checked_next().ok_or_else(|| {
262 SoapError::infrastructure("stored event stream version overflow")
263 })?;
264 }
265 }
266 let Some(last) = records.last() else {
267 return Err(SoapError::infrastructure(
268 "stored event append lost its non-empty record set",
269 ));
270 };
271 let result = AppendResult {
272 stream_version: last.stream_version,
273 global_position: last.global_position,
274 };
275 state
276 .streams
277 .entry(stream_id.clone())
278 .or_default()
279 .extend(records.iter().cloned());
280 state.global.extend(records);
281 Ok(result)
282 })
283 }
284
285 fn load<'a>(
286 &'a self,
287 stream_id: &'a StreamId,
288 after: Option<StreamVersion>,
289 ) -> BoxFuture<'a, SoapResult<Vec<StoredEvent<P>>>> {
290 Box::pin(async move {
291 let state = self
292 .state
293 .lock()
294 .map_err(|_| SoapError::infrastructure("stored event store lock poisoned"))?;
295 Ok(state
296 .streams
297 .get(stream_id)
298 .into_iter()
299 .flatten()
300 .filter(|event| after.is_none_or(|version| event.stream_version > version))
301 .cloned()
302 .collect())
303 })
304 }
305
306 fn read_all(
307 &self,
308 after: Option<GlobalPosition>,
309 limit: usize,
310 ) -> BoxFuture<'_, SoapResult<Vec<StoredEvent<P>>>> {
311 Box::pin(async move {
312 if limit == 0 {
313 return Err(SoapError::validation(
314 "stored global event read limit must be greater than zero",
315 ));
316 }
317 let state = self
318 .state
319 .lock()
320 .map_err(|_| SoapError::infrastructure("stored event store lock poisoned"))?;
321 Ok(state
322 .global
323 .iter()
324 .filter(|event| after.is_none_or(|position| event.global_position > position))
325 .take(limit)
326 .cloned()
327 .collect())
328 })
329 }
330}
331
332fn validate_expected_version(
333 expected: ExpectedVersion,
334 current: Option<StreamVersion>,
335) -> SoapResult<()> {
336 let matches = match expected {
337 ExpectedVersion::Any => true,
338 ExpectedVersion::NoStream => current.is_none(),
339 ExpectedVersion::Exact(version) => current == Some(version),
340 };
341 if matches {
342 Ok(())
343 } else {
344 Err(SoapError::conflict("event stream version conflict"))
345 }
346}
347
348#[derive(Debug, Default)]
350pub struct MemoryProjectionCheckpointStore {
351 checkpoints: Mutex<HashMap<ProjectionId, GlobalPosition>>,
352}
353
354impl MemoryProjectionCheckpointStore {
355 pub fn new() -> Self {
357 Self::default()
358 }
359}
360
361impl ProjectionCheckpointStore for MemoryProjectionCheckpointStore {
362 fn load<'a>(
363 &'a self,
364 projection: &'a ProjectionId,
365 ) -> BoxFuture<'a, SoapResult<Option<GlobalPosition>>> {
366 Box::pin(async move {
367 Ok(self
368 .checkpoints
369 .lock()
370 .map_err(|_| SoapError::infrastructure("checkpoint store lock poisoned"))?
371 .get(projection)
372 .copied())
373 })
374 }
375
376 fn store<'a>(
377 &'a self,
378 projection: &'a ProjectionId,
379 position: GlobalPosition,
380 ) -> BoxFuture<'a, SoapResult<()>> {
381 Box::pin(async move {
382 let mut checkpoints = self
383 .checkpoints
384 .lock()
385 .map_err(|_| SoapError::infrastructure("checkpoint store lock poisoned"))?;
386 if checkpoints
387 .get(projection)
388 .is_some_and(|current| position < *current)
389 {
390 return Err(SoapError::conflict(
391 "projection checkpoint cannot move backwards",
392 ));
393 }
394 checkpoints.insert(projection.clone(), position);
395 Ok(())
396 })
397 }
398}
399
400#[derive(Debug, Clone)]
401struct TransactionalProjectionState<S> {
402 read_model: S,
403 checkpoint: Option<GlobalPosition>,
404}
405
406pub struct MemoryTransactionalProjection<E, S, F> {
411 id: ProjectionId,
412 state: Mutex<TransactionalProjectionState<S>>,
413 apply: F,
414 event: PhantomData<fn(E)>,
415}
416
417impl<E, S, F> MemoryTransactionalProjection<E, S, F> {
418 pub fn new(id: ProjectionId, read_model: S, apply: F) -> Self {
420 Self {
421 id,
422 state: Mutex::new(TransactionalProjectionState {
423 read_model,
424 checkpoint: None,
425 }),
426 apply,
427 event: PhantomData,
428 }
429 }
430}
431
432impl<E, S, F> MemoryTransactionalProjection<E, S, F>
433where
434 S: Clone,
435{
436 pub fn snapshot(&self) -> SoapResult<(S, Option<GlobalPosition>)> {
438 let state = self
439 .state
440 .lock()
441 .map_err(|_| SoapError::infrastructure("transactional projection lock poisoned"))?;
442 Ok((state.read_model.clone(), state.checkpoint))
443 }
444}
445
446impl<E, S, F> std::fmt::Debug for MemoryTransactionalProjection<E, S, F> {
447 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448 formatter
449 .debug_struct("MemoryTransactionalProjection")
450 .field("id", &self.id)
451 .finish_non_exhaustive()
452 }
453}
454
455impl<E, S, F> TransactionalProjection<E> for MemoryTransactionalProjection<E, S, F>
456where
457 E: DomainEvent,
458 S: Clone + Send,
459 F: Fn(&mut S, &RecordedEvent<E>) -> SoapResult<()> + Send + Sync,
460{
461 fn id(&self) -> &ProjectionId {
462 &self.id
463 }
464
465 fn checkpoint(&self) -> BoxFuture<'_, SoapResult<Option<GlobalPosition>>> {
466 Box::pin(async move {
467 Ok(self
468 .state
469 .lock()
470 .map_err(|_| SoapError::infrastructure("transactional projection lock poisoned"))?
471 .checkpoint)
472 })
473 }
474
475 fn project_and_checkpoint<'a>(
476 &'a self,
477 event: &'a RecordedEvent<E>,
478 ) -> BoxFuture<'a, SoapResult<ProjectionApplyOutcome>> {
479 Box::pin(async move {
480 let mut state = self
481 .state
482 .lock()
483 .map_err(|_| SoapError::infrastructure("transactional projection lock poisoned"))?;
484 if state
485 .checkpoint
486 .is_some_and(|checkpoint| event.global_position <= checkpoint)
487 {
488 return Ok(ProjectionApplyOutcome::AlreadyApplied);
489 }
490
491 let mut candidate = state.read_model.clone();
492 (self.apply)(&mut candidate, event)?;
493 state.read_model = candidate;
494 state.checkpoint = Some(event.global_position);
495 Ok(ProjectionApplyOutcome::Applied)
496 })
497 }
498}
499
500#[derive(Debug, Default)]
502pub struct MemorySnapshotStore<S> {
503 snapshots: Mutex<HashMap<StreamId, Vec<Snapshot<S>>>>,
504}
505
506impl<S> MemorySnapshotStore<S> {
507 pub fn new() -> Self {
509 Self {
510 snapshots: Mutex::new(HashMap::new()),
511 }
512 }
513}
514
515impl<S> SnapshotStore<S> for MemorySnapshotStore<S>
516where
517 S: Clone + Send,
518{
519 fn load_latest<'a>(
520 &'a self,
521 stream_id: &'a StreamId,
522 ) -> BoxFuture<'a, SoapResult<Option<Snapshot<S>>>> {
523 Box::pin(async move {
524 Ok(self
525 .snapshots
526 .lock()
527 .map_err(|_| SoapError::infrastructure("snapshot store lock poisoned"))?
528 .get(stream_id)
529 .and_then(|items| items.last())
530 .cloned())
531 })
532 }
533
534 fn save(&self, snapshot: Snapshot<S>) -> BoxFuture<'_, SoapResult<()>> {
535 Box::pin(async move {
536 let mut snapshots = self
537 .snapshots
538 .lock()
539 .map_err(|_| SoapError::infrastructure("snapshot store lock poisoned"))?;
540 let stream = snapshots.entry(snapshot.stream_id.clone()).or_default();
541 if stream
542 .last()
543 .is_some_and(|current| snapshot.stream_version < current.stream_version)
544 {
545 return Err(SoapError::conflict(
546 "snapshot cannot replace a newer stream version",
547 ));
548 }
549 if stream
550 .last()
551 .is_some_and(|current| snapshot.stream_version == current.stream_version)
552 {
553 stream.pop();
554 }
555 stream.push(snapshot);
556 Ok(())
557 })
558 }
559
560 fn prune<'a>(
561 &'a self,
562 stream_id: &'a StreamId,
563 retain_from: StreamVersion,
564 ) -> BoxFuture<'a, SoapResult<u64>> {
565 Box::pin(async move {
566 let mut snapshots = self
567 .snapshots
568 .lock()
569 .map_err(|_| SoapError::infrastructure("snapshot store lock poisoned"))?;
570 let Some(stream) = snapshots.get_mut(stream_id) else {
571 return Ok(0);
572 };
573 let before = stream.len();
574 stream.retain(|snapshot| snapshot.stream_version >= retain_from);
575 Ok(before.saturating_sub(stream.len()) as u64)
576 })
577 }
578}
579
580#[derive(Debug, Default)]
582pub struct MemorySagaStore<S> {
583 sagas: Mutex<HashMap<SagaId, StoredSaga<S>>>,
584}
585
586impl<S> MemorySagaStore<S> {
587 pub fn new() -> Self {
589 Self {
590 sagas: Mutex::new(HashMap::new()),
591 }
592 }
593}
594
595impl<S> SagaStore<S> for MemorySagaStore<S>
596where
597 S: Clone + Send,
598{
599 fn load<'a>(&'a self, id: &'a SagaId) -> BoxFuture<'a, SoapResult<Option<StoredSaga<S>>>> {
600 Box::pin(async move {
601 Ok(self
602 .sagas
603 .lock()
604 .map_err(|_| SoapError::infrastructure("saga store lock poisoned"))?
605 .get(id)
606 .cloned())
607 })
608 }
609
610 fn save<'a>(
611 &'a self,
612 id: &'a SagaId,
613 expected: ExpectedSagaVersion,
614 saga: S,
615 ) -> BoxFuture<'a, SoapResult<SagaVersion>> {
616 Box::pin(async move {
617 let mut sagas = self
618 .sagas
619 .lock()
620 .map_err(|_| SoapError::infrastructure("saga store lock poisoned"))?;
621 let current = sagas.get(id).map(|stored| stored.version);
622 let version = next_saga_version(expected, current)?;
623 sagas.insert(
624 id.clone(),
625 StoredSaga {
626 version,
627 state: saga,
628 },
629 );
630 Ok(version)
631 })
632 }
633}
634
635fn next_saga_version(
636 expected: ExpectedSagaVersion,
637 current: Option<SagaVersion>,
638) -> SoapResult<SagaVersion> {
639 let matches = match expected {
640 ExpectedSagaVersion::Any => true,
641 ExpectedSagaVersion::NoSaga => current.is_none(),
642 ExpectedSagaVersion::Exact(version) => current == Some(version),
643 };
644 if !matches {
645 return Err(SoapError::conflict("saga version conflict"));
646 }
647 current
648 .map_or(Some(SagaVersion::FIRST), SagaVersion::checked_next)
649 .ok_or_else(|| SoapError::infrastructure("saga version overflow"))
650}
651
652#[derive(Debug, Clone)]
653struct MemorySagaActionEntry<C>
654where
655 C: Command<Output = ()>,
656{
657 record: SagaActionRecord<C>,
658 claim: Option<(DeliveryClaimId, SystemTime)>,
659}
660
661#[derive(Debug)]
662struct SagaActionState<C>
663where
664 C: Command<Output = ()>,
665{
666 order: Vec<SagaActionId>,
667 entries: HashMap<SagaActionId, MemorySagaActionEntry<C>>,
668}
669
670impl<C> Default for SagaActionState<C>
671where
672 C: Command<Output = ()>,
673{
674 fn default() -> Self {
675 Self {
676 order: Vec::new(),
677 entries: HashMap::new(),
678 }
679 }
680}
681
682#[derive(Debug)]
684pub struct MemorySagaActionStore<C>
685where
686 C: Command<Output = ()>,
687{
688 state: Mutex<SagaActionState<C>>,
689}
690
691impl<C> MemorySagaActionStore<C>
692where
693 C: Command<Output = ()>,
694{
695 pub fn new() -> Self {
697 Self {
698 state: Mutex::new(SagaActionState::default()),
699 }
700 }
701}
702
703impl<C> Default for MemorySagaActionStore<C>
704where
705 C: Command<Output = ()>,
706{
707 fn default() -> Self {
708 Self::new()
709 }
710}
711
712impl<C> SagaActionStore<C> for MemorySagaActionStore<C>
713where
714 C: Command<Output = ()> + Clone,
715{
716 fn claim_pending(
717 &self,
718 claim_id: DeliveryClaimId,
719 now: SystemTime,
720 lease_until: SystemTime,
721 limit: usize,
722 ) -> BoxFuture<'_, SoapResult<Vec<ClaimedSagaAction<C>>>> {
723 Box::pin(async move {
724 if limit == 0 {
725 return Err(SoapError::validation(
726 "saga action claim limit must be greater than zero",
727 ));
728 }
729 if lease_until <= now {
730 return Err(SoapError::validation(
731 "saga action claim lease must end after the claim time",
732 ));
733 }
734 let mut state = self
735 .state
736 .lock()
737 .map_err(|_| SoapError::infrastructure("saga action store lock poisoned"))?;
738 let order = state.order.clone();
739 let mut claimed = Vec::new();
740 for id in order {
741 if claimed.len() == limit {
742 break;
743 }
744 let Some(entry) = state.entries.get_mut(&id) else {
745 continue;
746 };
747 if entry
748 .claim
749 .as_ref()
750 .is_none_or(|(_, current_lease)| *current_lease <= now)
751 && entry.record.completed_at.is_none()
752 && entry.record.dead_letter.is_none()
753 && entry.record.available_at <= now
754 {
755 entry.claim = Some((claim_id.clone(), lease_until));
756 claimed.push(ClaimedSagaAction {
757 record: entry.record.clone(),
758 claim_id: claim_id.clone(),
759 lease_until,
760 });
761 }
762 }
763 Ok(claimed)
764 })
765 }
766
767 fn mark_completed<'a>(
768 &'a self,
769 id: &'a SagaActionId,
770 claim_id: &'a DeliveryClaimId,
771 completed_at: SystemTime,
772 ) -> BoxFuture<'a, SoapResult<()>> {
773 Box::pin(async move {
774 let mut state = self
775 .state
776 .lock()
777 .map_err(|_| SoapError::infrastructure("saga action store lock poisoned"))?;
778 let Some(entry) = state.entries.get_mut(id) else {
779 return Err(SoapError::not_found("saga action"));
780 };
781 validate_saga_action_claim(entry.claim.as_ref(), claim_id, completed_at)?;
782 entry.record.completed_at = Some(completed_at);
783 entry.claim = None;
784 Ok(())
785 })
786 }
787
788 fn mark_failed<'a>(
789 &'a self,
790 id: &'a SagaActionId,
791 claim_id: &'a DeliveryClaimId,
792 safe_error: &'a str,
793 failed_at: SystemTime,
794 available_at: SystemTime,
795 ) -> BoxFuture<'a, SoapResult<()>> {
796 Box::pin(async move {
797 let mut state = self
798 .state
799 .lock()
800 .map_err(|_| SoapError::infrastructure("saga action store lock poisoned"))?;
801 let Some(entry) = state.entries.get_mut(id) else {
802 return Err(SoapError::not_found("saga action"));
803 };
804 validate_saga_action_claim(entry.claim.as_ref(), claim_id, failed_at)?;
805 entry.record.attempts = entry.record.attempts.saturating_add(1);
806 entry.record.available_at = available_at;
807 entry.record.last_error = Some(safe_error.to_owned());
808 entry.claim = None;
809 Ok(())
810 })
811 }
812
813 fn mark_dead_lettered<'a>(
814 &'a self,
815 id: &'a SagaActionId,
816 claim_id: &'a DeliveryClaimId,
817 safe_error: &'a str,
818 failed_at: SystemTime,
819 ) -> BoxFuture<'a, SoapResult<()>> {
820 Box::pin(async move {
821 let mut state = self
822 .state
823 .lock()
824 .map_err(|_| SoapError::infrastructure("saga action store lock poisoned"))?;
825 let Some(entry) = state.entries.get_mut(id) else {
826 return Err(SoapError::not_found("saga action"));
827 };
828 validate_saga_action_claim(entry.claim.as_ref(), claim_id, failed_at)?;
829 entry.record.attempts = entry.record.attempts.saturating_add(1);
830 entry.record.last_error = Some(safe_error.to_owned());
831 entry.record.dead_letter = Some(DeadLetter {
832 failed_at,
833 safe_error: safe_error.to_owned(),
834 });
835 entry.claim = None;
836 Ok(())
837 })
838 }
839}
840
841fn validate_saga_action_claim(
842 claim: Option<&(DeliveryClaimId, SystemTime)>,
843 claim_id: &DeliveryClaimId,
844 outcome_at: SystemTime,
845) -> SoapResult<()> {
846 let Some((current, lease_until)) = claim else {
847 return Err(SoapError::conflict("saga action has no active claim"));
848 };
849 if current != claim_id {
850 return Err(SoapError::conflict(
851 "saga action claim is not owned by this worker",
852 ));
853 }
854 if *lease_until <= outcome_at {
855 return Err(SoapError::conflict("saga action claim lease has expired"));
856 }
857 Ok(())
858}
859
860#[derive(Debug)]
862pub struct MemorySagaOutbox<S, C>
863where
864 C: Command<Output = ()>,
865{
866 sagas: MemorySagaStore<S>,
867 actions: MemorySagaActionStore<C>,
868}
869
870impl<S, C> MemorySagaOutbox<S, C>
871where
872 C: Command<Output = ()>,
873{
874 pub fn new() -> Self {
876 Self {
877 sagas: MemorySagaStore::new(),
878 actions: MemorySagaActionStore::new(),
879 }
880 }
881
882 pub const fn saga_store(&self) -> &MemorySagaStore<S> {
884 &self.sagas
885 }
886
887 pub const fn action_store(&self) -> &MemorySagaActionStore<C> {
889 &self.actions
890 }
891}
892
893impl<S, C> Default for MemorySagaOutbox<S, C>
894where
895 C: Command<Output = ()>,
896{
897 fn default() -> Self {
898 Self::new()
899 }
900}
901
902impl<S, C> TransactionalSagaOutbox<S, C> for MemorySagaOutbox<S, C>
903where
904 S: Send,
905 C: Command<Output = ()>,
906{
907 fn commit_transition<'a>(
908 &'a self,
909 saga_id: &'a SagaId,
910 expected: ExpectedSagaVersion,
911 saga: S,
912 transition: soaprs_core::MessageMetadata,
913 actions: Vec<SagaAction<C>>,
914 ) -> BoxFuture<'a, SoapResult<SagaCommitResult>> {
915 Box::pin(async move {
916 let created_at = transition.created_at();
917 let mut records = Vec::with_capacity(actions.len());
918 for (index, action) in actions.into_iter().enumerate() {
919 let sequence = u32::try_from(index).map_err(|_| {
920 SoapError::validation("saga transition contains too many actions")
921 })?;
922 let available_at = match &action {
923 SagaAction::Schedule { delay, .. } => {
924 created_at.checked_add(*delay).ok_or_else(|| {
925 SoapError::validation("scheduled saga action time overflow")
926 })?
927 }
928 SagaAction::Dispatch(_)
929 | SagaAction::Compensate(_)
930 | SagaAction::Complete
931 | SagaAction::Fail(_) => created_at,
932 };
933 records.push(SagaActionRecord {
934 id: SagaActionId::new(transition.id().clone(), sequence),
935 saga_id: saga_id.clone(),
936 action,
937 attempts: 0,
938 available_at,
939 completed_at: None,
940 dead_letter: None,
941 last_error: None,
942 transition: transition.clone(),
943 });
944 }
945
946 let mut sagas = self
947 .sagas
948 .sagas
949 .lock()
950 .map_err(|_| SoapError::infrastructure("saga store lock poisoned"))?;
951 let mut action_state = self
952 .actions
953 .state
954 .lock()
955 .map_err(|_| SoapError::infrastructure("saga action store lock poisoned"))?;
956 let current = sagas.get(saga_id).map(|stored| stored.version);
957 let version = next_saga_version(expected, current)?;
958 if records
959 .iter()
960 .any(|record| action_state.entries.contains_key(&record.id))
961 {
962 return Err(SoapError::conflict(
963 "saga transition action identity already exists",
964 ));
965 }
966
967 let queued_actions = records.len();
968 for record in records {
969 action_state.order.push(record.id.clone());
970 action_state.entries.insert(
971 record.id.clone(),
972 MemorySagaActionEntry {
973 record,
974 claim: None,
975 },
976 );
977 }
978 sagas.insert(
979 saga_id.clone(),
980 StoredSaga {
981 version,
982 state: saga,
983 },
984 );
985 Ok(SagaCommitResult {
986 version,
987 queued_actions,
988 })
989 })
990 }
991}
992
993#[derive(Debug, Clone, PartialEq, Eq)]
994enum InboxState {
995 Claimed {
996 claim_id: DeliveryClaimId,
997 lease_until: SystemTime,
998 },
999 Completed,
1000}
1001
1002#[derive(Debug, Default)]
1004pub struct MemoryInboxStore {
1005 messages: Mutex<HashMap<(String, MessageId), InboxState>>,
1006}
1007
1008impl MemoryInboxStore {
1009 pub fn new() -> Self {
1011 Self::default()
1012 }
1013}
1014
1015impl InboxStore for MemoryInboxStore {
1016 fn claim<'a>(
1017 &'a self,
1018 consumer: &'a str,
1019 id: &'a MessageId,
1020 claim_id: DeliveryClaimId,
1021 received_at: SystemTime,
1022 lease_until: SystemTime,
1023 ) -> BoxFuture<'a, SoapResult<InboxClaim>> {
1024 Box::pin(async move {
1025 let mut messages = self
1026 .messages
1027 .lock()
1028 .map_err(|_| SoapError::infrastructure("inbox store lock poisoned"))?;
1029 if lease_until <= received_at {
1030 return Err(SoapError::validation(
1031 "inbox claim lease must end after the received time",
1032 ));
1033 }
1034 let key = (consumer.to_owned(), id.clone());
1035 match messages.entry(key) {
1036 std::collections::hash_map::Entry::Vacant(entry) => {
1037 entry.insert(InboxState::Claimed {
1038 claim_id,
1039 lease_until,
1040 });
1041 Ok(InboxClaim::Acquired)
1042 }
1043 std::collections::hash_map::Entry::Occupied(mut entry) => match entry.get() {
1044 InboxState::Claimed {
1045 lease_until: current_lease,
1046 ..
1047 } if *current_lease <= received_at => {
1048 entry.insert(InboxState::Claimed {
1049 claim_id,
1050 lease_until,
1051 });
1052 Ok(InboxClaim::Acquired)
1053 }
1054 InboxState::Claimed { .. } => Ok(InboxClaim::InProgress),
1055 InboxState::Completed => Ok(InboxClaim::Completed),
1056 },
1057 }
1058 })
1059 }
1060
1061 fn complete<'a>(
1062 &'a self,
1063 consumer: &'a str,
1064 id: &'a MessageId,
1065 claim_id: &'a DeliveryClaimId,
1066 completed_at: SystemTime,
1067 ) -> BoxFuture<'a, SoapResult<()>> {
1068 Box::pin(async move {
1069 let mut messages = self
1070 .messages
1071 .lock()
1072 .map_err(|_| SoapError::infrastructure("inbox store lock poisoned"))?;
1073 let key = (consumer.to_owned(), id.clone());
1074 let Some(state) = messages.get_mut(&key) else {
1075 return Err(SoapError::not_found("inbox message claim"));
1076 };
1077 if !matches!(state, InboxState::Claimed { claim_id: current, .. } if current == claim_id)
1078 {
1079 return Err(SoapError::conflict(
1080 "inbox message claim is not owned by this worker",
1081 ));
1082 }
1083 if matches!(state, InboxState::Claimed { lease_until, .. } if *lease_until <= completed_at)
1084 {
1085 return Err(SoapError::conflict("inbox message claim lease has expired"));
1086 }
1087 *state = InboxState::Completed;
1088 Ok(())
1089 })
1090 }
1091
1092 fn release<'a>(
1093 &'a self,
1094 consumer: &'a str,
1095 id: &'a MessageId,
1096 claim_id: &'a DeliveryClaimId,
1097 released_at: SystemTime,
1098 ) -> BoxFuture<'a, SoapResult<()>> {
1099 Box::pin(async move {
1100 let mut messages = self
1101 .messages
1102 .lock()
1103 .map_err(|_| SoapError::infrastructure("inbox store lock poisoned"))?;
1104 let key = (consumer.to_owned(), id.clone());
1105 match messages.get(&key) {
1106 Some(InboxState::Claimed {
1107 claim_id: current, ..
1108 }) if current == claim_id => {
1109 if matches!(messages.get(&key), Some(InboxState::Claimed { lease_until, .. }) if *lease_until <= released_at)
1110 {
1111 return Err(SoapError::conflict("inbox message claim lease has expired"));
1112 }
1113 }
1114 Some(InboxState::Claimed { .. } | InboxState::Completed) => {
1115 return Err(SoapError::conflict(
1116 "inbox message claim is not owned by this worker",
1117 ));
1118 }
1119 None => return Err(SoapError::not_found("inbox message claim")),
1120 }
1121 messages.remove(&key);
1122 Ok(())
1123 })
1124 }
1125}
1126
1127#[derive(Debug, Clone)]
1128struct MemoryOutboxEntry<M> {
1129 record: OutboxRecord<M>,
1130 claim: Option<(DeliveryClaimId, SystemTime)>,
1131}
1132
1133#[derive(Debug)]
1134struct OutboxState<M> {
1135 order: Vec<MessageId>,
1136 entries: HashMap<MessageId, MemoryOutboxEntry<M>>,
1137}
1138
1139impl<M> Default for OutboxState<M> {
1140 fn default() -> Self {
1141 Self {
1142 order: Vec::new(),
1143 entries: HashMap::new(),
1144 }
1145 }
1146}
1147
1148#[derive(Debug)]
1150pub struct MemoryOutboxStore<M> {
1151 state: Mutex<OutboxState<M>>,
1152}
1153
1154impl<M> MemoryOutboxStore<M> {
1155 pub fn new() -> Self {
1157 Self {
1158 state: Mutex::new(OutboxState::default()),
1159 }
1160 }
1161}
1162
1163impl<M> Default for MemoryOutboxStore<M> {
1164 fn default() -> Self {
1165 Self::new()
1166 }
1167}
1168
1169impl<M> OutboxStore<M> for MemoryOutboxStore<M>
1170where
1171 M: IntegrationEvent + Clone,
1172{
1173 fn enqueue(&self, messages: Vec<OutboxRecord<M>>) -> BoxFuture<'_, SoapResult<()>> {
1174 Box::pin(async move {
1175 let mut state = self
1176 .state
1177 .lock()
1178 .map_err(|_| SoapError::infrastructure("outbox store lock poisoned"))?;
1179 for message in &messages {
1180 if state.entries.contains_key(message.envelope.metadata.id()) {
1181 return Err(SoapError::conflict("duplicate outbox message identity"));
1182 }
1183 }
1184 for message in messages {
1185 let id = message.envelope.metadata.id().clone();
1186 state.order.push(id.clone());
1187 state.entries.insert(
1188 id,
1189 MemoryOutboxEntry {
1190 record: message,
1191 claim: None,
1192 },
1193 );
1194 }
1195 Ok(())
1196 })
1197 }
1198
1199 fn claim_pending(
1200 &self,
1201 claim_id: DeliveryClaimId,
1202 now: SystemTime,
1203 lease_until: SystemTime,
1204 limit: usize,
1205 ) -> BoxFuture<'_, SoapResult<Vec<ClaimedOutboxRecord<M>>>> {
1206 Box::pin(async move {
1207 if limit == 0 {
1208 return Err(SoapError::validation(
1209 "outbox claim limit must be greater than zero",
1210 ));
1211 }
1212 if lease_until <= now {
1213 return Err(SoapError::validation(
1214 "outbox claim lease must end after the claim time",
1215 ));
1216 }
1217 let mut state = self
1218 .state
1219 .lock()
1220 .map_err(|_| SoapError::infrastructure("outbox store lock poisoned"))?;
1221 let order = state.order.clone();
1222 let mut claimed = Vec::new();
1223 for id in order {
1224 if claimed.len() == limit {
1225 break;
1226 }
1227 let Some(entry) = state.entries.get_mut(&id) else {
1228 continue;
1229 };
1230 if entry
1231 .claim
1232 .as_ref()
1233 .is_none_or(|(_, current_lease)| *current_lease <= now)
1234 && entry.record.delivered_at.is_none()
1235 && entry.record.dead_letter.is_none()
1236 && entry.record.available_at <= now
1237 {
1238 entry.claim = Some((claim_id.clone(), lease_until));
1239 claimed.push(ClaimedOutboxRecord {
1240 record: entry.record.clone(),
1241 claim_id: claim_id.clone(),
1242 lease_until,
1243 });
1244 }
1245 }
1246 Ok(claimed)
1247 })
1248 }
1249
1250 fn mark_delivered<'a>(
1251 &'a self,
1252 id: &'a MessageId,
1253 claim_id: &'a DeliveryClaimId,
1254 delivered_at: SystemTime,
1255 ) -> BoxFuture<'a, SoapResult<()>> {
1256 Box::pin(async move {
1257 let mut state = self
1258 .state
1259 .lock()
1260 .map_err(|_| SoapError::infrastructure("outbox store lock poisoned"))?;
1261 let Some(entry) = state.entries.get_mut(id) else {
1262 return Err(SoapError::not_found("outbox message"));
1263 };
1264 if !matches!(&entry.claim, Some((current, _)) if current == claim_id) {
1265 return Err(SoapError::conflict(
1266 "outbox message claim is not owned by this worker",
1267 ));
1268 }
1269 if matches!(&entry.claim, Some((_, lease_until)) if *lease_until <= delivered_at) {
1270 return Err(SoapError::conflict(
1271 "outbox message claim lease has expired",
1272 ));
1273 }
1274 entry.record.delivered_at = Some(delivered_at);
1275 entry.claim = None;
1276 Ok(())
1277 })
1278 }
1279
1280 fn mark_failed<'a>(
1281 &'a self,
1282 id: &'a MessageId,
1283 claim_id: &'a DeliveryClaimId,
1284 safe_error: &'a str,
1285 failed_at: SystemTime,
1286 available_at: SystemTime,
1287 ) -> BoxFuture<'a, SoapResult<()>> {
1288 Box::pin(async move {
1289 let mut state = self
1290 .state
1291 .lock()
1292 .map_err(|_| SoapError::infrastructure("outbox store lock poisoned"))?;
1293 let Some(entry) = state.entries.get_mut(id) else {
1294 return Err(SoapError::not_found("outbox message"));
1295 };
1296 if !matches!(&entry.claim, Some((current, _)) if current == claim_id) {
1297 return Err(SoapError::conflict(
1298 "outbox message claim is not owned by this worker",
1299 ));
1300 }
1301 if matches!(&entry.claim, Some((_, lease_until)) if *lease_until <= failed_at) {
1302 return Err(SoapError::conflict(
1303 "outbox message claim lease has expired",
1304 ));
1305 }
1306 entry.record.attempts = entry.record.attempts.saturating_add(1);
1307 entry.record.available_at = available_at;
1308 entry.record.last_error = Some(safe_error.to_owned());
1309 entry.claim = None;
1310 Ok(())
1311 })
1312 }
1313
1314 fn mark_dead_lettered<'a>(
1315 &'a self,
1316 id: &'a MessageId,
1317 claim_id: &'a DeliveryClaimId,
1318 safe_error: &'a str,
1319 failed_at: SystemTime,
1320 ) -> BoxFuture<'a, SoapResult<()>> {
1321 Box::pin(async move {
1322 let mut state = self
1323 .state
1324 .lock()
1325 .map_err(|_| SoapError::infrastructure("outbox store lock poisoned"))?;
1326 let Some(entry) = state.entries.get_mut(id) else {
1327 return Err(SoapError::not_found("outbox message"));
1328 };
1329 if !matches!(&entry.claim, Some((current, _)) if current == claim_id) {
1330 return Err(SoapError::conflict(
1331 "outbox message claim is not owned by this worker",
1332 ));
1333 }
1334 if matches!(&entry.claim, Some((_, lease_until)) if *lease_until <= failed_at) {
1335 return Err(SoapError::conflict(
1336 "outbox message claim lease has expired",
1337 ));
1338 }
1339 entry.record.attempts = entry.record.attempts.saturating_add(1);
1340 entry.record.last_error = Some(safe_error.to_owned());
1341 entry.record.dead_letter = Some(DeadLetter {
1342 failed_at,
1343 safe_error: safe_error.to_owned(),
1344 });
1345 entry.claim = None;
1346 Ok(())
1347 })
1348 }
1349}
1350
1351#[derive(Debug)]
1353pub struct MemoryEventOutbox<E, M> {
1354 events: MemoryEventStore<E>,
1355 outbox: MemoryOutboxStore<M>,
1356}
1357
1358impl<E, M> MemoryEventOutbox<E, M> {
1359 pub fn new() -> Self {
1361 Self {
1362 events: MemoryEventStore::new(),
1363 outbox: MemoryOutboxStore::new(),
1364 }
1365 }
1366
1367 pub const fn event_store(&self) -> &MemoryEventStore<E> {
1369 &self.events
1370 }
1371
1372 pub const fn outbox(&self) -> &MemoryOutboxStore<M> {
1374 &self.outbox
1375 }
1376}
1377
1378impl<E, M> Default for MemoryEventOutbox<E, M> {
1379 fn default() -> Self {
1380 Self::new()
1381 }
1382}
1383
1384impl<E, M> TransactionalEventOutbox<E, M> for MemoryEventOutbox<E, M>
1385where
1386 E: DomainEvent + Clone,
1387 M: IntegrationEvent,
1388{
1389 fn append_with_outbox<'a>(
1390 &'a self,
1391 stream_id: &'a StreamId,
1392 expected: ExpectedVersion,
1393 events: Vec<soaprs_events::EventEnvelope<E>>,
1394 messages: Vec<OutboxRecord<M>>,
1395 ) -> BoxFuture<'a, SoapResult<AppendResult>> {
1396 Box::pin(async move {
1397 if events.is_empty() {
1398 return Err(SoapError::validation(
1399 "event store append requires at least one event",
1400 ));
1401 }
1402
1403 let mut event_state =
1404 self.events.state.lock().map_err(|_| {
1405 SoapError::infrastructure("in-memory event store lock poisoned")
1406 })?;
1407 let mut outbox_state = self
1408 .outbox
1409 .state
1410 .lock()
1411 .map_err(|_| SoapError::infrastructure("outbox store lock poisoned"))?;
1412
1413 let current = event_state
1414 .streams
1415 .get(stream_id)
1416 .and_then(|stream| stream.last())
1417 .map(|event| event.stream_version);
1418 validate_expected_version(expected, current)?;
1419
1420 let mut new_ids = HashSet::new();
1421 for message in &messages {
1422 let id = message.envelope.metadata.id();
1423 if outbox_state.entries.contains_key(id) || !new_ids.insert(id.clone()) {
1424 return Err(SoapError::conflict("duplicate outbox message identity"));
1425 }
1426 }
1427
1428 let mut next_global_position = event_state.next_global_position;
1429 let mut stream_version = current
1430 .map_or(Some(StreamVersion::FIRST), StreamVersion::checked_next)
1431 .ok_or_else(|| SoapError::infrastructure("event stream version overflow"))?;
1432 let event_count = events.len();
1433 let mut records = Vec::with_capacity(event_count);
1434 for (index, envelope) in events.into_iter().enumerate() {
1435 let global_position = GlobalPosition::new(next_global_position);
1436 next_global_position = next_global_position
1437 .checked_add(1)
1438 .ok_or_else(|| SoapError::infrastructure("global event position overflow"))?;
1439 records.push(RecordedEvent {
1440 stream_id: stream_id.clone(),
1441 stream_version,
1442 global_position,
1443 envelope,
1444 });
1445 if index + 1 < event_count {
1446 stream_version = stream_version.checked_next().ok_or_else(|| {
1447 SoapError::infrastructure("event stream version overflow")
1448 })?;
1449 }
1450 }
1451 let Some(last) = records.last() else {
1452 return Err(SoapError::infrastructure(
1453 "event append lost its non-empty record set",
1454 ));
1455 };
1456 let result = AppendResult {
1457 stream_version: last.stream_version,
1458 global_position: last.global_position,
1459 };
1460
1461 for message in messages {
1462 let id = message.envelope.metadata.id().clone();
1463 outbox_state.order.push(id.clone());
1464 outbox_state.entries.insert(
1465 id,
1466 MemoryOutboxEntry {
1467 record: message,
1468 claim: None,
1469 },
1470 );
1471 }
1472 event_state.next_global_position = next_global_position;
1473 event_state
1474 .streams
1475 .entry(stream_id.clone())
1476 .or_default()
1477 .extend(records.iter().cloned());
1478 event_state.global.extend(records);
1479 Ok(result)
1480 })
1481 }
1482}
1483
1484#[cfg(test)]
1485mod tests {
1486 use std::{
1487 sync::{Arc, Mutex},
1488 time::{Duration, UNIX_EPOCH},
1489 };
1490
1491 use soaprs_contract_tests::{
1492 TransactionalEventOutboxFixture, TransactionalSagaOutboxFixture, block_on,
1493 verify_event_store_contract, verify_inbox_contract, verify_outbox_contract,
1494 verify_projection_checkpoint_contract, verify_saga_store_contract,
1495 verify_snapshot_contract, verify_stored_event_store_contract,
1496 verify_transactional_event_outbox_contract, verify_transactional_projection_contract,
1497 verify_transactional_saga_outbox_contract,
1498 };
1499 use soaprs_core::{
1500 BoxFuture, Command, CommandHandler, MessageEnvelope, MessageMetadata, SoapError, SoapResult,
1501 };
1502 use soaprs_cqrs::{
1503 AggregateCommitResult, CodecEventStore, DeliveryClaimId, EncodedEvent, EventPayloadCodec,
1504 EventReplayHandler, EventReplayer, EventSourcedAggregate, EventSourcedRepository,
1505 EventStore, EventUpcaster, ExpectedSagaVersion, ExpectedVersion, GlobalPosition,
1506 IdempotentProjection, InboxClaim, InboxProcessingOutcome, InboxProcessor, InboxStore,
1507 OutboxProcessingReport, OutboxProcessor, OutboxRecord, OutboxStore, Projection,
1508 ProjectionId, ProjectionRunner, RecordedEvent, ReplayOptions, Saga, SagaAction,
1509 SagaActionProcessingReport, SagaActionProcessor, SagaActionStore, SagaCoordinator, SagaId,
1510 SagaStatus, Snapshot, StoredEventStore, StreamId, StreamVersion,
1511 TransactionalProjectionRunner, TransactionalSagaOutbox, TransientRetryPolicy,
1512 UpcasterChain, VersionedPayload,
1513 };
1514 use soaprs_events::{DomainEvent, Event, EventHandler, EventPublisher, IntegrationEvent};
1515
1516 use super::{
1517 MemoryEventOutbox, MemoryEventStore, MemoryInboxStore, MemoryOutboxStore,
1518 MemoryProjectionCheckpointStore, MemorySagaOutbox, MemorySnapshotStore,
1519 MemoryStoredEventStore, MemoryTransactionalProjection,
1520 };
1521
1522 #[derive(Debug, Clone, PartialEq, Eq)]
1523 enum TestEvent {
1524 Opened,
1525 Renamed(String),
1526 Closed,
1527 }
1528
1529 impl Event for TestEvent {
1530 fn event_type(&self) -> &'static str {
1531 match self {
1532 Self::Opened => "contract.opened",
1533 Self::Renamed(_) => "contract.renamed",
1534 Self::Closed => "contract.closed",
1535 }
1536 }
1537 }
1538
1539 impl DomainEvent for TestEvent {}
1540
1541 #[derive(Debug, Clone, PartialEq, Eq)]
1542 enum CounterEvent {
1543 Added(i32),
1544 }
1545
1546 impl Event for CounterEvent {
1547 fn event_type(&self) -> &'static str {
1548 "contract.counter-added"
1549 }
1550 }
1551
1552 impl DomainEvent for CounterEvent {}
1553
1554 #[derive(Debug, Clone, PartialEq, Eq)]
1555 struct CounterAggregate {
1556 stream_id: StreamId,
1557 version: Option<StreamVersion>,
1558 value: i32,
1559 uncommitted: Vec<MessageEnvelope<CounterEvent>>,
1560 }
1561
1562 impl CounterAggregate {
1563 fn pristine(stream_id: StreamId) -> Self {
1564 Self {
1565 stream_id,
1566 version: None,
1567 value: 0,
1568 uncommitted: Vec::new(),
1569 }
1570 }
1571 }
1572
1573 impl EventSourcedAggregate for CounterAggregate {
1574 type Event = CounterEvent;
1575
1576 fn stream_id(&self) -> &StreamId {
1577 &self.stream_id
1578 }
1579
1580 fn version(&self) -> Option<StreamVersion> {
1581 self.version
1582 }
1583
1584 fn apply(&mut self, event: &Self::Event) -> SoapResult<()> {
1585 match event {
1586 CounterEvent::Added(amount) => {
1587 self.value = self
1588 .value
1589 .checked_add(*amount)
1590 .ok_or_else(|| SoapError::domain("counter overflow"))?;
1591 }
1592 }
1593 Ok(())
1594 }
1595
1596 fn uncommitted_events(&self) -> &[MessageEnvelope<Self::Event>] {
1597 &self.uncommitted
1598 }
1599
1600 fn record(&mut self, event: MessageEnvelope<Self::Event>) -> SoapResult<()> {
1601 self.apply(&event.message)?;
1602 self.uncommitted.push(event);
1603 Ok(())
1604 }
1605
1606 fn mark_committed(&mut self, version: StreamVersion) {
1607 self.version = Some(version);
1608 self.uncommitted.clear();
1609 }
1610 }
1611
1612 #[derive(Debug, Clone, PartialEq, Eq)]
1613 enum VersionedTestEvent {
1614 Created { name: String, email: String },
1615 }
1616
1617 impl Event for VersionedTestEvent {
1618 fn event_type(&self) -> &'static str {
1619 "contract.user-created"
1620 }
1621
1622 fn schema_version(&self) -> u32 {
1623 2
1624 }
1625 }
1626
1627 impl DomainEvent for VersionedTestEvent {}
1628
1629 struct VersionedTestCodec;
1630
1631 impl EventPayloadCodec<VersionedTestEvent, String> for VersionedTestCodec {
1632 fn encode(&self, event: &VersionedTestEvent) -> SoapResult<String> {
1633 match event {
1634 VersionedTestEvent::Created { name, email } => Ok(format!("{name}|{email}")),
1635 }
1636 }
1637
1638 fn decode(&self, event: VersionedPayload<String>) -> SoapResult<VersionedTestEvent> {
1639 if event.event_type != "contract.user-created" || event.schema_version != 2 {
1640 return Err(SoapError::validation(
1641 "versioned test codec received an unsupported schema",
1642 ));
1643 }
1644 let Some((name, email)) = event.payload.split_once('|') else {
1645 return Err(SoapError::validation(
1646 "versioned test payload is missing email",
1647 ));
1648 };
1649 Ok(VersionedTestEvent::Created {
1650 name: name.to_owned(),
1651 email: email.to_owned(),
1652 })
1653 }
1654
1655 fn current_schema_version(&self, event_type: &str) -> SoapResult<u32> {
1656 if event_type == "contract.user-created" {
1657 Ok(2)
1658 } else {
1659 Err(SoapError::unsupported("unknown versioned test event type"))
1660 }
1661 }
1662 }
1663
1664 struct AddEmailUpcaster;
1665
1666 impl EventUpcaster<String> for AddEmailUpcaster {
1667 fn event_type(&self) -> &str {
1668 "contract.user-created"
1669 }
1670
1671 fn source_version(&self) -> u32 {
1672 1
1673 }
1674
1675 fn target_version(&self) -> u32 {
1676 2
1677 }
1678
1679 fn upcast(&self, payload: String) -> SoapResult<String> {
1680 Ok(format!("{payload}|unknown@example.test"))
1681 }
1682 }
1683
1684 #[derive(Debug, Clone, PartialEq, Eq)]
1685 struct Outgoing(String);
1686
1687 impl Event for Outgoing {
1688 fn event_type(&self) -> &'static str {
1689 "contract.outgoing"
1690 }
1691 }
1692
1693 impl IntegrationEvent for Outgoing {}
1694
1695 struct SelectivePublisher;
1696
1697 impl EventPublisher<Outgoing> for SelectivePublisher {
1698 fn publish(&self, event: MessageEnvelope<Outgoing>) -> BoxFuture<'_, SoapResult<()>> {
1699 Box::pin(async move {
1700 match event.message.0.as_str() {
1701 "retry" => Err(SoapError::timeout("publisher timed out")),
1702 "dead-letter" => Err(SoapError::validation("invalid integration event")),
1703 _ => Ok(()),
1704 }
1705 })
1706 }
1707 }
1708
1709 #[derive(Default)]
1710 struct SelectiveHandler {
1711 seen: Mutex<Vec<String>>,
1712 }
1713
1714 impl EventHandler<Outgoing> for SelectiveHandler {
1715 fn handle<'a>(
1716 &'a self,
1717 event: &'a MessageEnvelope<Outgoing>,
1718 ) -> BoxFuture<'a, SoapResult<()>> {
1719 Box::pin(async move {
1720 self.seen
1721 .lock()
1722 .map_err(|_| SoapError::infrastructure("test inbox handler lock poisoned"))?
1723 .push(event.message.0.clone());
1724 if event.message.0 == "fail" {
1725 Err(SoapError::domain("incoming handler failed"))
1726 } else {
1727 Ok(())
1728 }
1729 })
1730 }
1731 }
1732
1733 #[derive(Debug, Clone, PartialEq, Eq)]
1734 enum TestCommand {
1735 Continue,
1736 Retry,
1737 Reject,
1738 }
1739
1740 impl Command for TestCommand {
1741 type Output = ();
1742 }
1743
1744 #[derive(Default)]
1745 struct TestCommandHandler {
1746 seen: Mutex<Vec<TestCommand>>,
1747 }
1748
1749 impl CommandHandler<TestCommand> for TestCommandHandler {
1750 fn command(&self, command: TestCommand) -> BoxFuture<'_, SoapResult<()>> {
1751 Box::pin(async move {
1752 self.seen
1753 .lock()
1754 .map_err(|_| SoapError::infrastructure("test command handler lock poisoned"))?
1755 .push(command.clone());
1756 match command {
1757 TestCommand::Continue => Ok(()),
1758 TestCommand::Retry => Err(SoapError::timeout("command timed out")),
1759 TestCommand::Reject => Err(SoapError::validation("command was rejected")),
1760 }
1761 })
1762 }
1763 }
1764
1765 #[derive(Debug, Clone)]
1766 struct TestSaga {
1767 id: SagaId,
1768 status: SagaStatus,
1769 }
1770
1771 impl Saga<TestEvent> for TestSaga {
1772 type Command = TestCommand;
1773
1774 fn id(&self) -> &SagaId {
1775 &self.id
1776 }
1777
1778 fn status(&self) -> SagaStatus {
1779 self.status
1780 }
1781
1782 fn react(
1783 &mut self,
1784 _event: &MessageEnvelope<TestEvent>,
1785 ) -> SoapResult<Vec<SagaAction<Self::Command>>> {
1786 self.status = SagaStatus::Completed;
1787 Ok(vec![
1788 SagaAction::Dispatch(TestCommand::Continue),
1789 SagaAction::Complete,
1790 ])
1791 }
1792 }
1793
1794 fn event(id: &str, message: TestEvent) -> MessageEnvelope<TestEvent> {
1795 MessageEnvelope::new(message, MessageMetadata::new(id, UNIX_EPOCH))
1796 }
1797
1798 fn recorded_event(position: u64, message: TestEvent) -> RecordedEvent<TestEvent> {
1799 RecordedEvent {
1800 stream_id: StreamId::new("transactional-projection-contract"),
1801 stream_version: StreamVersion::new(position),
1802 global_position: GlobalPosition::new(position),
1803 envelope: event(&format!("projection-contract-{position}"), message),
1804 }
1805 }
1806
1807 fn encoded(id: &str, payload: &str) -> EncodedEvent<String> {
1808 EncodedEvent {
1809 event: VersionedPayload {
1810 event_type: "contract.raw-event".to_owned(),
1811 schema_version: 1,
1812 payload: payload.to_owned(),
1813 },
1814 metadata: MessageMetadata::new(id, UNIX_EPOCH),
1815 }
1816 }
1817
1818 #[test]
1819 fn event_store_passes_shared_contract() {
1820 let store = MemoryEventStore::new();
1821 let result = block_on(verify_event_store_contract(
1822 &store,
1823 StreamId::new("order-1"),
1824 StreamId::new("order-2"),
1825 event("event-1", TestEvent::Opened),
1826 event("event-2", TestEvent::Renamed("new".into())),
1827 event("event-3", TestEvent::Closed),
1828 ));
1829 assert!(result.is_ok(), "{result:?}");
1830 }
1831
1832 #[test]
1833 fn event_sourced_repository_rehydrates_and_preserves_conflicting_changes() {
1834 let store = MemoryEventStore::new();
1835 let factory = |stream_id| Ok(CounterAggregate::pristine(stream_id));
1836 let repository = EventSourcedRepository::new(&store, &factory);
1837 let stream = StreamId::new("counter-1");
1838 let aggregate: SoapResult<CounterAggregate> = repository.new_aggregate(stream.clone());
1839 let Some(mut aggregate) = aggregate.ok() else {
1840 panic!("counter aggregate factory was rejected");
1841 };
1842 let recorded = aggregate.record(MessageEnvelope::new(
1843 CounterEvent::Added(1),
1844 MessageMetadata::new("counter-event-1", UNIX_EPOCH),
1845 ));
1846 assert!(recorded.is_ok(), "{recorded:?}");
1847 let first_commit = block_on(repository.commit(&mut aggregate));
1848 assert!(matches!(
1849 first_commit,
1850 Ok(AggregateCommitResult::Appended { event_count: 1, .. })
1851 ));
1852 assert_eq!(aggregate.version, Some(StreamVersion::FIRST));
1853 assert!(aggregate.uncommitted.is_empty());
1854 let unchanged = block_on(repository.commit(&mut aggregate));
1855 assert_eq!(
1856 unchanged.ok(),
1857 Some(AggregateCommitResult::Unchanged {
1858 stream_version: Some(StreamVersion::FIRST),
1859 })
1860 );
1861
1862 let first: SoapResult<Option<CounterAggregate>> = block_on(repository.load(&stream));
1863 let second: SoapResult<Option<CounterAggregate>> = block_on(repository.load(&stream));
1864 let Some(mut first) = first.ok().flatten() else {
1865 panic!("first counter copy was not rehydrated");
1866 };
1867 let Some(mut second) = second.ok().flatten() else {
1868 panic!("second counter copy was not rehydrated");
1869 };
1870 assert_eq!(first.value, 1);
1871 assert_eq!(first.version, Some(StreamVersion::FIRST));
1872 let first_recorded = first.record(MessageEnvelope::new(
1873 CounterEvent::Added(2),
1874 MessageMetadata::new("counter-event-2", UNIX_EPOCH),
1875 ));
1876 let second_recorded = second.record(MessageEnvelope::new(
1877 CounterEvent::Added(4),
1878 MessageMetadata::new("counter-event-conflict", UNIX_EPOCH),
1879 ));
1880 assert!(first_recorded.is_ok(), "{first_recorded:?}");
1881 assert!(second_recorded.is_ok(), "{second_recorded:?}");
1882 let winning_commit = block_on(repository.commit(&mut first));
1883 assert!(winning_commit.is_ok(), "{winning_commit:?}");
1884 let conflicting_commit = block_on(repository.commit(&mut second));
1885 assert!(conflicting_commit.is_err());
1886 assert_eq!(second.version, Some(StreamVersion::FIRST));
1887 assert_eq!(second.uncommitted.len(), 1);
1888
1889 let reloaded: SoapResult<Option<CounterAggregate>> = block_on(repository.load(&stream));
1890 assert!(matches!(reloaded, Ok(Some(current)) if current.value == 3
1891 && current.version == Some(StreamVersion::new(2))
1892 && current.uncommitted.is_empty()));
1893 }
1894
1895 #[test]
1896 fn stored_event_store_passes_shared_contract() {
1897 let store = MemoryStoredEventStore::new();
1898 let result = block_on(verify_stored_event_store_contract(
1899 &store,
1900 StreamId::new("raw-stream-1"),
1901 StreamId::new("raw-stream-2"),
1902 encoded("raw-event-1", "first"),
1903 encoded("raw-event-2", "second"),
1904 encoded("raw-event-3", "other"),
1905 ));
1906 assert!(result.is_ok(), "{result:?}");
1907 }
1908
1909 #[test]
1910 fn codec_event_store_upcasts_old_payloads_and_preserves_metadata() {
1911 let raw = MemoryStoredEventStore::new();
1912 let stream = StreamId::new("versioned-user-1");
1913 let seeded = block_on(raw.append(
1914 &stream,
1915 ExpectedVersion::NoStream,
1916 vec![EncodedEvent {
1917 event: VersionedPayload {
1918 event_type: "contract.user-created".to_owned(),
1919 schema_version: 1,
1920 payload: "Ada".to_owned(),
1921 },
1922 metadata: MessageMetadata::new("versioned-event-1", UNIX_EPOCH)
1923 .with_correlation_id("registration-1"),
1924 }],
1925 ));
1926 assert!(seeded.is_ok(), "{seeded:?}");
1927
1928 let mut upcasters = UpcasterChain::new();
1929 let registered = upcasters.register(Arc::new(AddEmailUpcaster));
1930 assert!(registered.is_ok(), "{registered:?}");
1931 let store = CodecEventStore::new(raw, VersionedTestCodec, upcasters);
1932 let loaded: SoapResult<Vec<RecordedEvent<VersionedTestEvent>>> =
1933 block_on(store.load(&stream, None));
1934 assert!(matches!(loaded, Ok(events) if events.len() == 1
1935 && events[0].envelope.message == VersionedTestEvent::Created {
1936 name: "Ada".to_owned(),
1937 email: "unknown@example.test".to_owned(),
1938 }
1939 && events[0]
1940 .envelope
1941 .metadata
1942 .correlation_id()
1943 .is_some_and(|id| id.as_str() == "registration-1")));
1944
1945 let appended = block_on(store.append(
1946 &stream,
1947 ExpectedVersion::Exact(StreamVersion::FIRST),
1948 vec![MessageEnvelope::new(
1949 VersionedTestEvent::Created {
1950 name: "Grace".to_owned(),
1951 email: "grace@example.test".to_owned(),
1952 },
1953 MessageMetadata::new("versioned-event-2", UNIX_EPOCH),
1954 )],
1955 ));
1956 assert!(appended.is_ok(), "{appended:?}");
1957 let raw_events = block_on(store.store().load(&stream, Some(StreamVersion::FIRST)));
1958 assert!(matches!(raw_events, Ok(events) if events.len() == 1
1959 && events[0].encoded.event.schema_version == 2
1960 && events[0].encoded.event.payload == "Grace|grace@example.test"));
1961 }
1962
1963 #[test]
1964 fn checkpoint_inbox_and_snapshot_pass_shared_contracts() {
1965 let checkpoint_result = block_on(verify_projection_checkpoint_contract(
1966 &MemoryProjectionCheckpointStore::new(),
1967 ));
1968 assert!(checkpoint_result.is_ok(), "{checkpoint_result:?}");
1969
1970 let inbox_result = block_on(verify_inbox_contract(
1971 &MemoryInboxStore::new(),
1972 MessageMetadata::new("incoming-1", UNIX_EPOCH).id(),
1973 UNIX_EPOCH,
1974 ));
1975 assert!(inbox_result.is_ok(), "{inbox_result:?}");
1976
1977 let snapshots = MemorySnapshotStore::new();
1978 let first = Snapshot {
1979 stream_id: StreamId::new("order-1"),
1980 stream_version: StreamVersion::new(1),
1981 state: "first".to_owned(),
1982 captured_at: UNIX_EPOCH,
1983 };
1984 let second = Snapshot {
1985 stream_id: StreamId::new("order-1"),
1986 stream_version: StreamVersion::new(2),
1987 state: "second".to_owned(),
1988 captured_at: UNIX_EPOCH + Duration::from_secs(1),
1989 };
1990 let snapshot_result = block_on(verify_snapshot_contract(&snapshots, first, second));
1991 assert!(snapshot_result.is_ok(), "{snapshot_result:?}");
1992 }
1993
1994 #[test]
1995 fn outbox_claims_once_until_failure_releases_the_message() {
1996 let store = MemoryOutboxStore::new();
1997 let record = OutboxRecord::pending(
1998 MessageEnvelope::new(
1999 Outgoing("payload".to_owned()),
2000 MessageMetadata::new("outgoing-1", UNIX_EPOCH),
2001 ),
2002 UNIX_EPOCH,
2003 );
2004 let dead_letter_record = OutboxRecord::pending(
2005 MessageEnvelope::new(
2006 Outgoing("dead-letter".to_owned()),
2007 MessageMetadata::new("outgoing-dead-letter", UNIX_EPOCH),
2008 ),
2009 UNIX_EPOCH,
2010 );
2011 let result = block_on(verify_outbox_contract(
2012 &store,
2013 record,
2014 dead_letter_record,
2015 UNIX_EPOCH,
2016 ));
2017 assert!(result.is_ok(), "{result:?}");
2018 }
2019
2020 #[test]
2021 fn outbox_processor_delivers_retries_and_dead_letters_with_one_policy() {
2022 let store = MemoryOutboxStore::new();
2023 let now = UNIX_EPOCH + Duration::from_secs(10);
2024 let record = |id: &str, payload: &str| {
2025 OutboxRecord::pending(
2026 MessageEnvelope::new(Outgoing(payload.to_owned()), MessageMetadata::new(id, now)),
2027 now,
2028 )
2029 };
2030 let enqueued = block_on(store.enqueue(vec![
2031 record("delivery-success", "success"),
2032 record("delivery-retry", "retry"),
2033 record("delivery-dead-letter", "dead-letter"),
2034 ]));
2035 assert!(enqueued.is_ok(), "{enqueued:?}");
2036
2037 let policy = TransientRetryPolicy::new(3, Duration::from_secs(5), Duration::from_secs(30));
2038 let Some(policy) = policy.ok() else {
2039 panic!("valid retry policy was rejected");
2040 };
2041 let clock = move || now;
2042 let publisher = SelectivePublisher;
2043 let processor = OutboxProcessor::new(&store, &publisher, &policy, &clock);
2044 let report = block_on(processor.process_batch::<Outgoing>(
2045 DeliveryClaimId::new("processor-claim"),
2046 Duration::from_secs(60),
2047 10,
2048 ));
2049 assert_eq!(
2050 report.ok(),
2051 Some(OutboxProcessingReport {
2052 claimed: 3,
2053 delivered: 1,
2054 retry_scheduled: 1,
2055 dead_lettered: 1,
2056 })
2057 );
2058
2059 let retry = block_on(store.claim_pending(
2060 DeliveryClaimId::new("retry-claim"),
2061 now + Duration::from_secs(5),
2062 now + Duration::from_secs(65),
2063 10,
2064 ));
2065 assert!(matches!(retry, Ok(records) if records.len() == 1
2066 && records[0].record.envelope.metadata.id().as_str() == "delivery-retry"
2067 && records[0].record.attempts == 1));
2068 }
2069
2070 #[test]
2071 fn inbox_processor_completes_duplicates_and_releases_failures() {
2072 let store = MemoryInboxStore::new();
2073 let handler = SelectiveHandler::default();
2074 let now = UNIX_EPOCH + Duration::from_secs(10);
2075 let clock = move || now;
2076 let processor = InboxProcessor::new(&store, &handler, &clock);
2077 let successful = MessageEnvelope::new(
2078 Outgoing("success".to_owned()),
2079 MessageMetadata::new("incoming-success", now),
2080 );
2081
2082 let first = block_on(processor.process(
2083 "orders",
2084 successful.clone(),
2085 DeliveryClaimId::new("inbox-success-1"),
2086 Duration::from_secs(60),
2087 ));
2088 assert_eq!(first.ok(), Some(InboxProcessingOutcome::Processed));
2089 let duplicate = block_on(processor.process(
2090 "orders",
2091 successful,
2092 DeliveryClaimId::new("inbox-success-2"),
2093 Duration::from_secs(60),
2094 ));
2095 assert_eq!(
2096 duplicate.ok(),
2097 Some(InboxProcessingOutcome::AlreadyProcessed)
2098 );
2099
2100 let active = MessageEnvelope::new(
2101 Outgoing("active".to_owned()),
2102 MessageMetadata::new("incoming-active", now),
2103 );
2104 let active_claim = block_on(store.claim(
2105 "orders",
2106 active.metadata.id(),
2107 DeliveryClaimId::new("inbox-active-owner"),
2108 now,
2109 now + Duration::from_secs(60),
2110 ));
2111 assert!(matches!(active_claim, Ok(InboxClaim::Acquired)));
2112 let in_progress = block_on(processor.process(
2113 "orders",
2114 active,
2115 DeliveryClaimId::new("inbox-active-duplicate"),
2116 Duration::from_secs(60),
2117 ));
2118 assert_eq!(in_progress.ok(), Some(InboxProcessingOutcome::InProgress));
2119
2120 let failing = || {
2121 MessageEnvelope::new(
2122 Outgoing("fail".to_owned()),
2123 MessageMetadata::new("incoming-failure", now),
2124 )
2125 };
2126 let first_failure = block_on(processor.process(
2127 "orders",
2128 failing(),
2129 DeliveryClaimId::new("inbox-failure-1"),
2130 Duration::from_secs(60),
2131 ));
2132 let second_failure = block_on(processor.process(
2133 "orders",
2134 failing(),
2135 DeliveryClaimId::new("inbox-failure-2"),
2136 Duration::from_secs(60),
2137 ));
2138 assert!(first_failure.is_err());
2139 assert!(second_failure.is_err());
2140 assert_eq!(
2141 handler.seen.lock().ok().map(|seen| seen.clone()),
2142 Some(vec![
2143 "success".to_owned(),
2144 "fail".to_owned(),
2145 "fail".to_owned(),
2146 ])
2147 );
2148 }
2149
2150 #[test]
2151 fn transactional_outbox_does_not_commit_messages_after_version_conflict() {
2152 let store = MemoryEventOutbox::new();
2153 let stream = StreamId::new("order-atomic");
2154 let outgoing = |id: &str| {
2155 OutboxRecord::pending(
2156 MessageEnvelope::new(
2157 Outgoing(id.to_owned()),
2158 MessageMetadata::new(id, UNIX_EPOCH),
2159 ),
2160 UNIX_EPOCH,
2161 )
2162 };
2163
2164 let result = block_on(verify_transactional_event_outbox_contract(
2165 &store,
2166 store.event_store(),
2167 store.outbox(),
2168 TransactionalEventOutboxFixture {
2169 stream,
2170 first_event: event("atomic-event-1", TestEvent::Opened),
2171 conflicting_event: event("atomic-event-2", TestEvent::Closed),
2172 first_message: outgoing("atomic-message-1"),
2173 conflicting_message: outgoing("atomic-message-2"),
2174 now: UNIX_EPOCH,
2175 },
2176 ));
2177 assert!(result.is_ok(), "{result:?}");
2178 }
2179
2180 struct RecordingReplay {
2181 seen: Mutex<Vec<u64>>,
2182 }
2183
2184 impl EventReplayHandler<TestEvent> for RecordingReplay {
2185 fn handle_batch<'a>(
2186 &'a self,
2187 events: &'a [RecordedEvent<TestEvent>],
2188 ) -> BoxFuture<'a, SoapResult<()>> {
2189 Box::pin(async move {
2190 let mut seen = self
2191 .seen
2192 .lock()
2193 .map_err(|_| SoapError::infrastructure("replay test lock poisoned"))?;
2194 seen.extend(events.iter().map(|item| item.global_position.get()));
2195 Ok(())
2196 })
2197 }
2198 }
2199
2200 struct RecordingProjection {
2201 id: ProjectionId,
2202 seen: Mutex<Vec<u64>>,
2203 }
2204
2205 impl Projection<TestEvent> for RecordingProjection {
2206 fn id(&self) -> &ProjectionId {
2207 &self.id
2208 }
2209
2210 fn project<'a>(
2211 &'a self,
2212 event: &'a RecordedEvent<TestEvent>,
2213 ) -> BoxFuture<'a, SoapResult<()>> {
2214 Box::pin(async move {
2215 self.seen
2216 .lock()
2217 .map_err(|_| SoapError::infrastructure("projection test lock poisoned"))?
2218 .push(event.global_position.get());
2219 Ok(())
2220 })
2221 }
2222 }
2223
2224 impl IdempotentProjection<TestEvent> for RecordingProjection {}
2225
2226 #[test]
2227 fn replay_and_projection_resume_from_durable_positions() {
2228 let store = MemoryEventStore::new();
2229 let stream = StreamId::new("replay-stream");
2230 let append = block_on(store.append(
2231 &stream,
2232 ExpectedVersion::NoStream,
2233 vec![
2234 event("replay-1", TestEvent::Opened),
2235 event("replay-2", TestEvent::Closed),
2236 ],
2237 ));
2238 assert!(append.is_ok(), "{append:?}");
2239
2240 let handler = RecordingReplay {
2241 seen: Mutex::new(Vec::new()),
2242 };
2243 let replayer = EventReplayer::new(&store);
2244 let Some(options) = ReplayOptions::new(1).ok() else {
2245 panic!("valid replay options were rejected");
2246 };
2247 let replay = block_on(replayer.replay::<TestEvent, _>(options, &handler));
2248 assert_eq!(replay.ok().map(|report| report.processed), Some(2));
2249 assert_eq!(
2250 handler.seen.lock().ok().map(|seen| seen.clone()),
2251 Some(vec![1, 2])
2252 );
2253
2254 let checkpoints = MemoryProjectionCheckpointStore::new();
2255 let projection = RecordingProjection {
2256 id: ProjectionId::new("orders"),
2257 seen: Mutex::new(Vec::new()),
2258 };
2259 let runner = ProjectionRunner::new(&store, &checkpoints);
2260 assert_eq!(
2261 block_on(runner.run_once::<TestEvent, _>(&projection, 10)).ok(),
2262 Some(2)
2263 );
2264 assert_eq!(
2265 block_on(runner.run_once::<TestEvent, _>(&projection, 10)).ok(),
2266 Some(0)
2267 );
2268 }
2269
2270 #[test]
2271 fn transactional_projection_passes_shared_contract() {
2272 let projection = MemoryTransactionalProjection::new(
2273 ProjectionId::new("transactional-contract"),
2274 Vec::<u64>::new(),
2275 |model: &mut Vec<u64>, event: &RecordedEvent<TestEvent>| {
2276 model.push(event.global_position.get());
2277 Ok(())
2278 },
2279 );
2280 let first = recorded_event(1, TestEvent::Opened);
2281 let second = recorded_event(2, TestEvent::Closed);
2282
2283 let result = block_on(verify_transactional_projection_contract(
2284 &projection,
2285 &first,
2286 &second,
2287 ));
2288 assert!(result.is_ok(), "{result:?}");
2289 assert_eq!(
2290 projection.snapshot().ok(),
2291 Some((vec![1, 2], Some(GlobalPosition::new(2))))
2292 );
2293 }
2294
2295 #[test]
2296 fn transactional_projection_rolls_back_model_and_checkpoint_on_failure() {
2297 let store = MemoryEventStore::new();
2298 let stream = StreamId::new("transactional-projection-stream");
2299 let append = block_on(store.append(
2300 &stream,
2301 ExpectedVersion::NoStream,
2302 vec![
2303 event("projection-1", TestEvent::Opened),
2304 event("projection-2", TestEvent::Renamed("fail".to_owned())),
2305 event("projection-3", TestEvent::Closed),
2306 ],
2307 ));
2308 assert!(append.is_ok(), "{append:?}");
2309
2310 let projection = MemoryTransactionalProjection::new(
2311 ProjectionId::new("transactional-rollback"),
2312 Vec::<String>::new(),
2313 |model: &mut Vec<String>, event: &RecordedEvent<TestEvent>| {
2314 match &event.envelope.message {
2315 TestEvent::Opened => model.push("opened".to_owned()),
2316 TestEvent::Renamed(name) => {
2317 model.push(name.clone());
2318 return Err(SoapError::domain("projection update failed"));
2319 }
2320 TestEvent::Closed => model.push("closed".to_owned()),
2321 }
2322 Ok(())
2323 },
2324 );
2325 let runner = TransactionalProjectionRunner::new(&store);
2326
2327 let result = block_on(runner.run_once::<TestEvent, _>(&projection, 10));
2328 assert_eq!(
2329 result.as_ref().map_err(SoapError::kind),
2330 Err(soaprs_core::SoapErrorKind::Domain)
2331 );
2332 assert_eq!(
2333 projection.snapshot().ok(),
2334 Some((vec!["opened".to_owned()], Some(GlobalPosition::new(1))))
2335 );
2336 }
2337
2338 #[test]
2339 fn saga_store_round_trips_application_state() {
2340 let store = super::MemorySagaStore::new();
2341 let id = SagaId::new("checkout-1");
2342 let result = block_on(verify_saga_store_contract(
2343 &store,
2344 &id,
2345 "waiting-for-payment".to_owned(),
2346 "completed".to_owned(),
2347 ));
2348 assert!(result.is_ok(), "{result:?}");
2349 }
2350
2351 #[test]
2352 fn saga_outbox_passes_atomic_state_action_and_timer_contract() {
2353 let store = MemorySagaOutbox::new();
2354 let later = UNIX_EPOCH + Duration::from_secs(60);
2355 let result = block_on(verify_transactional_saga_outbox_contract(
2356 &store,
2357 store.saga_store(),
2358 store.action_store(),
2359 TransactionalSagaOutboxFixture {
2360 saga_id: SagaId::new("contract-durable-saga"),
2361 first_state: "running".to_owned(),
2362 conflicting_state: "stale".to_owned(),
2363 first_transition: MessageMetadata::new("saga-transition-1", UNIX_EPOCH),
2364 first_actions: vec![
2365 SagaAction::Dispatch(TestCommand::Continue),
2366 SagaAction::Schedule {
2367 delay: Duration::from_secs(60),
2368 command: TestCommand::Continue,
2369 },
2370 SagaAction::Complete,
2371 ],
2372 conflicting_transition: MessageMetadata::new(
2373 "saga-transition-conflict",
2374 UNIX_EPOCH,
2375 ),
2376 conflicting_actions: vec![SagaAction::Dispatch(TestCommand::Reject)],
2377 now: UNIX_EPOCH,
2378 later,
2379 expected_due_now: 2,
2380 expected_due_later: 1,
2381 },
2382 ));
2383 assert!(result.is_ok(), "{result:?}");
2384 }
2385
2386 #[test]
2387 fn saga_coordinator_commits_actions_instead_of_returning_them() {
2388 let store = MemorySagaOutbox::new();
2389 let coordinator = SagaCoordinator::new(&store);
2390 let saga = TestSaga {
2391 id: SagaId::new("durable-coordinated-saga"),
2392 status: SagaStatus::Pending,
2393 };
2394 let triggering_event = event("durable-saga-event", TestEvent::Opened);
2395 let transition = block_on(coordinator.transition(
2396 saga,
2397 ExpectedSagaVersion::NoSaga,
2398 &triggering_event,
2399 MessageMetadata::new("durable-saga-transition", UNIX_EPOCH),
2400 ));
2401 assert_eq!(
2402 transition
2403 .as_ref()
2404 .ok()
2405 .map(|result| result.commit.queued_actions),
2406 Some(2)
2407 );
2408 let claimed = block_on(store.action_store().claim_pending(
2409 DeliveryClaimId::new("durable-saga-worker"),
2410 UNIX_EPOCH,
2411 UNIX_EPOCH + Duration::from_secs(60),
2412 10,
2413 ));
2414 assert!(matches!(claimed, Ok(actions) if actions.len() == 2));
2415 }
2416
2417 #[test]
2418 fn saga_action_processor_dispatches_retries_dead_letters_and_waits_for_timers() {
2419 let store = MemorySagaOutbox::new();
2420 let committed = block_on(store.commit_transition(
2421 &SagaId::new("processed-saga"),
2422 ExpectedSagaVersion::NoSaga,
2423 "running".to_owned(),
2424 MessageMetadata::new("processed-saga-transition", UNIX_EPOCH),
2425 vec![
2426 SagaAction::Dispatch(TestCommand::Continue),
2427 SagaAction::Dispatch(TestCommand::Retry),
2428 SagaAction::Compensate(TestCommand::Reject),
2429 SagaAction::Complete,
2430 SagaAction::Schedule {
2431 delay: Duration::from_secs(60),
2432 command: TestCommand::Continue,
2433 },
2434 ],
2435 ));
2436 assert!(committed.is_ok(), "{committed:?}");
2437
2438 let policy = TransientRetryPolicy::new(3, Duration::from_secs(5), Duration::from_secs(30));
2439 let Some(policy) = policy.ok() else {
2440 panic!("valid retry policy was rejected");
2441 };
2442 let handler = TestCommandHandler::default();
2443 let clock = || UNIX_EPOCH;
2444 let processor = SagaActionProcessor::new(store.action_store(), &handler, &policy, &clock);
2445 let report = block_on(processor.process_batch::<TestCommand>(
2446 DeliveryClaimId::new("saga-action-processor"),
2447 Duration::from_secs(30),
2448 10,
2449 ));
2450 assert_eq!(
2451 report.ok(),
2452 Some(SagaActionProcessingReport {
2453 claimed: 4,
2454 dispatched: 1,
2455 markers_completed: 1,
2456 retry_scheduled: 1,
2457 dead_lettered: 1,
2458 })
2459 );
2460 let before_timer = block_on(store.action_store().claim_pending(
2461 DeliveryClaimId::new("saga-before-timer"),
2462 UNIX_EPOCH + Duration::from_secs(4),
2463 UNIX_EPOCH + Duration::from_secs(30),
2464 10,
2465 ));
2466 assert!(matches!(before_timer, Ok(actions) if actions.is_empty()));
2467 let retry_claim = DeliveryClaimId::new("saga-retry");
2468 let retry = block_on(store.action_store().claim_pending(
2469 retry_claim.clone(),
2470 UNIX_EPOCH + Duration::from_secs(5),
2471 UNIX_EPOCH + Duration::from_secs(30),
2472 10,
2473 ));
2474 let Ok(retry) = retry else {
2475 panic!("retryable saga action could not be claimed");
2476 };
2477 assert_eq!(retry.len(), 1);
2478 assert_eq!(retry[0].record.attempts, 1);
2479 let retry_completed = block_on(store.action_store().mark_completed(
2480 &retry[0].record.id,
2481 &retry_claim,
2482 UNIX_EPOCH + Duration::from_secs(5),
2483 ));
2484 assert!(retry_completed.is_ok(), "{retry_completed:?}");
2485 let timer = block_on(store.action_store().claim_pending(
2486 DeliveryClaimId::new("saga-timer"),
2487 UNIX_EPOCH + Duration::from_secs(60),
2488 UNIX_EPOCH + Duration::from_secs(90),
2489 10,
2490 ));
2491 assert!(matches!(timer, Ok(actions) if actions.len() == 1
2492 && matches!(actions[0].record.action, SagaAction::Schedule { .. })));
2493 }
2494}