Skip to main content

distributed/aggregate/
repository.rs

1use std::future::Future;
2use std::marker::PhantomData;
3use std::pin::Pin;
4
5use crate::entity::Entity;
6use crate::outbox::OutboxPublisherConfig;
7use crate::queued_repo::{GetAllWithOpts, GetWithOpts, ReadOpts, UnlockableRepository};
8use crate::repository::{
9    CommitBatch, GetStream, RepositoryError, SnapshotWrite, StreamIdentity, StreamWrite,
10    TransactionalCommit,
11};
12use crate::snapshot::SnapshotRecord;
13
14use super::{hydrate, Aggregate};
15
16fn stream_identity_for<A: Aggregate>(
17    aggregate_id: &str,
18) -> Result<StreamIdentity, RepositoryError> {
19    StreamIdentity::new(A::aggregate_type(), aggregate_id)
20}
21
22/// Builder trait for creating typed async aggregate repositories.
23pub trait AggregateBuilder: Sized {
24    fn aggregate<A: Aggregate>(self) -> AggregateRepository<Self, A> {
25        AggregateRepository::new(self)
26    }
27}
28
29impl<T> AggregateBuilder for T {}
30
31/// Snapshot behaviour for an [`AggregateRepository`], installed by
32/// `with_snapshots`.
33///
34/// A snapshot is a rebuildable cache over the event stream, so enabling it must
35/// not change the repository's API. The `Snapshottable` / `SnapshotStore`
36/// requirements are captured here as monomorphized function pointers at
37/// `with_snapshots` time, which keeps the repository's generic get/commit methods
38/// unbounded — they just consult `Option<SnapshotPolicy>`.
39pub(crate) struct SnapshotPolicy<R, A> {
40    /// How many events between automatic snapshots.
41    frequency: u64,
42    /// Build a snapshot cache record for the aggregate when one is due.
43    record: fn(&A, u64) -> Result<Option<SnapshotRecord>, RepositoryError>,
44    /// Load the cache record (if any) and hydrate the aggregate from it.
45    hydrate: HydrateFn<R, A>,
46}
47
48type HydrateFn<R, A> =
49    for<'a> fn(
50        &'a R,
51        &'a StreamIdentity,
52        Entity,
53    ) -> Pin<Box<dyn Future<Output = Result<A, RepositoryError>> + Send + 'a>>;
54
55impl<R, A> SnapshotPolicy<R, A> {
56    /// Construct a policy from its captured hooks. Called by `with_snapshots`,
57    /// which carries the `Snapshottable`/`SnapshotStore` bounds.
58    pub(crate) fn new(
59        frequency: u64,
60        record: fn(&A, u64) -> Result<Option<SnapshotRecord>, RepositoryError>,
61        hydrate: HydrateFn<R, A>,
62    ) -> Self {
63        Self {
64            frequency,
65            record,
66            hydrate,
67        }
68    }
69}
70
71/// Async repository wrapper for a specific aggregate type.
72///
73/// Snapshots are an optional, transparent optimization: `with_snapshots(n)`
74/// configures snapshot caching on this same type, and every method behaves
75/// identically with or without it — on commit a snapshot is staged in the same
76/// transaction when due, and on load the aggregate is hydrated from a snapshot
77/// when one exists.
78pub struct AggregateRepository<R, A> {
79    repo: R,
80    snapshot: Option<SnapshotPolicy<R, A>>,
81    outbox_publisher: Option<OutboxPublisherConfig>,
82    _marker: PhantomData<A>,
83}
84
85impl<R, A> AggregateRepository<R, A> {
86    pub fn new(repo: R) -> Self {
87        Self {
88            repo,
89            snapshot: None,
90            outbox_publisher: None,
91            _marker: PhantomData,
92        }
93    }
94
95    pub fn repo(&self) -> &R {
96        &self.repo
97    }
98
99    pub fn repo_mut(&mut self) -> &mut R {
100        &mut self.repo
101    }
102
103    /// Install the snapshot policy (used by `with_snapshots`).
104    pub(crate) fn set_snapshot_policy(&mut self, policy: SnapshotPolicy<R, A>) {
105        self.snapshot = Some(policy);
106    }
107
108    /// Install the outbox publisher so commits publish immediately (used by
109    /// `Service::with_bus`).
110    pub(crate) fn set_outbox_publisher(&mut self, config: OutboxPublisherConfig) {
111        self.outbox_publisher = Some(config);
112    }
113
114    /// The configured outbox publisher, if any. Consulted by
115    /// `OutboxCommit::commit`.
116    pub(crate) fn outbox_publisher(&self) -> Option<&OutboxPublisherConfig> {
117        self.outbox_publisher.as_ref()
118    }
119}
120
121impl<R, A> AggregateRepository<R, A>
122where
123    A: Aggregate + Send,
124{
125    /// Hydrate one entity into an aggregate, using the snapshot cache when a
126    /// policy is configured and a cache record is available, otherwise a full
127    /// replay. Same result either way.
128    async fn hydrate_entity(
129        &self,
130        identity: &StreamIdentity,
131        entity: Entity,
132    ) -> Result<A, RepositoryError> {
133        match &self.snapshot {
134            Some(policy) => (policy.hydrate)(&self.repo, identity, entity).await,
135            None => hydrate::<A>(entity),
136        }
137    }
138
139    /// Snapshot writes to stage alongside a commit of `aggregate`, plus the
140    /// covered version to record on the entity afterwards. Empty when no policy
141    /// is configured or a snapshot is not yet due.
142    fn snapshot_writes(
143        &self,
144        aggregate: &A,
145    ) -> Result<(Vec<SnapshotWrite>, Option<u64>), RepositoryError> {
146        let Some(policy) = &self.snapshot else {
147            return Ok((Vec::new(), None));
148        };
149        let Some(record) = (policy.record)(aggregate, policy.frequency)? else {
150            return Ok((Vec::new(), None));
151        };
152        let version = record.version;
153        let identity = stream_identity_for::<A>(aggregate.entity().id())?;
154        Ok((
155            vec![SnapshotWrite::Save { identity, record }],
156            Some(version),
157        ))
158    }
159
160    /// Snapshot writes for `aggregate`, exposed to the outbox/read-model commit
161    /// builders so they stage snapshots in the same transaction.
162    pub(crate) fn snapshot_writes_for(
163        &self,
164        aggregate: &A,
165    ) -> Result<(Vec<SnapshotWrite>, Option<u64>), RepositoryError> {
166        self.snapshot_writes(aggregate)
167    }
168}
169
170impl<R, A> AggregateRepository<R, A>
171where
172    R: GetStream,
173    A: Aggregate + Send,
174{
175    pub async fn get(&self, id: &str) -> Result<Option<A>, RepositoryError> {
176        let identity = stream_identity_for::<A>(id)?;
177        let entity = self.repo.get_stream(&identity).await?;
178        let Some(entity) = entity else {
179            return Ok(None);
180        };
181        Ok(Some(self.hydrate_entity(&identity, entity).await?))
182    }
183
184    /// Load existing aggregates for the provided ids.
185    ///
186    /// Each id is converted to a `StreamIdentity`, fetched through `get_streams`,
187    /// and hydrated if present. Missing streams are skipped, and backend
188    /// implementations may return aggregates in storage order rather than input
189    /// order.
190    pub async fn get_all(&self, ids: &[&str]) -> Result<Vec<A>, RepositoryError> {
191        let identities = ids
192            .iter()
193            .map(|id| stream_identity_for::<A>(id))
194            .collect::<Result<Vec<_>, _>>()?;
195        let entities = self.repo.get_streams(&identities).await?;
196        self.hydrate_entities(entities).await
197    }
198}
199
200impl<R, A> AggregateRepository<R, A>
201where
202    A: Aggregate + Send,
203{
204    /// Hydrate a batch of entities, deriving each identity from the entity id so
205    /// the snapshot cache can be consulted per aggregate.
206    async fn hydrate_entities(&self, entities: Vec<Entity>) -> Result<Vec<A>, RepositoryError> {
207        let mut aggregates = Vec::with_capacity(entities.len());
208        for entity in entities {
209            let identity = stream_identity_for::<A>(entity.id())?;
210            aggregates.push(self.hydrate_entity(&identity, entity).await?);
211        }
212        Ok(aggregates)
213    }
214}
215
216impl<R, A> AggregateRepository<R, A>
217where
218    R: TransactionalCommit,
219    A: Aggregate + Send,
220{
221    pub async fn commit(&self, aggregate: &mut A) -> Result<(), RepositoryError> {
222        let (snapshots, snapshot_version) = self.snapshot_writes(aggregate)?;
223        let identity = stream_identity_for::<A>(aggregate.entity().id())?;
224        let stream = StreamWrite::new(identity, aggregate.entity_mut());
225        let mut batch = CommitBatch::new(vec![stream]);
226        batch.snapshots = snapshots;
227        self.repo.commit_batch(batch).await?;
228        if let Some(version) = snapshot_version {
229            aggregate.entity_mut().set_snapshot_version(version);
230        }
231        Ok(())
232    }
233
234    pub async fn commit_all(&self, aggregates: &mut [&mut A]) -> Result<(), RepositoryError> {
235        // Compute snapshot writes (immutable borrows) before taking the mutable
236        // entity borrows for the streams.
237        let mut snapshots = Vec::new();
238        let mut snapshot_versions = Vec::with_capacity(aggregates.len());
239        for aggregate in aggregates.iter() {
240            let (mut writes, version) = self.snapshot_writes(aggregate)?;
241            snapshots.append(&mut writes);
242            snapshot_versions.push(version);
243        }
244
245        let mut streams = Vec::with_capacity(aggregates.len());
246        for aggregate in aggregates.iter_mut() {
247            let identity = stream_identity_for::<A>((*aggregate).entity().id())?;
248            streams.push(StreamWrite::new(identity, (*aggregate).entity_mut()));
249        }
250        let mut batch = CommitBatch::new(streams);
251        batch.snapshots = snapshots;
252        self.repo.commit_batch(batch).await?;
253
254        for (aggregate, version) in aggregates.iter_mut().zip(snapshot_versions) {
255            if let Some(version) = version {
256                (*aggregate).entity_mut().set_snapshot_version(version);
257            }
258        }
259        Ok(())
260    }
261
262    pub async fn commit_entities(
263        &self,
264        streams: Vec<(StreamIdentity, &mut Entity)>,
265    ) -> Result<(), RepositoryError> {
266        let streams = streams
267            .into_iter()
268            .map(|(identity, entity)| StreamWrite::new(identity, entity))
269            .collect();
270        self.repo.commit_batch(CommitBatch::new(streams)).await
271    }
272}
273
274impl<R, A> AggregateRepository<R, A>
275where
276    R: GetWithOpts,
277    A: Aggregate + Send,
278{
279    /// Load an aggregate with options (e.g. `ReadOpts::no_lock()` to skip the
280    /// queue lock when the repository is a `queued()` wrapper).
281    pub async fn get_with(&self, id: &str, opts: ReadOpts) -> Result<Option<A>, RepositoryError> {
282        let identity = stream_identity_for::<A>(id)?;
283        let Some(entity) = self.repo.get_stream_with(&identity, opts).await? else {
284            return Ok(None);
285        };
286        Ok(Some(self.hydrate_entity(&identity, entity).await?))
287    }
288
289    /// Non-locking read (alias for `get_with(ReadOpts::no_lock())`).
290    pub async fn peek(&self, id: &str) -> Result<Option<A>, RepositoryError> {
291        self.get_with(id, ReadOpts::no_lock()).await
292    }
293}
294
295impl<R, A> AggregateRepository<R, A>
296where
297    R: GetAllWithOpts,
298    A: Aggregate + Send,
299{
300    /// Load aggregates for the provided ids with options.
301    pub async fn get_all_with(
302        &self,
303        ids: &[&str],
304        opts: ReadOpts,
305    ) -> Result<Vec<A>, RepositoryError> {
306        let identities = ids
307            .iter()
308            .map(|id| stream_identity_for::<A>(id))
309            .collect::<Result<Vec<_>, _>>()?;
310        let entities = self.repo.get_streams_with(&identities, opts).await?;
311        self.hydrate_entities(entities).await
312    }
313
314    /// Non-locking multi-read (alias for `get_all_with(ReadOpts::no_lock())`).
315    pub async fn peek_all(&self, ids: &[&str]) -> Result<Vec<A>, RepositoryError> {
316        self.get_all_with(ids, ReadOpts::no_lock()).await
317    }
318}
319
320impl<R, A> AggregateRepository<R, A>
321where
322    R: UnlockableRepository,
323    A: Aggregate,
324{
325    /// Release the lock held for an aggregate after an aborted load.
326    pub async fn abort(&self, aggregate: &A) -> Result<(), RepositoryError> {
327        let identity = stream_identity_for::<A>(aggregate.entity().id())?;
328        // Forward to the repo's `abort` hook (not `unlock`) so an
329        // `UnlockableRepository` that overrides `abort` for extra cleanup
330        // is honored. The default `abort` delegates to `unlock`.
331        self.repo.abort(&identity).await
332    }
333
334    /// Release the lock held for an aggregate id.
335    pub async fn unlock(&self, id: &str) -> Result<(), RepositoryError> {
336        let identity = stream_identity_for::<A>(id)?;
337        self.repo.unlock(&identity).await
338    }
339}