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 exactly
102 /// [`WorkflowStatus::Running`](aion_core::WorkflowStatus::Running).
103 async fn list_active(&self) -> Result<Vec<WorkflowId>, StoreError>;
104
105 /// Lists workflow identifiers whose projected status is exactly
106 /// [`WorkflowStatus::Paused`](aion_core::WorkflowStatus::Paused).
107 ///
108 /// Mirrors [`Self::list_active`] with a `== Paused` exact-equality filter: it
109 /// is the durable source the dispatch-hold set is rebuilt from at startup and
110 /// at shard adoption, so a run paused before a `kill -9` keeps its outbox rows
111 /// held after restart. A paused run is excluded from [`Self::list_active`]
112 /// (which filters `== Running`), so nothing else would repopulate the hold.
113 async fn list_paused(&self) -> Result<Vec<WorkflowId>, StoreError>;
114
115 /// Returns workflow summaries matching `filter`.
116 async fn query(&self, filter: &WorkflowFilter) -> Result<Vec<WorkflowSummary>, StoreError>;
117
118 /// Persists a durable timer for `workflow_id` that is due at `fire_at`.
119 ///
120 /// Timer scheduling remains on the public store surface because timers are not workflow-history
121 /// appends and are used by the timer subsystem after the recorder has written `TimerStarted`.
122 async fn schedule_timer(
123 &self,
124 workflow_id: &WorkflowId,
125 timer_id: &TimerId,
126 fire_at: DateTime<Utc>,
127 ) -> Result<(), StoreError>;
128
129 /// Returns durable timers whose `fire_at` is less than or equal to `as_of`.
130 async fn expired_timers(&self, as_of: DateTime<Utc>) -> Result<Vec<TimerEntry>, StoreError>;
131
132 /// Restrict every per-workflow enumeration (active workflows, timers, outbox
133 /// rows) to the named set of distribution shards this node owns, or restore
134 /// the own-all-shards default when `shards` is `None`.
135 ///
136 /// This is the engine-lifecycle hook behind a multi-shard deployment: the
137 /// boot path tells the store which shards this node serves so recovery and
138 /// enumeration see only that node's slice of the cluster's state. The
139 /// default implementation is a deliberate no-op — single-shard backends
140 /// (in-memory, libSQL) own everything unconditionally, so a `None` or any
141 /// shard set leaves their behaviour byte-identical. Only a sharded backend
142 /// (haematite) overrides this to scope its enumeration. Decorators that wrap
143 /// another store must forward this call to their inner store.
144 fn set_owned_shards(&self, shards: Option<&[usize]>) {
145 let _ = shards;
146 }
147
148 /// Acquire-and-serve ownership of each named distribution shard BEFORE the
149 /// boot path recovers or enumerates over them, so the node is the fenced
150 /// owner and its replicated state is union-merged locally first.
151 ///
152 /// This is the SS-2 election hook the engine boot path calls right after
153 /// [`Self::set_owned_shards`] and BEFORE startup recovery: a distributed
154 /// backend wins the per-shard election and becomes the live owner, so the
155 /// subsequent recovery reads see the full committed history for its shards.
156 ///
157 /// The default implementation is a deliberate no-op returning `Ok(())` —
158 /// single-shard / non-distributed backends (in-memory, libSQL, and the
159 /// single-node haematite mode) own everything unconditionally and elect
160 /// nothing, so boot stays byte-identical. Only a DISTRIBUTED sharded backend
161 /// overrides this to run the election. Decorators that wrap another store
162 /// must forward this call to their inner store.
163 ///
164 /// # Errors
165 ///
166 /// Returns [`StoreError::Backend`] when a distributed backend cannot win the
167 /// election or become the live owner of one of `shards`; the node must not
168 /// serve those shards in that case (fail-closed).
169 fn acquire_owned_shards(&self, shards: &[usize]) -> Result<(), StoreError> {
170 let _ = shards;
171 Ok(())
172 }
173
174 /// Acquire-and-serve ownership of a SINGLE distribution `shard` — the
175 /// per-shard primitive [`Self::acquire_owned_shards`] is a loop over, exposed
176 /// so the failover path can drive a per-shard abort seam: a clean election
177 /// loss on one shard ([`StoreError::NotOwner`]) drops only that shard rather
178 /// than failing the whole adoption batch (ADR-021 clean-partial).
179 ///
180 /// The default implementation is a deliberate no-op returning `Ok(())` —
181 /// single-shard / non-distributed backends own everything unconditionally and
182 /// elect nothing. Only a DISTRIBUTED sharded backend (haematite) overrides it.
183 /// Decorators that wrap another store must forward this call.
184 ///
185 /// # Errors
186 ///
187 /// Returns [`StoreError::NotOwner`] when a strictly higher ballot deposed this
188 /// candidate (a clean, droppable election loss), and [`StoreError::Backend`]
189 /// for a quorum-unavailable election or any transport fault (retryable).
190 fn acquire_owned_shard(&self, shard: usize) -> Result<(), StoreError> {
191 let _ = shard;
192 Ok(())
193 }
194
195 /// Whether this node currently holds LIVE serve-authority for `shard` — it won
196 /// the per-shard election THIS process lifetime and has not been deposed
197 /// in-process.
198 ///
199 /// This is the residual-window re-assertion the failover path uses to exclude
200 /// a survivor that lost its epoch between winning acquire+publish and widening
201 /// its enumeration scope (ADR-021 clean-partial). It is a POINT-IN-TIME
202 /// ADVISORY, not a durable lock — the authoritative gate remains the per-write
203 /// CAS fence.
204 ///
205 /// The default implementation returns `true` — single-shard / non-distributed
206 /// backends own everything unconditionally, so the failover path's
207 /// re-assertion is a no-op there and behaviour stays byte-identical. Only a
208 /// DISTRIBUTED sharded backend (haematite) overrides it. Decorators that wrap
209 /// another store must forward this call.
210 fn is_current_owner(&self, shard: usize) -> bool {
211 let _ = shard;
212 true
213 }
214
215 /// Add `shards` to this node's owned-enumeration scope, UNIONING them with
216 /// the shards it already owns rather than replacing the set.
217 ///
218 /// This is the SS-5 failover hook: when a live node absorbs a dead peer's
219 /// shards it must KEEP serving its own shards while ALSO enumerating the
220 /// adopted ones. [`Self::set_owned_shards`] replaces the scope (the boot
221 /// path's one-shot assignment); this widens it in place. The boot path uses
222 /// `set_owned_shards`; the failover path uses this.
223 ///
224 /// The default implementation is a deliberate no-op — single-shard backends
225 /// own everything unconditionally, so widening their scope is meaningless and
226 /// leaves their behaviour byte-identical. Only a sharded backend (haematite)
227 /// overrides this. Decorators that wrap another store must forward this call.
228 ///
229 /// When the store currently owns ALL shards (the `None` / single-node
230 /// default), it already enumerates `shards`, so a sharded backend leaves the
231 /// own-all scope untouched.
232 fn extend_owned_shards(&self, shards: &[usize]) {
233 let _ = shards;
234 }
235
236 /// Publish THIS node as the current owner of `shard` in the cluster's
237 /// shard-owner directory, so other nodes' request-routing edges resolve
238 /// `shard` to this node (SS-3).
239 ///
240 /// This is the failover-publish hook the engine calls from `adopt_shards`
241 /// right after it has won `shard`'s election: it records, durably and
242 /// cluster-visibly, that this node has adopted `shard`, so a request reaching
243 /// a DIFFERENT survivor routes to this adopter rather than mis-resolving to
244 /// the dead declared owner (gap #2).
245 ///
246 /// The default implementation is a deliberate no-op returning `Ok(())` —
247 /// single-shard / non-distributed backends own everything unconditionally and
248 /// have no peers to coordinate, so boot and adoption stay byte-identical. Only
249 /// a DISTRIBUTED sharded backend overrides this. Decorators that wrap another
250 /// store must forward this call.
251 ///
252 /// # Errors
253 ///
254 /// Returns [`StoreError::NotOwner`] when a distributed backend's fenced
255 /// directory write is out-voted (this node is not actually the owner), and
256 /// [`StoreError::Backend`] for any other replication/transport failure.
257 fn publish_shard_owner(&self, shard: usize) -> Result<(), StoreError> {
258 let _ = shard;
259 Ok(())
260 }
261}
262
263/// Write authority for appending workflow-history events.
264///
265/// `append` requires a [`WriteToken`], so having an `Arc<dyn EventStore>` or
266/// `Arc<dyn ReadableEventStore>` is not sufficient to write events.
267#[async_trait]
268pub trait WritableEventStore: Send + Sync + 'static {
269 /// Atomically appends `events` to `workflow_id` when the stored history head equals
270 /// `expected_seq`.
271 ///
272 /// Implementations must apply every event in `events` or none of them. If the current stored
273 /// head for `workflow_id` differs from `expected_seq`, this method must return
274 /// [`StoreError::SequenceConflict`] and leave history unchanged. A first append with
275 /// `expected_seq == 0` creates the workflow history implicitly.
276 async fn append(
277 &self,
278 token: WriteToken,
279 workflow_id: &WorkflowId,
280 events: &[Event],
281 expected_seq: u64,
282 ) -> Result<(), StoreError>;
283
284 /// Atomically appends `events` and the durable-outbox `outbox_rows` for `workflow_id` in a
285 /// single transaction, under the same expected-head sequence guard as [`Self::append`].
286 ///
287 /// This is the durable fan-out write: the `ActivityScheduled`/`ActivityStarted` scheduling
288 /// events and their matching outbox rows commit together or not at all. Atomicity is
289 /// load-bearing — a committed fan-out batch always carries both, or neither — so the out-of-band
290 /// dispatcher can never observe an outbox row whose scheduling events were rolled back, and a
291 /// re-issued append cannot leave events without their dispatch rows.
292 ///
293 /// # Default implementation (safe for outbox-unaware backends)
294 ///
295 /// The default delegates to [`Self::append`] when `outbox_rows` is empty (byte-for-byte
296 /// equivalent to an event-only append), and otherwise returns [`StoreError::Backend`] **rather
297 /// than silently dropping the outbox rows**. Dropping them would be the dangerous failure mode:
298 /// the events would commit, the workflow would believe its fan-out is durably staged, and the
299 /// rows would never be dispatched. A hard error forces a backend to opt in to durable-outbox
300 /// support by overriding this method (as the libSQL store does) before any caller can route a
301 /// fan-out batch through it.
302 ///
303 /// # Errors
304 ///
305 /// Returns [`StoreError::SequenceConflict`] when the stored head differs from `expected_seq`,
306 /// [`StoreError::Serialization`] when an event or outbox payload cannot be serialized, and
307 /// [`StoreError::Backend`] for backend boundary failures or when an outbox-unaware backend is
308 /// asked to persist a non-empty `outbox_rows` slice.
309 async fn append_with_outbox(
310 &self,
311 token: WriteToken,
312 workflow_id: &WorkflowId,
313 events: &[Event],
314 expected_seq: u64,
315 outbox_rows: &[OutboxRow],
316 ) -> Result<(), StoreError> {
317 if outbox_rows.is_empty() {
318 return self.append(token, workflow_id, events, expected_seq).await;
319 }
320 Err(StoreError::Backend(String::from(
321 "this event store does not support durable-outbox appends; \
322 refusing to drop outbox rows (override WritableEventStore::append_with_outbox)",
323 )))
324 }
325
326 /// Returns the outbox rows for `rows`' `dispatch_key`s to `Pending`, re-staging them for the
327 /// out-of-band dispatcher.
328 ///
329 /// This is the crash-recovery re-arm: on first arrival after a restart, an activity whose
330 /// `ActivityScheduled` is recorded but which has no terminal event lost its in-flight dispatch
331 /// when the previous engine process died. Under the durable-outbox model the recovering workflow
332 /// re-stages the dispatch by flipping its outbox row back to claimable `Pending` (an UPSERT — a
333 /// brand-new `dispatch_key` with no prior row is inserted as `Pending`) instead of driving an
334 /// in-process completion task. Redelivery is safe: the completion dedup
335 /// (`record_fan_out_completion`) ignores a terminal for an already-resolved ordinal, so re-arm is
336 /// at-least-once.
337 ///
338 /// The dispatch retry budget is preserved across re-arm: a backend must NOT reset an existing
339 /// row's `attempt` to zero, so a workflow that reliably crashes the server still eventually
340 /// dead-letters rather than re-dispatching forever.
341 ///
342 /// # Default implementation (safe for outbox-unaware backends)
343 ///
344 /// An empty `rows` slice is `Ok(())`. A non-empty slice returns [`StoreError::Backend`] **rather
345 /// than silently no-op'ing the re-arm**: a store without durable-outbox support cannot re-stage a
346 /// dispatch, and silently dropping the request would strand the recovered activity. A hard error
347 /// forces a backend to opt in (as the libSQL store does) before any caller can route a re-arm
348 /// through it.
349 ///
350 /// # Errors
351 ///
352 /// Returns [`StoreError::Serialization`] when an outbox payload cannot be serialized, and
353 /// [`StoreError::Backend`] for backend boundary failures or when an outbox-unaware backend is
354 /// asked to re-arm a non-empty `rows` slice.
355 async fn rearm_outbox_pending(&self, rows: &[OutboxRow]) -> Result<(), StoreError> {
356 if rows.is_empty() {
357 return Ok(());
358 }
359 Err(StoreError::Backend(String::from(
360 "this event store does not support durable-outbox re-arm; \
361 refusing to drop a non-empty re-arm (override WritableEventStore::rearm_outbox_pending)",
362 )))
363 }
364
365 /// Idempotently settles one outbox row to cancelled when this writer is backed by an outbox.
366 ///
367 /// Outbox-aware backends override this and delegate to [`crate::OutboxStore`]. The default is a
368 /// no-op so non-outbox test stores and legacy backends can still record `ActivityCancelled`
369 /// history without requiring an outbox table.
370 ///
371 /// # Errors
372 ///
373 /// Outbox-aware overrides return [`StoreError::Backend`] for backend boundary failures.
374 async fn settle_outbox_row_cancelled(&self, dispatch_key: &str) -> Result<(), StoreError> {
375 let _ = dispatch_key;
376 Ok(())
377 }
378
379 /// Idempotently settles EVERY live ([`crate::OutboxStatus::Pending`] or
380 /// [`crate::OutboxStatus::Claimed`]) outbox row of `workflow_id` to
381 /// [`crate::OutboxStatus::Cancelled`], returning the settled `dispatch_key`s (#253).
382 ///
383 /// This is the workflow-terminal settle the Recorder runs after durably recording a workflow
384 /// terminal (`WorkflowCompleted`/`WorkflowFailed`/`WorkflowCancelled`): a terminal workflow's
385 /// staged dispatches must never be redelivered, so its live rows are retired to the terminal
386 /// `Cancelled` disposition that re-arm and claim contractually never touch. Rows already in
387 /// `Done`/`Failed`/`Cancelled` are left untouched, so the settle is idempotent. Reopen still
388 /// supersedes it: [`Self::rearm_outbox_pending`] forcibly returns any existing row — including a
389 /// `Cancelled` one — to `Pending`, so a reopened workflow's re-dispatches deliver again.
390 ///
391 /// The default is a no-op returning no settled keys, mirroring
392 /// [`Self::settle_outbox_row_cancelled`], so non-outbox test stores and legacy backends record
393 /// workflow terminals without requiring an outbox table. Outbox-aware backends override it and
394 /// share the implementation with [`crate::OutboxStore::cancel_outbox_rows_for_workflow`].
395 ///
396 /// # Errors
397 ///
398 /// Outbox-aware overrides return [`StoreError::Backend`] for backend boundary failures and
399 /// [`StoreError::Serialization`] when a stored row cannot be decoded.
400 async fn settle_workflow_outbox_rows_cancelled(
401 &self,
402 workflow_id: &WorkflowId,
403 ) -> Result<Vec<String>, StoreError> {
404 let _ = workflow_id;
405 Ok(Vec::new())
406 }
407}
408
409/// Convenience trait for concrete stores that support reads/timers, recorder
410/// writes, and deployed-package persistence.
411///
412/// [`crate::PackageStore`] is part of the contract, not an optional add-on:
413/// runtime-deployed packages share the durability promise of event history
414/// (a recovered run is pinned to a recorded package version, and a backend
415/// that dropped the archive would strand it).
416pub trait EventStore: ReadableEventStore + WritableEventStore + crate::PackageStore {}
417
418impl<T> EventStore for T where
419 T: ReadableEventStore + WritableEventStore + crate::PackageStore + ?Sized
420{
421}
422
423pub(crate) fn conformance_write_token() -> WriteToken {
424 write_capability::conformance()
425}
426
427#[cfg(test)]
428mod tests {
429 use std::sync::Arc;
430
431 use super::{EventStore, ReadableEventStore, WritableEventStore};
432
433 #[test]
434 fn event_store_traits_are_object_safe() {
435 let _: Option<Arc<dyn ReadableEventStore>> = None;
436 let _: Option<Arc<dyn WritableEventStore>> = None;
437 let _: Option<Arc<dyn EventStore>> = None;
438 }
439}