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    /// Hydrate an already-loaded entity from the cache record (if any). Used on
45    /// load paths that have the full stream in hand (locked reads).
46    hydrate: HydrateFn<R, A>,
47    /// Hydrate a batch of already-loaded entities, reading all cache records in
48    /// one round trip. Used by batch loads (`get_all`).
49    hydrate_all: HydrateAllFn<R, A>,
50    /// Own the whole load: read the snapshot first, then fetch only the tail of
51    /// the stream (skipping already-snapshotted I/O), falling back to a full
52    /// load on a cache miss. Used by the single-aggregate `get` hot path.
53    load: LoadFn<R, A>,
54}
55
56type HydrateFn<R, A> =
57    for<'a> fn(
58        &'a R,
59        &'a StreamIdentity,
60        Entity,
61    ) -> Pin<Box<dyn Future<Output = Result<A, RepositoryError>> + Send + 'a>>;
62
63type HydrateAllFn<R, A> =
64    for<'a> fn(
65        &'a R,
66        Vec<(StreamIdentity, Entity)>,
67    ) -> Pin<Box<dyn Future<Output = Result<Vec<A>, RepositoryError>> + Send + 'a>>;
68
69type LoadFn<R, A> = for<'a> fn(
70    &'a R,
71    &'a StreamIdentity,
72) -> Pin<
73    Box<dyn Future<Output = Result<Option<A>, RepositoryError>> + Send + 'a>,
74>;
75
76impl<R, A> SnapshotPolicy<R, A> {
77    /// Construct a policy from its captured hooks. Called by `with_snapshots`,
78    /// which carries the `Snapshottable`/`SnapshotStore`/`GetStream` bounds.
79    pub(crate) fn new(
80        frequency: u64,
81        record: fn(&A, u64) -> Result<Option<SnapshotRecord>, RepositoryError>,
82        hydrate: HydrateFn<R, A>,
83        hydrate_all: HydrateAllFn<R, A>,
84        load: LoadFn<R, A>,
85    ) -> Self {
86        Self {
87            frequency,
88            record,
89            hydrate,
90            hydrate_all,
91            load,
92        }
93    }
94}
95
96/// Repository wrapper for a specific aggregate type.
97///
98/// Snapshots are an optional, transparent optimization: `with_snapshots(n)`
99/// configures snapshot caching on this same type, and every method behaves
100/// identically with or without it — on commit a snapshot is staged in the same
101/// transaction when due, and on load the aggregate is hydrated from a snapshot
102/// when one exists.
103pub struct AggregateRepository<R, A> {
104    repo: R,
105    snapshot: Option<SnapshotPolicy<R, A>>,
106    outbox_publisher: Option<OutboxPublisherConfig>,
107    _marker: PhantomData<A>,
108}
109
110impl<R, A> AggregateRepository<R, A> {
111    pub fn new(repo: R) -> Self {
112        Self {
113            repo,
114            snapshot: None,
115            outbox_publisher: None,
116            _marker: PhantomData,
117        }
118    }
119
120    pub fn repo(&self) -> &R {
121        &self.repo
122    }
123
124    pub fn repo_mut(&mut self) -> &mut R {
125        &mut self.repo
126    }
127
128    /// Install the snapshot policy (used by `with_snapshots`).
129    pub(crate) fn set_snapshot_policy(&mut self, policy: SnapshotPolicy<R, A>) {
130        self.snapshot = Some(policy);
131    }
132
133    /// Install the outbox publisher so commits publish immediately (used by
134    /// `Service::with_bus`).
135    pub(crate) fn set_outbox_publisher(&mut self, config: OutboxPublisherConfig) {
136        self.outbox_publisher = Some(config);
137    }
138
139    /// The configured outbox publisher, if any. Consulted by
140    /// `OutboxCommit::commit`.
141    pub(crate) fn outbox_publisher(&self) -> Option<&OutboxPublisherConfig> {
142        self.outbox_publisher.as_ref()
143    }
144}
145
146impl<R, A> AggregateRepository<R, A>
147where
148    A: Aggregate + Send,
149{
150    /// Hydrate one entity into an aggregate, using the snapshot cache when a
151    /// policy is configured and a cache record is available, otherwise a full
152    /// replay. Same result either way.
153    async fn hydrate_entity(
154        &self,
155        identity: &StreamIdentity,
156        entity: Entity,
157    ) -> Result<A, RepositoryError> {
158        match &self.snapshot {
159            Some(policy) => (policy.hydrate)(&self.repo, identity, entity).await,
160            None => hydrate::<A>(entity),
161        }
162    }
163
164    /// Snapshot writes to stage alongside a commit of `aggregate`, plus the
165    /// covered version to record on the entity afterwards. Empty when no policy
166    /// is configured or a snapshot is not yet due.
167    fn snapshot_writes(
168        &self,
169        aggregate: &A,
170    ) -> Result<(Vec<SnapshotWrite>, Option<u64>), RepositoryError> {
171        let Some(policy) = &self.snapshot else {
172            return Ok((Vec::new(), None));
173        };
174        let Some(record) = (policy.record)(aggregate, policy.frequency)? else {
175            return Ok((Vec::new(), None));
176        };
177        let version = record.version;
178        let identity = stream_identity_for::<A>(aggregate.entity().id())?;
179        Ok((
180            vec![SnapshotWrite::Save { identity, record }],
181            Some(version),
182        ))
183    }
184
185    /// Snapshot writes for `aggregate`, exposed to the outbox/read-model commit
186    /// builders so they stage snapshots in the same transaction.
187    pub(crate) fn snapshot_writes_for(
188        &self,
189        aggregate: &A,
190    ) -> Result<(Vec<SnapshotWrite>, Option<u64>), RepositoryError> {
191        self.snapshot_writes(aggregate)
192    }
193}
194
195impl<R, A> AggregateRepository<R, A>
196where
197    R: GetStream,
198    A: Aggregate + Send,
199{
200    pub async fn get(&self, id: &str) -> Result<Option<A>, RepositoryError> {
201        let identity = stream_identity_for::<A>(id)?;
202        // With a snapshot policy, the policy owns the load so it can read the
203        // snapshot first and fetch only the post-snapshot tail (skipping the I/O
204        // and decode of already-snapshotted events). Without one, a plain full
205        // stream load. Same hydrated aggregate either way.
206        match &self.snapshot {
207            Some(policy) => (policy.load)(&self.repo, &identity).await,
208            None => {
209                let Some(entity) = self.repo.get_stream(&identity).await? else {
210                    return Ok(None);
211                };
212                Ok(Some(hydrate::<A>(entity)?))
213            }
214        }
215    }
216
217    /// Load existing aggregates for the provided ids.
218    ///
219    /// Each id is converted to a `StreamIdentity`, fetched through `get_streams`,
220    /// and hydrated if present. Missing streams are skipped, and backend
221    /// implementations may return aggregates in storage order rather than input
222    /// order.
223    pub async fn get_all(&self, ids: &[&str]) -> Result<Vec<A>, RepositoryError> {
224        let identities = ids
225            .iter()
226            .map(|id| stream_identity_for::<A>(id))
227            .collect::<Result<Vec<_>, _>>()?;
228        let entities = self.repo.get_streams(&identities).await?;
229        self.hydrate_entities(entities).await
230    }
231}
232
233impl<R, A> AggregateRepository<R, A>
234where
235    A: Aggregate + Send,
236{
237    /// Hydrate a batch of entities, deriving each identity from the entity id.
238    /// With a snapshot policy the cache records for the whole batch are read in
239    /// one round trip; without one, a plain per-entity replay.
240    async fn hydrate_entities(&self, entities: Vec<Entity>) -> Result<Vec<A>, RepositoryError> {
241        match &self.snapshot {
242            Some(policy) => {
243                let mut pairs = Vec::with_capacity(entities.len());
244                for entity in entities {
245                    let identity = stream_identity_for::<A>(entity.id())?;
246                    pairs.push((identity, entity));
247                }
248                (policy.hydrate_all)(&self.repo, pairs).await
249            }
250            None => entities.into_iter().map(hydrate::<A>).collect(),
251        }
252    }
253}
254
255impl<R, A> AggregateRepository<R, A>
256where
257    R: TransactionalCommit,
258    A: Aggregate + Send,
259{
260    pub async fn commit(&self, aggregate: &mut A) -> Result<(), RepositoryError> {
261        let (snapshots, snapshot_version) = self.snapshot_writes(aggregate)?;
262        let identity = stream_identity_for::<A>(aggregate.entity().id())?;
263        let stream = StreamWrite::new(identity, aggregate.entity_mut());
264        let mut batch = CommitBatch::new(vec![stream]);
265        batch.snapshots = snapshots;
266        self.repo.commit_batch(batch).await?;
267        if let Some(version) = snapshot_version {
268            aggregate.entity_mut().set_snapshot_version(version);
269        }
270        Ok(())
271    }
272
273    pub async fn commit_all(&self, aggregates: &mut [&mut A]) -> Result<(), RepositoryError> {
274        // Compute snapshot writes (immutable borrows) before taking the mutable
275        // entity borrows for the streams.
276        let mut snapshots = Vec::new();
277        let mut snapshot_versions = Vec::with_capacity(aggregates.len());
278        for aggregate in aggregates.iter() {
279            let (mut writes, version) = self.snapshot_writes(aggregate)?;
280            snapshots.append(&mut writes);
281            snapshot_versions.push(version);
282        }
283
284        let mut streams = Vec::with_capacity(aggregates.len());
285        for aggregate in aggregates.iter_mut() {
286            let identity = stream_identity_for::<A>((*aggregate).entity().id())?;
287            streams.push(StreamWrite::new(identity, (*aggregate).entity_mut()));
288        }
289        let mut batch = CommitBatch::new(streams);
290        batch.snapshots = snapshots;
291        self.repo.commit_batch(batch).await?;
292
293        for (aggregate, version) in aggregates.iter_mut().zip(snapshot_versions) {
294            if let Some(version) = version {
295                (*aggregate).entity_mut().set_snapshot_version(version);
296            }
297        }
298        Ok(())
299    }
300
301    pub async fn commit_entities(
302        &self,
303        streams: Vec<(StreamIdentity, &mut Entity)>,
304    ) -> Result<(), RepositoryError> {
305        let streams = streams
306            .into_iter()
307            .map(|(identity, entity)| StreamWrite::new(identity, entity))
308            .collect();
309        self.repo.commit_batch(CommitBatch::new(streams)).await
310    }
311}
312
313impl<R, A> AggregateRepository<R, A>
314where
315    R: GetWithOpts,
316    A: Aggregate + Send,
317{
318    /// Load an aggregate with options (e.g. `ReadOpts::no_lock()` to skip the
319    /// queue lock when the repository is a `queued()` wrapper).
320    pub async fn get_with(&self, id: &str, opts: ReadOpts) -> Result<Option<A>, RepositoryError> {
321        let identity = stream_identity_for::<A>(id)?;
322        let Some(entity) = self.repo.get_stream_with(&identity, opts).await? else {
323            return Ok(None);
324        };
325        Ok(Some(self.hydrate_entity(&identity, entity).await?))
326    }
327
328    /// Non-locking read (alias for `get_with(ReadOpts::no_lock())`).
329    pub async fn peek(&self, id: &str) -> Result<Option<A>, RepositoryError> {
330        self.get_with(id, ReadOpts::no_lock()).await
331    }
332}
333
334impl<R, A> AggregateRepository<R, A>
335where
336    R: GetAllWithOpts,
337    A: Aggregate + Send,
338{
339    /// Load aggregates for the provided ids with options.
340    pub async fn get_all_with(
341        &self,
342        ids: &[&str],
343        opts: ReadOpts,
344    ) -> Result<Vec<A>, RepositoryError> {
345        let identities = ids
346            .iter()
347            .map(|id| stream_identity_for::<A>(id))
348            .collect::<Result<Vec<_>, _>>()?;
349        let entities = self.repo.get_streams_with(&identities, opts).await?;
350        self.hydrate_entities(entities).await
351    }
352
353    /// Non-locking multi-read (alias for `get_all_with(ReadOpts::no_lock())`).
354    pub async fn peek_all(&self, ids: &[&str]) -> Result<Vec<A>, RepositoryError> {
355        self.get_all_with(ids, ReadOpts::no_lock()).await
356    }
357}
358
359impl<R, A> AggregateRepository<R, A>
360where
361    R: UnlockableRepository,
362    A: Aggregate,
363{
364    /// Release the lock held for an aggregate after an aborted load.
365    pub async fn abort(&self, aggregate: &A) -> Result<(), RepositoryError> {
366        let identity = stream_identity_for::<A>(aggregate.entity().id())?;
367        // Forward to the repo's `abort` hook (not `unlock`) so an
368        // `UnlockableRepository` that overrides `abort` for extra cleanup
369        // is honored. The default `abort` delegates to `unlock`.
370        self.repo.abort(&identity).await
371    }
372
373    /// Release the lock held for an aggregate id.
374    pub async fn unlock(&self, id: &str) -> Result<(), RepositoryError> {
375        let identity = stream_identity_for::<A>(id)?;
376        self.repo.unlock(&identity).await
377    }
378}