Skip to main content

aion_store/
store.rs

1//! Event-store traits and single-writer capability.
2
3use aion_core::{
4    Event, RunId, TimerId, WorkflowFilter, WorkflowId, WorkflowStatus, WorkflowSummary,
5};
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8
9use crate::{OutboxRow, StoreError, TimerEntry};
10
11mod write_capability {
12    /// Capability required to append workflow events.
13    ///
14    /// This token enforces Aion's single-writer durability invariant at the type level: only the
15    /// recorder append path may hold write authority for a workflow. `SequenceConflict` remains the
16    /// runtime defense-in-depth signal for any internal misuse or future bypass that attempts to
17    /// append with a stale head.
18    #[derive(Clone, Copy, Debug)]
19    pub struct WriteToken {
20        _private: (),
21    }
22
23    impl WriteToken {
24        /// Constructs a write token for Aion's recorder path.
25        #[must_use]
26        pub fn recorder() -> Self {
27            Self { _private: () }
28        }
29    }
30
31    pub(crate) fn conformance() -> WriteToken {
32        WriteToken { _private: () }
33    }
34}
35
36pub use write_capability::WriteToken;
37
38/// Summary of one concrete run in a workflow's continuation chain.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct RunSummary {
41    /// Concrete run identifier for this chain entry.
42    pub run_id: RunId,
43    /// Parent run that continued as this run, or `None` for the first run.
44    pub parent_run_id: Option<RunId>,
45    /// Status projected from this run's slice of lifecycle events.
46    pub status: WorkflowStatus,
47    /// Timestamp of this run's `WorkflowStarted` event.
48    pub started_at: DateTime<Utc>,
49    /// Timestamp of this run's terminal lifecycle event, when closed.
50    pub closed_at: Option<DateTime<Utc>>,
51}
52
53/// Read and durable-timer contract for Aion event stores.
54#[async_trait]
55pub trait ReadableEventStore: Send + Sync + 'static {
56    /// Reads the complete event history for `workflow_id` in ascending sequence order.
57    ///
58    /// A workflow with no recorded events is observed as an empty history. This includes unknown
59    /// workflow identifiers: because the first append with `expected_seq == 0` creates a workflow
60    /// implicitly, "unknown workflow" and "empty history" are the same observable state for reads.
61    /// This method must not return [`StoreError::NotFound`] for absent workflows.
62    async fn read_history(&self, workflow_id: &WorkflowId) -> Result<Vec<Event>, StoreError>;
63
64    /// Reads the event history for `workflow_id` restricted to events with sequence number
65    /// greater than or equal to `from_seq`, in ascending sequence order.
66    ///
67    /// This is the range-read primitive behind O(delta) WS resume: callers replaying from a
68    /// cursor must not pay for the full history. Semantics:
69    ///
70    /// - `from_seq <= 1` is equivalent to [`Self::read_history`]: sequence numbers start at 1,
71    ///   so every recorded event satisfies the bound.
72    /// - `from_seq` beyond the current head returns an empty vector, never an error. Whether a
73    ///   beyond-head cursor is *valid* is protocol judgment, not store judgment: the WS resume
74    ///   protocol rejects `resume_from_seq > head + 1` as an invalid cursor
75    ///   (`ResumeCursorAheadOfHistory`), but it makes that call by comparing the cursor against
76    ///   the head it observes — the store only answers which events exist at or after the
77    ///   requested sequence.
78    /// - Unknown workflows behave exactly like [`Self::read_history`] for unknown workflows:
79    ///   empty history, never [`StoreError::NotFound`], because "unknown workflow" and "empty
80    ///   history" are the same observable state for reads.
81    ///
82    /// There is deliberately no default implementation: a read-all-then-filter fallback would
83    /// silently reintroduce O(history) behavior. Every backend must implement this as a real
84    /// range read (for SQL backends, an indexed `seq >= ?` range scan).
85    async fn read_history_from(
86        &self,
87        workflow_id: &WorkflowId,
88        from_seq: u64,
89    ) -> Result<Vec<Event>, StoreError>;
90
91    /// Reads the concrete run chain for `workflow_id` in continuation order.
92    async fn read_run_chain(&self, workflow_id: &WorkflowId)
93    -> Result<Vec<RunSummary>, StoreError>;
94
95    /// Lists every workflow identifier that has at least one event in history.
96    ///
97    /// Unlike [`Self::list_active`], this includes terminal workflows and exists to let projection
98    /// repair jobs reconcile derived indexes against the authoritative event history.
99    async fn list_workflow_ids(&self) -> Result<Vec<WorkflowId>, StoreError>;
100
101    /// Lists workflow identifiers whose projected status is non-terminal.
102    async fn list_active(&self) -> Result<Vec<WorkflowId>, StoreError>;
103
104    /// Returns workflow summaries matching `filter`.
105    async fn query(&self, filter: &WorkflowFilter) -> Result<Vec<WorkflowSummary>, StoreError>;
106
107    /// Persists a durable timer for `workflow_id` that is due at `fire_at`.
108    ///
109    /// Timer scheduling remains on the public store surface because timers are not workflow-history
110    /// appends and are used by the timer subsystem after the recorder has written `TimerStarted`.
111    async fn schedule_timer(
112        &self,
113        workflow_id: &WorkflowId,
114        timer_id: &TimerId,
115        fire_at: DateTime<Utc>,
116    ) -> Result<(), StoreError>;
117
118    /// Returns durable timers whose `fire_at` is less than or equal to `as_of`.
119    async fn expired_timers(&self, as_of: DateTime<Utc>) -> Result<Vec<TimerEntry>, StoreError>;
120
121    /// Restrict every per-workflow enumeration (active workflows, timers, outbox
122    /// rows) to the named set of distribution shards this node owns, or restore
123    /// the own-all-shards default when `shards` is `None`.
124    ///
125    /// This is the engine-lifecycle hook behind a multi-shard deployment: the
126    /// boot path tells the store which shards this node serves so recovery and
127    /// enumeration see only that node's slice of the cluster's state. The
128    /// default implementation is a deliberate no-op — single-shard backends
129    /// (in-memory, libSQL) own everything unconditionally, so a `None` or any
130    /// shard set leaves their behaviour byte-identical. Only a sharded backend
131    /// (haematite) overrides this to scope its enumeration. Decorators that wrap
132    /// another store must forward this call to their inner store.
133    fn set_owned_shards(&self, shards: Option<&[usize]>) {
134        let _ = shards;
135    }
136
137    /// Acquire-and-serve ownership of each named distribution shard BEFORE the
138    /// boot path recovers or enumerates over them, so the node is the fenced
139    /// owner and its replicated state is union-merged locally first.
140    ///
141    /// This is the SS-2 election hook the engine boot path calls right after
142    /// [`Self::set_owned_shards`] and BEFORE startup recovery: a distributed
143    /// backend wins the per-shard election and becomes the live owner, so the
144    /// subsequent recovery reads see the full committed history for its shards.
145    ///
146    /// The default implementation is a deliberate no-op returning `Ok(())` —
147    /// single-shard / non-distributed backends (in-memory, libSQL, and the
148    /// single-node haematite mode) own everything unconditionally and elect
149    /// nothing, so boot stays byte-identical. Only a DISTRIBUTED sharded backend
150    /// overrides this to run the election. Decorators that wrap another store
151    /// must forward this call to their inner store.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`StoreError::Backend`] when a distributed backend cannot win the
156    /// election or become the live owner of one of `shards`; the node must not
157    /// serve those shards in that case (fail-closed).
158    fn acquire_owned_shards(&self, shards: &[usize]) -> Result<(), StoreError> {
159        let _ = shards;
160        Ok(())
161    }
162
163    /// Acquire-and-serve ownership of a SINGLE distribution `shard` — the
164    /// per-shard primitive [`Self::acquire_owned_shards`] is a loop over, exposed
165    /// so the failover path can drive a per-shard abort seam: a clean election
166    /// loss on one shard ([`StoreError::NotOwner`]) drops only that shard rather
167    /// than failing the whole adoption batch (ADR-021 clean-partial).
168    ///
169    /// The default implementation is a deliberate no-op returning `Ok(())` —
170    /// single-shard / non-distributed backends own everything unconditionally and
171    /// elect nothing. Only a DISTRIBUTED sharded backend (haematite) overrides it.
172    /// Decorators that wrap another store must forward this call.
173    ///
174    /// # Errors
175    ///
176    /// Returns [`StoreError::NotOwner`] when a strictly higher ballot deposed this
177    /// candidate (a clean, droppable election loss), and [`StoreError::Backend`]
178    /// for a quorum-unavailable election or any transport fault (retryable).
179    fn acquire_owned_shard(&self, shard: usize) -> Result<(), StoreError> {
180        let _ = shard;
181        Ok(())
182    }
183
184    /// Whether this node currently holds LIVE serve-authority for `shard` — it won
185    /// the per-shard election THIS process lifetime and has not been deposed
186    /// in-process.
187    ///
188    /// This is the residual-window re-assertion the failover path uses to exclude
189    /// a survivor that lost its epoch between winning acquire+publish and widening
190    /// its enumeration scope (ADR-021 clean-partial). It is a POINT-IN-TIME
191    /// ADVISORY, not a durable lock — the authoritative gate remains the per-write
192    /// CAS fence.
193    ///
194    /// The default implementation returns `true` — single-shard / non-distributed
195    /// backends own everything unconditionally, so the failover path's
196    /// re-assertion is a no-op there and behaviour stays byte-identical. Only a
197    /// DISTRIBUTED sharded backend (haematite) overrides it. Decorators that wrap
198    /// another store must forward this call.
199    fn is_current_owner(&self, shard: usize) -> bool {
200        let _ = shard;
201        true
202    }
203
204    /// Add `shards` to this node's owned-enumeration scope, UNIONING them with
205    /// the shards it already owns rather than replacing the set.
206    ///
207    /// This is the SS-5 failover hook: when a live node absorbs a dead peer's
208    /// shards it must KEEP serving its own shards while ALSO enumerating the
209    /// adopted ones. [`Self::set_owned_shards`] replaces the scope (the boot
210    /// path's one-shot assignment); this widens it in place. The boot path uses
211    /// `set_owned_shards`; the failover path uses this.
212    ///
213    /// The default implementation is a deliberate no-op — single-shard backends
214    /// own everything unconditionally, so widening their scope is meaningless and
215    /// leaves their behaviour byte-identical. Only a sharded backend (haematite)
216    /// overrides this. Decorators that wrap another store must forward this call.
217    ///
218    /// When the store currently owns ALL shards (the `None` / single-node
219    /// default), it already enumerates `shards`, so a sharded backend leaves the
220    /// own-all scope untouched.
221    fn extend_owned_shards(&self, shards: &[usize]) {
222        let _ = shards;
223    }
224
225    /// Publish THIS node as the current owner of `shard` in the cluster's
226    /// shard-owner directory, so other nodes' request-routing edges resolve
227    /// `shard` to this node (SS-3).
228    ///
229    /// This is the failover-publish hook the engine calls from `adopt_shards`
230    /// right after it has won `shard`'s election: it records, durably and
231    /// cluster-visibly, that this node has adopted `shard`, so a request reaching
232    /// a DIFFERENT survivor routes to this adopter rather than mis-resolving to
233    /// the dead declared owner (gap #2).
234    ///
235    /// The default implementation is a deliberate no-op returning `Ok(())` —
236    /// single-shard / non-distributed backends own everything unconditionally and
237    /// have no peers to coordinate, so boot and adoption stay byte-identical. Only
238    /// a DISTRIBUTED sharded backend overrides this. Decorators that wrap another
239    /// store must forward this call.
240    ///
241    /// # Errors
242    ///
243    /// Returns [`StoreError::NotOwner`] when a distributed backend's fenced
244    /// directory write is out-voted (this node is not actually the owner), and
245    /// [`StoreError::Backend`] for any other replication/transport failure.
246    fn publish_shard_owner(&self, shard: usize) -> Result<(), StoreError> {
247        let _ = shard;
248        Ok(())
249    }
250}
251
252/// Write authority for appending workflow-history events.
253///
254/// `append` requires a [`WriteToken`], so having an `Arc<dyn EventStore>` or
255/// `Arc<dyn ReadableEventStore>` is not sufficient to write events.
256#[async_trait]
257pub trait WritableEventStore: Send + Sync + 'static {
258    /// Atomically appends `events` to `workflow_id` when the stored history head equals
259    /// `expected_seq`.
260    ///
261    /// Implementations must apply every event in `events` or none of them. If the current stored
262    /// head for `workflow_id` differs from `expected_seq`, this method must return
263    /// [`StoreError::SequenceConflict`] and leave history unchanged. A first append with
264    /// `expected_seq == 0` creates the workflow history implicitly.
265    async fn append(
266        &self,
267        token: WriteToken,
268        workflow_id: &WorkflowId,
269        events: &[Event],
270        expected_seq: u64,
271    ) -> Result<(), StoreError>;
272
273    /// Atomically appends `events` and the durable-outbox `outbox_rows` for `workflow_id` in a
274    /// single transaction, under the same expected-head sequence guard as [`Self::append`].
275    ///
276    /// This is the durable fan-out write: the `ActivityScheduled`/`ActivityStarted` scheduling
277    /// events and their matching outbox rows commit together or not at all. Atomicity is
278    /// load-bearing — a committed fan-out batch always carries both, or neither — so the out-of-band
279    /// dispatcher can never observe an outbox row whose scheduling events were rolled back, and a
280    /// re-issued append cannot leave events without their dispatch rows.
281    ///
282    /// # Default implementation (safe for outbox-unaware backends)
283    ///
284    /// The default delegates to [`Self::append`] when `outbox_rows` is empty (byte-for-byte
285    /// equivalent to an event-only append), and otherwise returns [`StoreError::Backend`] **rather
286    /// than silently dropping the outbox rows**. Dropping them would be the dangerous failure mode:
287    /// the events would commit, the workflow would believe its fan-out is durably staged, and the
288    /// rows would never be dispatched. A hard error forces a backend to opt in to durable-outbox
289    /// support by overriding this method (as the libSQL store does) before any caller can route a
290    /// fan-out batch through it.
291    ///
292    /// # Errors
293    ///
294    /// Returns [`StoreError::SequenceConflict`] when the stored head differs from `expected_seq`,
295    /// [`StoreError::Serialization`] when an event or outbox payload cannot be serialized, and
296    /// [`StoreError::Backend`] for backend boundary failures or when an outbox-unaware backend is
297    /// asked to persist a non-empty `outbox_rows` slice.
298    async fn append_with_outbox(
299        &self,
300        token: WriteToken,
301        workflow_id: &WorkflowId,
302        events: &[Event],
303        expected_seq: u64,
304        outbox_rows: &[OutboxRow],
305    ) -> Result<(), StoreError> {
306        if outbox_rows.is_empty() {
307            return self.append(token, workflow_id, events, expected_seq).await;
308        }
309        Err(StoreError::Backend(String::from(
310            "this event store does not support durable-outbox appends; \
311             refusing to drop outbox rows (override WritableEventStore::append_with_outbox)",
312        )))
313    }
314
315    /// Returns the outbox rows for `rows`' `dispatch_key`s to `Pending`, re-staging them for the
316    /// out-of-band dispatcher.
317    ///
318    /// This is the crash-recovery re-arm: on first arrival after a restart, an activity whose
319    /// `ActivityScheduled` is recorded but which has no terminal event lost its in-flight dispatch
320    /// when the previous engine process died. Under the durable-outbox model the recovering workflow
321    /// re-stages the dispatch by flipping its outbox row back to claimable `Pending` (an UPSERT — a
322    /// brand-new `dispatch_key` with no prior row is inserted as `Pending`) instead of driving an
323    /// in-process completion task. Redelivery is safe: the completion dedup
324    /// (`record_fan_out_completion`) ignores a terminal for an already-resolved ordinal, so re-arm is
325    /// at-least-once.
326    ///
327    /// The dispatch retry budget is preserved across re-arm: a backend must NOT reset an existing
328    /// row's `attempt` to zero, so a workflow that reliably crashes the server still eventually
329    /// dead-letters rather than re-dispatching forever.
330    ///
331    /// # Default implementation (safe for outbox-unaware backends)
332    ///
333    /// An empty `rows` slice is `Ok(())`. A non-empty slice returns [`StoreError::Backend`] **rather
334    /// than silently no-op'ing the re-arm**: a store without durable-outbox support cannot re-stage a
335    /// dispatch, and silently dropping the request would strand the recovered activity. A hard error
336    /// forces a backend to opt in (as the libSQL store does) before any caller can route a re-arm
337    /// through it.
338    ///
339    /// # Errors
340    ///
341    /// Returns [`StoreError::Serialization`] when an outbox payload cannot be serialized, and
342    /// [`StoreError::Backend`] for backend boundary failures or when an outbox-unaware backend is
343    /// asked to re-arm a non-empty `rows` slice.
344    async fn rearm_outbox_pending(&self, rows: &[OutboxRow]) -> Result<(), StoreError> {
345        if rows.is_empty() {
346            return Ok(());
347        }
348        Err(StoreError::Backend(String::from(
349            "this event store does not support durable-outbox re-arm; \
350             refusing to drop a non-empty re-arm (override WritableEventStore::rearm_outbox_pending)",
351        )))
352    }
353
354    /// Idempotently settles one outbox row to cancelled when this writer is backed by an outbox.
355    ///
356    /// Outbox-aware backends override this and delegate to [`crate::OutboxStore`]. The default is a
357    /// no-op so non-outbox test stores and legacy backends can still record `ActivityCancelled`
358    /// history without requiring an outbox table.
359    ///
360    /// # Errors
361    ///
362    /// Outbox-aware overrides return [`StoreError::Backend`] for backend boundary failures.
363    async fn settle_outbox_row_cancelled(&self, dispatch_key: &str) -> Result<(), StoreError> {
364        let _ = dispatch_key;
365        Ok(())
366    }
367}
368
369/// Convenience trait for concrete stores that support reads/timers, recorder
370/// writes, and deployed-package persistence.
371///
372/// [`crate::PackageStore`] is part of the contract, not an optional add-on:
373/// runtime-deployed packages share the durability promise of event history
374/// (a recovered run is pinned to a recorded package version, and a backend
375/// that dropped the archive would strand it).
376pub trait EventStore: ReadableEventStore + WritableEventStore + crate::PackageStore {}
377
378impl<T> EventStore for T where
379    T: ReadableEventStore + WritableEventStore + crate::PackageStore + ?Sized
380{
381}
382
383pub(crate) fn conformance_write_token() -> WriteToken {
384    write_capability::conformance()
385}
386
387#[cfg(test)]
388mod tests {
389    use std::sync::Arc;
390
391    use super::{EventStore, ReadableEventStore, WritableEventStore};
392
393    #[test]
394    fn event_store_traits_are_object_safe() {
395        let _: Option<Arc<dyn ReadableEventStore>> = None;
396        let _: Option<Arc<dyn WritableEventStore>> = None;
397        let _: Option<Arc<dyn EventStore>> = None;
398    }
399}