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, TimerRetirement};
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 /// Workflow sequence of this run's `WorkflowStarted` event — the boundary
50 /// at which this generation opens.
51 ///
52 /// SEQUENCE, NOT TIMESTAMP, IS THE AXIS THAT LOCATES AN EVENT'S GENERATION.
53 /// A reader holding an event at sequence `n` answers "which run is this?" by
54 /// taking the last boundary at or before `n`; `started_at` is a recorded
55 /// value and cannot order events against it. Without this field the only way
56 /// to answer that question is to scan history for `WorkflowStarted` — which
57 /// is exactly what the ops console did, over whatever window it happened to
58 /// have loaded, so on any workflow longer than one window it reported
59 /// "run unknown" for every attempt it showed.
60 pub started_seq: u64,
61 /// Timestamp of this run's terminal lifecycle event, when closed.
62 pub closed_at: Option<DateTime<Utc>>,
63}
64
65/// Read and durable-timer contract for Aion event stores.
66#[async_trait]
67pub trait ReadableEventStore: Send + Sync + 'static {
68 /// Reads the complete event history for `workflow_id` in ascending sequence order.
69 ///
70 /// A workflow with no recorded events is observed as an empty history. This includes unknown
71 /// workflow identifiers: because the first append with `expected_seq == 0` creates a workflow
72 /// implicitly, "unknown workflow" and "empty history" are the same observable state for reads.
73 /// This method must not return [`StoreError::NotFound`] for absent workflows.
74 async fn read_history(&self, workflow_id: &WorkflowId) -> Result<Vec<Event>, StoreError>;
75
76 /// Reads the event history for `workflow_id` restricted to events with sequence number
77 /// greater than or equal to `from_seq`, in ascending sequence order.
78 ///
79 /// This is the range-read primitive behind O(delta) WS resume: callers replaying from a
80 /// cursor must not pay for the full history. Semantics:
81 ///
82 /// - `from_seq <= 1` is equivalent to [`Self::read_history`]: sequence numbers start at 1,
83 /// so every recorded event satisfies the bound.
84 /// - `from_seq` beyond the current head returns an empty vector, never an error. Whether a
85 /// beyond-head cursor is *valid* is protocol judgment, not store judgment: the WS resume
86 /// protocol rejects `resume_from_seq > head + 1` as an invalid cursor
87 /// (`ResumeCursorAheadOfHistory`), but it makes that call by comparing the cursor against
88 /// the head it observes — the store only answers which events exist at or after the
89 /// requested sequence.
90 /// - Unknown workflows behave exactly like [`Self::read_history`] for unknown workflows:
91 /// empty history, never [`StoreError::NotFound`], because "unknown workflow" and "empty
92 /// history" are the same observable state for reads.
93 ///
94 /// There is deliberately no default implementation: a read-all-then-filter fallback would
95 /// silently reintroduce O(history) behavior. Every backend must implement this as a real
96 /// range read (for SQL backends, an indexed `seq >= ?` range scan).
97 async fn read_history_from(
98 &self,
99 workflow_id: &WorkflowId,
100 from_seq: u64,
101 ) -> Result<Vec<Event>, StoreError>;
102
103 /// Reads the concrete run chain for `workflow_id` in continuation order.
104 async fn read_run_chain(&self, workflow_id: &WorkflowId)
105 -> Result<Vec<RunSummary>, StoreError>;
106
107 /// Lists every workflow identifier that has at least one event in history.
108 ///
109 /// Unlike [`Self::list_active`], this includes terminal workflows and exists to let projection
110 /// repair jobs reconcile derived indexes against the authoritative event history.
111 async fn list_workflow_ids(&self) -> Result<Vec<WorkflowId>, StoreError>;
112
113 /// Lists every workflow's event stream with the `seq` of its last event —
114 /// the stream-side half of the handshake that lets a boot trust a
115 /// visibility row instead of opening the history behind it
116 /// ([`crate::visibility::head`]).
117 ///
118 /// Covers the same streams as [`Self::list_workflow_ids`] (terminal ones
119 /// included) in no particular order; a stream with no events is not
120 /// listed. This is an index read — it never decodes an event.
121 async fn stream_heads(&self) -> Result<Vec<crate::visibility::StreamHead>, StoreError>;
122
123 /// Lists workflow identifiers whose projected status is exactly
124 /// [`WorkflowStatus::Running`](aion_core::WorkflowStatus::Running).
125 ///
126 /// Answered row-first: for every stream head, a visibility row at that
127 /// head settles a finished or paused workflow without a history read
128 /// ([`crate::visibility::head::verdict`]); only in-flight, unstamped or
129 /// stale rows fold their history. Finished workflows are never opened.
130 async fn list_active(&self) -> Result<Vec<WorkflowId>, StoreError>;
131
132 /// Lists workflow identifiers whose projected status is exactly
133 /// [`WorkflowStatus::Paused`](aion_core::WorkflowStatus::Paused).
134 ///
135 /// Mirrors [`Self::list_active`] with a `== Paused` exact-equality filter: it
136 /// is the durable source the dispatch-hold set is rebuilt from at startup and
137 /// at shard adoption, so a run paused before a `kill -9` keeps its outbox rows
138 /// held after restart. A paused run is excluded from [`Self::list_active`]
139 /// (which filters `== Running`), so nothing else would repopulate the hold.
140 async fn list_paused(&self) -> Result<Vec<WorkflowId>, StoreError>;
141
142 /// Returns workflow summaries matching `filter`.
143 async fn query(&self, filter: &WorkflowFilter) -> Result<Vec<WorkflowSummary>, StoreError>;
144
145 /// Persists a durable timer for `workflow_id` that is due at `fire_at`.
146 ///
147 /// Timer scheduling remains on the public store surface because timers are not workflow-history
148 /// appends and are used by the timer subsystem after the recorder has written `TimerStarted`.
149 ///
150 /// `armed_seq` is the workflow-history sequence of the arming's
151 /// `TimerStarted` event and becomes part of the row's identity
152 /// ([`TimerEntry::armed_seq`]): together with `fire_at` it is what a
153 /// [`Self::retire_timer`] compare matches, so a re-arm to the IDENTICAL
154 /// instant still writes a distinguishable row. An engine-internal arming
155 /// that records no `TimerStarted` (the schedule coordinator) passes `0` —
156 /// history sequences start at 1, so `0` is unambiguous, and such rows are
157 /// keyed per trigger and never alias a workflow arming.
158 async fn schedule_timer(
159 &self,
160 workflow_id: &WorkflowId,
161 timer_id: &TimerId,
162 fire_at: DateTime<Utc>,
163 armed_seq: u64,
164 ) -> Result<(), StoreError>;
165
166 /// Returns durable timers whose `fire_at` is less than or equal to `as_of`.
167 async fn expired_timers(&self, as_of: DateTime<Utc>) -> Result<Vec<TimerEntry>, StoreError>;
168
169 /// Removes the durable timer row for `(workflow_id, timer_id)` — but only
170 /// while the row still carries exactly the `(fire_at, armed_seq)`
171 /// identity of the arming being retired.
172 ///
173 /// Called by the timer subsystem once the row's consuming fact is durably
174 /// recorded — the fire path after `TimerFired` lands (or is refused by a
175 /// terminal run), the cancel path after `TimerCancelled` lands, and the
176 /// boot sweep's reconciliation of rows whose consumption predates this
177 /// method. Without retirement every timer a workflow ever armed leaves a
178 /// permanent row, [`Self::expired_timers`] returns the workflow's whole
179 /// consumed past on every call, and the startup sweep walks it — the
180 /// 2026-08-24 estate outage's 90-minute boot.
181 ///
182 /// The `(fire_at, armed_seq)` condition is what makes retirement safe
183 /// against re-arms: a named timer re-armed after its fire OVERWRITES the
184 /// same row with the replacement arming's values
185 /// ([`Self::schedule_timer`]), and a retirement decided against the
186 /// consumed arming must never delete the replacement's row — that row is
187 /// the re-armed timer's only durable claim to a recovery fire. The
188 /// `armed_seq` half is load-bearing for a re-arm to the IDENTICAL
189 /// instant: `fire_at` alone cannot tell those two armings apart, and the
190 /// replacement's `TimerStarted` always carries a strictly higher
191 /// sequence. A caller always knows the arming it is retiring (the sweep
192 /// row it walked, or the `TimerStarted` it read), so the condition costs
193 /// nothing; a mismatch means "already re-armed, nothing left to retire"
194 /// and is the [`TimerRetirement::Superseded`] SUCCESS, not an error.
195 ///
196 /// The condition must hold under CONCURRENT re-arming, not merely against
197 /// a stale caller decision: a `schedule_timer` racing this call must
198 /// either land before the compare (mismatch, `Superseded`) or after the
199 /// delete (its row survives) — never inside it. A backend without an
200 /// atomic conditional delete must serialize this method against
201 /// [`Self::schedule_timer`] itself.
202 ///
203 /// Idempotent: retiring an absent (never scheduled, or already retired)
204 /// row succeeds as [`TimerRetirement::Retired`] — the boot sweep and a
205 /// racing live fire may both retire the same row, and the second act must
206 /// be a no-op, not an error. Replay never calls this: replay is read-only
207 /// on the timer keyspace.
208 ///
209 /// A distributed backend must ride retirement on the same stamped,
210 /// replicated write path as [`Self::schedule_timer`], routed onto the
211 /// workflow's shard — an adopted shard must not resurrect retired rows.
212 async fn retire_timer(
213 &self,
214 workflow_id: &WorkflowId,
215 timer_id: &TimerId,
216 fire_at: DateTime<Utc>,
217 armed_seq: u64,
218 ) -> Result<TimerRetirement, StoreError>;
219
220 /// Restrict every per-workflow enumeration (active workflows, timers, outbox
221 /// rows) to the named set of distribution shards this node owns, or restore
222 /// the own-all-shards default when `shards` is `None`.
223 ///
224 /// This is the engine-lifecycle hook behind a multi-shard deployment: the
225 /// boot path tells the store which shards this node serves so recovery and
226 /// enumeration see only that node's slice of the cluster's state. The
227 /// default implementation is a deliberate no-op — the single-shard in-memory
228 /// backend owns everything unconditionally, so a `None` or any shard set
229 /// leaves its behaviour byte-identical. The sharded haematite backend
230 /// overrides this to scope its enumeration. Decorators that wrap another
231 /// store must forward this call to their inner store.
232 fn set_owned_shards(&self, shards: Option<&[usize]>) {
233 let _ = shards;
234 }
235
236 /// Acquire-and-serve ownership of each named distribution shard BEFORE the
237 /// boot path recovers or enumerates over them, so the node is the fenced
238 /// owner and its replicated state is union-merged locally first.
239 ///
240 /// This is the SS-2 election hook the engine boot path calls right after
241 /// [`Self::set_owned_shards`] and BEFORE startup recovery: a distributed
242 /// backend wins the per-shard election and becomes the live owner, so the
243 /// subsequent recovery reads see the full committed history for its shards.
244 ///
245 /// The default implementation is a deliberate no-op returning `Ok(())` —
246 /// non-distributed backends (the in-memory store and single-node haematite)
247 /// own everything unconditionally and elect nothing, so boot stays
248 /// byte-identical. Only a DISTRIBUTED sharded backend
249 /// overrides this to run the election. Decorators that wrap another store
250 /// must forward this call to their inner store.
251 ///
252 /// # Errors
253 ///
254 /// Returns [`StoreError::Backend`] when a distributed backend cannot win the
255 /// election or become the live owner of one of `shards`; the node must not
256 /// serve those shards in that case (fail-closed).
257 fn acquire_owned_shards(&self, shards: &[usize]) -> Result<(), StoreError> {
258 let _ = shards;
259 Ok(())
260 }
261
262 /// Acquire-and-serve ownership of a SINGLE distribution `shard` — the
263 /// per-shard primitive [`Self::acquire_owned_shards`] is a loop over, exposed
264 /// so the failover path can drive a per-shard abort seam: a clean election
265 /// loss on one shard ([`StoreError::NotOwner`]) drops only that shard rather
266 /// than failing the whole adoption batch (ADR-021 clean-partial).
267 ///
268 /// The default implementation is a deliberate no-op returning `Ok(())` —
269 /// single-shard / non-distributed backends own everything unconditionally and
270 /// elect nothing. Only a DISTRIBUTED sharded backend (haematite) overrides it.
271 /// Decorators that wrap another store must forward this call.
272 ///
273 /// # Errors
274 ///
275 /// Returns [`StoreError::NotOwner`] when a strictly higher ballot deposed this
276 /// candidate (a clean, droppable election loss), and [`StoreError::Backend`]
277 /// for a quorum-unavailable election or any transport fault (retryable).
278 fn acquire_owned_shard(&self, shard: usize) -> Result<(), StoreError> {
279 let _ = shard;
280 Ok(())
281 }
282
283 /// Whether this node currently holds LIVE serve-authority for `shard` — it won
284 /// the per-shard election THIS process lifetime and has not been deposed
285 /// in-process.
286 ///
287 /// This is the residual-window re-assertion the failover path uses to exclude
288 /// a survivor that lost its epoch between winning acquire+publish and widening
289 /// its enumeration scope (ADR-021 clean-partial). It is a POINT-IN-TIME
290 /// ADVISORY, not a durable lock — the authoritative gate remains the per-write
291 /// CAS fence.
292 ///
293 /// The default implementation returns `true` — single-shard / non-distributed
294 /// backends own everything unconditionally, so the failover path's
295 /// re-assertion is a no-op there and behaviour stays byte-identical. Only a
296 /// DISTRIBUTED sharded backend (haematite) overrides it. Decorators that wrap
297 /// another store must forward this call.
298 fn is_current_owner(&self, shard: usize) -> bool {
299 let _ = shard;
300 true
301 }
302
303 /// Add `shards` to this node's owned-enumeration scope, UNIONING them with
304 /// the shards it already owns rather than replacing the set.
305 ///
306 /// This is the SS-5 failover hook: when a live node absorbs a dead peer's
307 /// shards it must KEEP serving its own shards while ALSO enumerating the
308 /// adopted ones. [`Self::set_owned_shards`] replaces the scope (the boot
309 /// path's one-shot assignment); this widens it in place. The boot path uses
310 /// `set_owned_shards`; the failover path uses this.
311 ///
312 /// The default implementation is a deliberate no-op — single-shard backends
313 /// own everything unconditionally, so widening their scope is meaningless and
314 /// leaves their behaviour byte-identical. Only a sharded backend (haematite)
315 /// overrides this. Decorators that wrap another store must forward this call.
316 ///
317 /// When the store currently owns ALL shards (the `None` / single-node
318 /// default), it already enumerates `shards`, so a sharded backend leaves the
319 /// own-all scope untouched.
320 fn extend_owned_shards(&self, shards: &[usize]) {
321 let _ = shards;
322 }
323
324 /// Publish THIS node as the current owner of `shard` in the cluster's
325 /// shard-owner directory, so other nodes' request-routing edges resolve
326 /// `shard` to this node (SS-3).
327 ///
328 /// This is the failover-publish hook the engine calls from `adopt_shards`
329 /// right after it has won `shard`'s election: it records, durably and
330 /// cluster-visibly, that this node has adopted `shard`, so a request reaching
331 /// a DIFFERENT survivor routes to this adopter rather than mis-resolving to
332 /// the dead declared owner (gap #2).
333 ///
334 /// The default implementation is a deliberate no-op returning `Ok(())` —
335 /// single-shard / non-distributed backends own everything unconditionally and
336 /// have no peers to coordinate, so boot and adoption stay byte-identical. Only
337 /// a DISTRIBUTED sharded backend overrides this. Decorators that wrap another
338 /// store must forward this call.
339 ///
340 /// # Errors
341 ///
342 /// Returns [`StoreError::NotOwner`] when a distributed backend's fenced
343 /// directory write is out-voted (this node is not actually the owner), and
344 /// [`StoreError::Backend`] for any other replication/transport failure.
345 fn publish_shard_owner(&self, shard: usize) -> Result<(), StoreError> {
346 let _ = shard;
347 Ok(())
348 }
349}
350
351/// Write authority for appending workflow-history events.
352///
353/// `append` requires a [`WriteToken`], so having an `Arc<dyn EventStore>` or
354/// `Arc<dyn ReadableEventStore>` is not sufficient to write events.
355#[async_trait]
356pub trait WritableEventStore: Send + Sync + 'static {
357 /// Atomically appends `events` to `workflow_id` when the stored history head equals
358 /// `expected_seq`.
359 ///
360 /// Implementations must apply every event in `events` or none of them. If the current stored
361 /// head for `workflow_id` differs from `expected_seq`, this method must return
362 /// [`StoreError::SequenceConflict`] and leave history unchanged. A first append with
363 /// `expected_seq == 0` creates the workflow history implicitly.
364 async fn append(
365 &self,
366 token: WriteToken,
367 workflow_id: &WorkflowId,
368 events: &[Event],
369 expected_seq: u64,
370 ) -> Result<(), StoreError>;
371
372 /// Appends `events` and the durable-outbox `outbox_rows` for `workflow_id` under the same
373 /// expected-head sequence guard as [`Self::append`], as one fan-out write.
374 ///
375 /// This is the durable fan-out write: the `ActivityScheduled`/`ActivityStarted` scheduling
376 /// events and their matching outbox rows. The contract a backend must keep is about what
377 /// is OBSERVABLE, not about one transaction: after recovery, no committed scheduling event
378 /// is without its dispatch row, and no dispatch row exists whose scheduling events were
379 /// never committed. A backend may keep it either way — by committing both in a single
380 /// transaction (the in-memory store does), or by committing the events first as the
381 /// authoritative write and the rows after, closing the crash window between them on
382 /// recovery by rebuilding the pending-dispatch set from history and re-arming the rows
383 /// (the haematite backend does; see its crate documentation). The out-of-band dispatcher
384 /// therefore never observes a row whose scheduling events were rolled back, and a
385 /// re-issued append cannot leave events without their dispatch rows — but a reader of a
386 /// live haematite store between the two commits CAN see events without rows, which is why
387 /// recovery, not the read path, is what closes it.
388 ///
389 /// # Default implementation (safe for outbox-unaware backends)
390 ///
391 /// The default delegates to [`Self::append`] when `outbox_rows` is empty (byte-for-byte
392 /// equivalent to an event-only append), and otherwise returns [`StoreError::Backend`] **rather
393 /// than silently dropping the outbox rows**. Dropping them would be the dangerous failure mode:
394 /// the events would commit, the workflow would believe its fan-out is durably staged, and the
395 /// rows would never be dispatched. A hard error forces a backend to opt in to durable-outbox
396 /// support by overriding this method (as the haematite store does) before any caller can route a
397 /// fan-out batch through it.
398 ///
399 /// # Errors
400 ///
401 /// Returns [`StoreError::SequenceConflict`] when the stored head differs from `expected_seq`,
402 /// [`StoreError::Serialization`] when an event or outbox payload cannot be serialized, and
403 /// [`StoreError::Backend`] for backend boundary failures or when an outbox-unaware backend is
404 /// asked to persist a non-empty `outbox_rows` slice.
405 async fn append_with_outbox(
406 &self,
407 token: WriteToken,
408 workflow_id: &WorkflowId,
409 events: &[Event],
410 expected_seq: u64,
411 outbox_rows: &[OutboxRow],
412 ) -> Result<(), StoreError> {
413 if outbox_rows.is_empty() {
414 return self.append(token, workflow_id, events, expected_seq).await;
415 }
416 Err(StoreError::Backend(String::from(
417 "this event store does not support durable-outbox appends; \
418 refusing to drop outbox rows (override WritableEventStore::append_with_outbox)",
419 )))
420 }
421
422 /// Returns the outbox rows for `rows`' `dispatch_key`s to `Pending`, re-staging them for the
423 /// out-of-band dispatcher.
424 ///
425 /// This is the crash-recovery re-arm: on first arrival after a restart, an activity whose
426 /// `ActivityScheduled` is recorded but which has no terminal event lost its in-flight dispatch
427 /// when the previous engine process died. Under the durable-outbox model the recovering workflow
428 /// re-stages the dispatch by flipping its outbox row back to claimable `Pending` (an UPSERT — a
429 /// brand-new `dispatch_key` with no prior row is inserted as `Pending`) instead of driving an
430 /// in-process completion task. Redelivery is safe: the completion dedup
431 /// (`record_fan_out_completion`) ignores a terminal for an already-resolved ordinal, so re-arm is
432 /// at-least-once.
433 ///
434 /// The dispatch retry budget is preserved across re-arm: a backend must NOT reset an existing
435 /// row's `attempt` to zero, so a workflow that reliably crashes the server still eventually
436 /// dead-letters rather than re-dispatching forever.
437 ///
438 /// # Default implementation (safe for outbox-unaware backends)
439 ///
440 /// An empty `rows` slice is `Ok(())`. A non-empty slice returns [`StoreError::Backend`] **rather
441 /// than silently no-op'ing the re-arm**: a store without durable-outbox support cannot re-stage a
442 /// dispatch, and silently dropping the request would strand the recovered activity. A hard error
443 /// forces a backend to opt in (as the haematite store does) before any caller can route a re-arm
444 /// through it.
445 ///
446 /// # Errors
447 ///
448 /// Returns [`StoreError::Serialization`] when an outbox payload cannot be serialized, and
449 /// [`StoreError::Backend`] for backend boundary failures or when an outbox-unaware backend is
450 /// asked to re-arm a non-empty `rows` slice.
451 async fn rearm_outbox_pending(&self, rows: &[OutboxRow]) -> Result<(), StoreError> {
452 if rows.is_empty() {
453 return Ok(());
454 }
455 Err(StoreError::Backend(String::from(
456 "this event store does not support durable-outbox re-arm; \
457 refusing to drop a non-empty re-arm (override WritableEventStore::rearm_outbox_pending)",
458 )))
459 }
460
461 /// Idempotently settles one outbox row to cancelled when this writer is backed by an outbox.
462 ///
463 /// Outbox-aware backends override this and delegate to [`crate::OutboxStore`]. The default is a
464 /// no-op so non-outbox test stores and legacy backends can still record `ActivityCancelled`
465 /// history without requiring an outbox table.
466 ///
467 /// # Errors
468 ///
469 /// Outbox-aware overrides return [`StoreError::Backend`] for backend boundary failures.
470 async fn settle_outbox_row_cancelled(&self, dispatch_key: &str) -> Result<(), StoreError> {
471 let _ = dispatch_key;
472 Ok(())
473 }
474
475 /// Idempotently settles EVERY live ([`crate::OutboxStatus::Pending`] or
476 /// [`crate::OutboxStatus::Claimed`]) outbox row of `workflow_id` to
477 /// [`crate::OutboxStatus::Cancelled`], returning the settled `dispatch_key`s (#253).
478 ///
479 /// This is the workflow-terminal settle the Recorder runs after durably recording a workflow
480 /// terminal (`WorkflowCompleted`/`WorkflowFailed`/`WorkflowCancelled`): a terminal workflow's
481 /// staged dispatches must never be redelivered, so its live rows are retired to the terminal
482 /// `Cancelled` disposition that re-arm and claim contractually never touch. Rows already in
483 /// `Done`/`Failed`/`Cancelled` are left untouched, so the settle is idempotent. Reopen still
484 /// supersedes it: [`Self::rearm_outbox_pending`] forcibly returns any existing row — including a
485 /// `Cancelled` one — to `Pending`, so a reopened workflow's re-dispatches deliver again.
486 ///
487 /// The default is a no-op returning no settled keys, mirroring
488 /// [`Self::settle_outbox_row_cancelled`], so non-outbox test stores and legacy backends record
489 /// workflow terminals without requiring an outbox table. Outbox-aware backends override it and
490 /// share the implementation with [`crate::OutboxStore::cancel_outbox_rows_for_workflow`].
491 ///
492 /// # Errors
493 ///
494 /// Outbox-aware overrides return [`StoreError::Backend`] for backend boundary failures and
495 /// [`StoreError::Serialization`] when a stored row cannot be decoded.
496 async fn settle_workflow_outbox_rows_cancelled(
497 &self,
498 workflow_id: &WorkflowId,
499 ) -> Result<Vec<String>, StoreError> {
500 let _ = workflow_id;
501 Ok(Vec::new())
502 }
503}
504
505/// Convenience trait for concrete stores that support reads/timers, recorder
506/// writes, and deployed-package persistence.
507///
508/// [`crate::PackageStore`] is part of the contract, not an optional add-on:
509/// runtime-deployed packages share the durability promise of event history
510/// (a recovered run is pinned to a recorded package version, and a backend
511/// that dropped the archive would strand it).
512pub trait EventStore: ReadableEventStore + WritableEventStore + crate::PackageStore {}
513
514impl<T> EventStore for T where
515 T: ReadableEventStore + WritableEventStore + crate::PackageStore + ?Sized
516{
517}
518
519pub(crate) fn conformance_write_token() -> WriteToken {
520 write_capability::conformance()
521}
522
523#[cfg(test)]
524mod tests {
525 use std::sync::Arc;
526
527 use super::{EventStore, ReadableEventStore, WritableEventStore};
528
529 #[test]
530 fn event_store_traits_are_object_safe() {
531 let _: Option<Arc<dyn ReadableEventStore>> = None;
532 let _: Option<Arc<dyn WritableEventStore>> = None;
533 let _: Option<Arc<dyn EventStore>> = None;
534 }
535}