Skip to main content

chio_settle/
outcome_store.rs

1use thiserror::Error;
2
3use crate::hook::{SettlementFailureReason, SettlementSkipReason};
4use crate::retry::RetryPolicy;
5
6/// Maximum UTF-8 byte length of a settlement worker identifier.
7pub const MAX_SETTLEMENT_WORKER_ID_BYTES: usize = 128;
8
9/// Maximum settlement claim lease duration in milliseconds.
10pub const MAX_SETTLEMENT_LEASE_MS: u64 = 86_400_000;
11
12/// Maximum number of rows returned by one settlement claim.
13pub const MAX_SETTLEMENT_CLAIM_BATCH: usize = 1024;
14
15/// Invalid settlement claim parameters.
16#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
17pub enum SettlementClaimValidationError {
18    /// Worker identifier is empty or exceeds its UTF-8 byte bound.
19    #[error("settlement worker id length is out of bounds")]
20    WorkerIdLength,
21    /// Lease duration is zero or exceeds the one-day bound.
22    #[error("settlement lease duration is out of bounds")]
23    LeaseDuration,
24    /// Claim batch is zero or exceeds the row-count bound.
25    #[error("settlement claim batch size is out of bounds")]
26    ClaimBatch,
27}
28
29/// Validate parameters shared by settlement claim operations.
30pub 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/// Opaque identity shared by receipt and outcome views of one durable writer.
48///
49/// Backends generate one value per writer instance and copy it into every
50/// handle that participates in the same atomic settlement ledger.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub struct SettlementStoreBinding([u8; 32]);
53
54impl SettlementStoreBinding {
55    /// Construct a binding from a backend-generated digest.
56    #[must_use]
57    pub const fn from_digest(digest: [u8; 32]) -> Self {
58        Self(digest)
59    }
60
61    /// Return the backend-generated digest.
62    #[must_use]
63    pub const fn as_bytes(&self) -> &[u8; 32] {
64        &self.0
65    }
66}
67
68/// Normalized outcome accepted by durable settlement routing.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum SettlementRoutingInput {
71    /// The hook durably accepted the observation.
72    Accepted,
73    /// The observation legitimately requires no settlement work.
74    Skipped { reason: SettlementSkipReason },
75    /// The observation may succeed when replayed.
76    Retryable { reason: SettlementFailureReason },
77    /// The observation must terminate without replay.
78    Permanent { reason: SettlementFailureReason },
79}
80
81/// Persisted row and lease state required to commit a settlement transition.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct SettlementAttemptClaim {
84    /// Receipt bound to the claimed row.
85    pub receipt_id: String,
86    /// Receipt finalization timestamp persisted with the row.
87    pub finalized_at: u64,
88    /// Completed hook invocations persisted before this claim.
89    pub attempts: u32,
90    /// Version incremented when the claim was acquired.
91    pub row_version: u64,
92    /// Worker that owns the lease.
93    pub lease_owner: String,
94    /// Unpredictable token required by the outcome CAS.
95    pub lease_token: String,
96    /// Exclusive lease deadline in milliseconds.
97    pub lease_until_ms: u64,
98}
99
100/// Result of atomically recording a claimed settlement outcome.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum SettlementRoute {
103    /// The claimed row was removed without further work.
104    NoAction,
105    /// The claimed row was rescheduled for retry.
106    RetryScheduled {
107        /// Persisted attempt number for the next invocation.
108        attempt: u32,
109        /// Earliest visibility time in milliseconds.
110        next_visible_at_ms: u64,
111    },
112    /// The failure was committed as terminal.
113    DeadLettered {
114        /// Total hook invocations represented by the dead letter.
115        attempts: u32,
116    },
117}
118
119/// Bounded class for settlement routing failures.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum SettlementRouteErrorClass {
122    /// Persistence backend failure.
123    Backend,
124    /// Lease, version, or terminal-state conflict.
125    Conflict,
126    /// Invalid or overflowing durable data.
127    InvalidRecord,
128}
129
130/// Failure returned by the durable settlement router.
131#[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    /// Return the stable class suitable for bounded telemetry.
143    #[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
153/// Atomic leased store for settlement outcomes.
154pub trait SettlementOutcomeStore: Send + Sync {
155    /// Identify the durable writer that owns this outcome store.
156    fn settlement_store_binding(&self) -> SettlementStoreBinding;
157
158    /// Claim one due row by receipt id, or return `None` when a live lease owns it.
159    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    /// Claim a bounded due batch using the same lease and version rules.
168    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    /// Atomically commit an outcome for an exact, unexpired claim.
177    ///
178    /// Terminal timestamps and attempt counts come from the persisted claim,
179    /// not from caller-supplied duplicates.
180    ///
181    /// `observed_at_ms` is both the lease-validity check time and the base for
182    /// any retry visibility deadline.
183    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}