Skip to main content

liminal/durability/
error.rs

1//! Named storage failures and uncertain channel append outcomes.
2/// Error taxonomy for haematite-backed durability operations.
3#[derive(Debug, thiserror::Error)]
4pub enum DurabilityError {
5    /// A logical operation has no staged rows to commit.
6    #[error("operation batch is empty")]
7    EmptyOperationBatch,
8
9    /// A store does not implement atomic routed storage.
10    #[error("{store} does not support atomic routed storage")]
11    BatchUnsupported {
12        /// The store's type.
13        store: &'static str,
14    },
15
16    /// The store did not establish whether a sent append committed.
17    #[error("append to {stream_key} at sequence {sequence} is unresolved: {cause}")]
18    AppendUnresolved {
19        /// Partition stream receiving the append.
20        stream_key: String,
21        /// Reserved sequence of the sent bytes.
22        sequence: u64,
23        /// Original append failure, never used to classify the outcome.
24        cause: String,
25    },
26
27    /// The authoritative read of an earlier uncertain append failed.
28    #[error(
29        "append to {stream_key} at sequence {sequence} remains unresolved: append: {append_error}; read-back: {read_error}"
30    )]
31    PendingAppendUnreadable {
32        /// Partition stream that must be reconciled.
33        stream_key: String,
34        /// Reserved sequence of the sent bytes.
35        sequence: u64,
36        /// Original append error, retained across every failed read.
37        append_error: String,
38        /// The latest authoritative read error.
39        read_error: String,
40    },
41
42    /// A write the store had started when its caller stopped waiting: it may
43    /// have been applied. It is never treated as not written: the caller
44    /// reads the position back to settle it (`settle`), or refuses by this
45    /// name.
46    #[error(
47        "write outcome unknown on shard {pid}: it may have been applied; read the position again"
48    )]
49    OutcomeUnknown {
50        /// The shard that had started the write.
51        pid: u64,
52    },
53
54    /// A write withdrawn before the store started it: nothing was written,
55    /// and it may be sent again.
56    #[error("write not started on shard {pid}: nothing was written")]
57    NotStarted {
58        /// The shard the write was queued for.
59        pid: u64,
60    },
61
62    /// Haematite returned a store-level failure.
63    ///
64    /// The umbrella `From<haematite::ApiError>` conversion lives in
65    /// [`super::store`] because it routes the optimistic-concurrency variants to
66    /// their dedicated cases rather than wrapping them here.
67    #[error("haematite store error: {0}")]
68    StoreError(haematite::ApiError),
69
70    /// An append observed a different stream sequence than the caller expected.
71    #[error("sequence conflict: expected {expected}, actual {actual}")]
72    SequenceConflict {
73        /// Caller-provided expected stream sequence.
74        expected: u64,
75        /// Actual stream sequence reported by haematite.
76        actual: u64,
77    },
78
79    /// A cursor checkpoint attempted to move from a stale stored value.
80    #[error("cursor regression: stored {stored}, attempted {attempted}")]
81    CursorRegression {
82        /// Current value stored for the cursor.
83        stored: u64,
84        /// Value the caller attempted to checkpoint from.
85        attempted: u64,
86    },
87
88    /// A producer idempotency key collided with an existing dedup entry.
89    #[error("dedup key collision for key {key}")]
90    DedupCollision {
91        /// Idempotency key that collided.
92        key: String,
93    },
94
95    /// Durability configuration failed validation.
96    #[error("configuration error: {0}")]
97    ConfigError(String),
98
99    /// An ephemeral durable store could not be opened on disk.
100    ///
101    /// Raised only on the construction path of a self-owned ephemeral store
102    /// (see [`super::EphemeralRoot::open_store`]); the guarding temporary directory
103    /// is already removed by the time this surfaces.
104    #[error("ephemeral store open failed: {0}")]
105    EphemeralStoreOpen(String),
106
107    /// An operation reached an ephemeral store whose backing database was
108    /// already detached by teardown.
109    ///
110    /// Unreachable by construction: the store is detached only inside the
111    /// ephemeral guard's `Drop`, which cannot overlap a live handle's call.
112    /// The variant exists because that detachment is expressed through an
113    /// `Option` the compiler cannot see through, and the fallback must be a
114    /// typed refusal rather than a panic.
115    #[error("ephemeral store already detached by teardown")]
116    EphemeralStoreDetached,
117
118    /// A store that cannot delete entries was asked to truncate a stream.
119    #[error("{store} cannot truncate {stream_key} below sequence {before_seq}")]
120    TruncateUnsupported {
121        /// The store's type.
122        store: &'static str,
123        /// The stream it was asked to cut.
124        stream_key: String,
125        /// The sequence it was asked to cut below.
126        before_seq: u64,
127    },
128
129    /// A store that cannot list a routed key window was asked to scan one.
130    #[error("{store} cannot scan routed keys on route {route_key}")]
131    RoutedScanUnsupported {
132        /// The store's type.
133        store: &'static str,
134        /// The route it was asked to scan.
135        route_key: String,
136    },
137
138    /// A store that cannot delete a routed key window in one commit was
139    /// asked to remove routed rows.
140    ///
141    /// Composing the window from single deletes is never done in its place
142    /// (DUR-018 R3): each would be its own commit.
143    #[error("{store} cannot remove routed rows on route {route_key} in one commit")]
144    RoutedRemoveUnsupported {
145        /// The store's type.
146        store: &'static str,
147        /// The route it was asked to cut.
148        route_key: String,
149    },
150
151    /// A store that cannot delete a key inside a routed batch was asked to
152    /// commit a batch with a delete (DUR-028 R3). Nothing was written: the
153    /// batch is one durable operation or none.
154    #[error("{store} cannot delete inside a routed batch on route {route_key}")]
155    RoutedDeleteUnsupported {
156        /// The store's type.
157        store: &'static str,
158        /// The route the batch was for.
159        route_key: String,
160    },
161
162    /// Persisted envelope bytes could not be encoded or decoded.
163    #[error("envelope serialization error: {0}")]
164    EnvelopeError(String),
165}
166
167impl From<haematite::SequenceConflict> for DurabilityError {
168    fn from(error: haematite::SequenceConflict) -> Self {
169        Self::SequenceConflict {
170            expected: error.expected,
171            actual: error.actual,
172        }
173    }
174}
175
176impl From<haematite::CasMismatch> for DurabilityError {
177    fn from(error: haematite::CasMismatch) -> Self {
178        // The real `CasMismatch` carries `Option<u64>` to distinguish absent
179        // (`None`) from stored-zero (`Some(0)`). The cursor contract treats an
180        // absent key as the value 0 (see `HaematiteStore::cas`), so a `None`
181        // collapses to 0 here when reporting a regression.
182        Self::CursorRegression {
183            stored: error.actual.unwrap_or(0),
184            attempted: error.expected.unwrap_or(0),
185        }
186    }
187}