1use thiserror::Error;
2
3use crate::hook::{SettlementFailureReason, SettlementSkipReason};
4use crate::retry::RetryPolicy;
5
6pub const MAX_SETTLEMENT_WORKER_ID_BYTES: usize = 128;
8
9pub const MAX_SETTLEMENT_LEASE_MS: u64 = 86_400_000;
11
12pub const MAX_SETTLEMENT_CLAIM_BATCH: usize = 1024;
14
15#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
17pub enum SettlementClaimValidationError {
18 #[error("settlement worker id length is out of bounds")]
20 WorkerIdLength,
21 #[error("settlement lease duration is out of bounds")]
23 LeaseDuration,
24 #[error("settlement claim batch size is out of bounds")]
26 ClaimBatch,
27}
28
29pub fn validate_settlement_claim(
31 worker_id: &str,
32 lease_ms: u64,
33 limit: usize,
34) -> Result<(), SettlementClaimValidationError> {
35 if worker_id.is_empty() || worker_id.len() > MAX_SETTLEMENT_WORKER_ID_BYTES {
36 return Err(SettlementClaimValidationError::WorkerIdLength);
37 }
38 if lease_ms == 0 || lease_ms > MAX_SETTLEMENT_LEASE_MS {
39 return Err(SettlementClaimValidationError::LeaseDuration);
40 }
41 if limit == 0 || limit > MAX_SETTLEMENT_CLAIM_BATCH {
42 return Err(SettlementClaimValidationError::ClaimBatch);
43 }
44 Ok(())
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub struct SettlementStoreBinding([u8; 32]);
53
54impl SettlementStoreBinding {
55 #[must_use]
57 pub const fn from_digest(digest: [u8; 32]) -> Self {
58 Self(digest)
59 }
60
61 #[must_use]
63 pub const fn as_bytes(&self) -> &[u8; 32] {
64 &self.0
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum SettlementRoutingInput {
71 Accepted,
73 Skipped { reason: SettlementSkipReason },
75 Retryable { reason: SettlementFailureReason },
77 Permanent { reason: SettlementFailureReason },
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct SettlementAttemptClaim {
84 pub receipt_id: String,
86 pub finalized_at: u64,
88 pub attempts: u32,
90 pub row_version: u64,
92 pub lease_owner: String,
94 pub lease_token: String,
96 pub lease_until_ms: u64,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum SettlementRoute {
103 NoAction,
105 RetryScheduled {
107 attempt: u32,
109 next_visible_at_ms: u64,
111 },
112 DeadLettered {
114 attempts: u32,
116 },
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum SettlementRouteErrorClass {
122 Backend,
124 Conflict,
126 InvalidRecord,
128}
129
130#[derive(Debug, Error, Clone, PartialEq, Eq)]
132pub enum SettlementRouteError {
133 #[error("settlement routing backend failure: {detail}")]
134 Backend { detail: String },
135 #[error("settlement routing conflict: {detail}")]
136 Conflict { detail: String },
137 #[error("invalid settlement routing record: {detail}")]
138 InvalidRecord { detail: String },
139}
140
141impl SettlementRouteError {
142 #[must_use]
144 pub const fn class(&self) -> SettlementRouteErrorClass {
145 match self {
146 Self::Backend { .. } => SettlementRouteErrorClass::Backend,
147 Self::Conflict { .. } => SettlementRouteErrorClass::Conflict,
148 Self::InvalidRecord { .. } => SettlementRouteErrorClass::InvalidRecord,
149 }
150 }
151}
152
153pub trait SettlementOutcomeStore: Send + Sync {
155 fn settlement_store_binding(&self) -> SettlementStoreBinding;
157
158 fn claim_receipt(
160 &self,
161 receipt_id: &str,
162 worker_id: &str,
163 now_ms: u64,
164 lease_ms: u64,
165 ) -> Result<Option<SettlementAttemptClaim>, SettlementRouteError>;
166
167 fn claim_due(
169 &self,
170 worker_id: &str,
171 now_ms: u64,
172 lease_ms: u64,
173 limit: usize,
174 ) -> Result<Vec<SettlementAttemptClaim>, SettlementRouteError>;
175
176 fn record_claimed_outcome(
184 &self,
185 claim: &SettlementAttemptClaim,
186 outcome: &SettlementRoutingInput,
187 policy: RetryPolicy,
188 observed_at_ms: u64,
189 ) -> Result<SettlementRoute, SettlementRouteError>;
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn attempt_claim_carries_the_persisted_cas_state() {
198 let claim = SettlementAttemptClaim {
199 receipt_id: "receipt-1".to_string(),
200 finalized_at: 41,
201 attempts: 2,
202 row_version: 3,
203 lease_owner: "worker-1".to_string(),
204 lease_token: "token-1".to_string(),
205 lease_until_ms: 50,
206 };
207
208 assert_eq!(claim.finalized_at, 41);
209 assert_eq!(claim.attempts, 2);
210 assert_eq!(claim.lease_owner, "worker-1");
211 }
212
213 #[test]
214 fn claim_parameters_are_bounded() {
215 assert!(validate_settlement_claim("worker", 1, 1).is_ok());
216 assert!(validate_settlement_claim(
217 &"w".repeat(MAX_SETTLEMENT_WORKER_ID_BYTES),
218 MAX_SETTLEMENT_LEASE_MS,
219 MAX_SETTLEMENT_CLAIM_BATCH,
220 )
221 .is_ok());
222 assert!(validate_settlement_claim(&"\u{e9}".repeat(64), 1, 1).is_ok());
223
224 let invalid = [
225 (
226 validate_settlement_claim("", 1, 1),
227 SettlementClaimValidationError::WorkerIdLength,
228 ),
229 (
230 validate_settlement_claim(&"w".repeat(MAX_SETTLEMENT_WORKER_ID_BYTES + 1), 1, 1),
231 SettlementClaimValidationError::WorkerIdLength,
232 ),
233 (
234 validate_settlement_claim(&"\u{e9}".repeat(65), 1, 1),
235 SettlementClaimValidationError::WorkerIdLength,
236 ),
237 (
238 validate_settlement_claim("worker", 0, 1),
239 SettlementClaimValidationError::LeaseDuration,
240 ),
241 (
242 validate_settlement_claim("worker", MAX_SETTLEMENT_LEASE_MS + 1, 1),
243 SettlementClaimValidationError::LeaseDuration,
244 ),
245 (
246 validate_settlement_claim("worker", 1, 0),
247 SettlementClaimValidationError::ClaimBatch,
248 ),
249 (
250 validate_settlement_claim("worker", 1, MAX_SETTLEMENT_CLAIM_BATCH + 1),
251 SettlementClaimValidationError::ClaimBatch,
252 ),
253 ];
254
255 for (result, expected) in invalid {
256 assert_eq!(result, Err(expected));
257 }
258 }
259
260 #[test]
261 fn outcome_store_error_classes_are_bounded() {
262 let cases = [
263 (
264 SettlementRouteError::Backend {
265 detail: "db unavailable".to_string(),
266 },
267 SettlementRouteErrorClass::Backend,
268 ),
269 (
270 SettlementRouteError::Conflict {
271 detail: "stale lease".to_string(),
272 },
273 SettlementRouteErrorClass::Conflict,
274 ),
275 (
276 SettlementRouteError::InvalidRecord {
277 detail: "negative counter".to_string(),
278 },
279 SettlementRouteErrorClass::InvalidRecord,
280 ),
281 ];
282
283 for (error, expected) in cases {
284 assert_eq!(error.class(), expected);
285 }
286 }
287
288 #[test]
289 fn outcome_store_trait_is_object_safe() {
290 fn accepts_trait_object(_store: &dyn SettlementOutcomeStore) {}
291
292 let _ = accepts_trait_object;
293 }
294}