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}