Skip to main content

spacedb_consistency/
strong.rs

1//! The Strong (linearizable) tier — a quorum that **fails safe** under partition.
2//!
3//! Only the genuine minority of data that CRDTs cannot express needs this:
4//! **uniqueness** (one username, one seat), **non-negative invariants** (don't
5//! oversell), **money**. The mechanism is a per-key **quorum** of members, each
6//! holding a versioned register; a write is a compare-and-set that commits only if
7//! a **majority** agrees, so:
8//!
9//! - Concurrent writers race on the version: exactly one wins; the loser is
10//!   refused, never double-committed (no two usernames, no oversold seat).
11//! - **Under partition the quorum is unreachable → the op returns
12//!   [`StrongResult::Unavailable`] and commits nothing.** It fails safe, not open —
13//!   a minority side can never diverge, because only a majority side can commit
14//!   and there is at most one majority side.
15//!
16//! This is the in-process consensus core (2-of-3 happy path + the fail-safe
17//! partition behaviour, which is Phase-1 scope). The scheduler-placed, anti-affine
18//! membership and the cross-host transport are the M3/M4 seams; production
19//! reconfiguration-under-churn is Phase 2.
20
21use std::collections::HashMap;
22
23use crate::outcome::{Outcome, UnavailableReason};
24use crate::tier::Tier;
25
26/// Why a strong op was refused by the invariant (the quorum *was* reached).
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum RejectReason {
29    /// A uniqueness key is already owned.
30    AlreadyClaimed,
31    /// A non-negative resource is exhausted.
32    Exhausted,
33    /// A concurrent writer already advanced the version (this CAS lost the race).
34    VersionConflict,
35}
36
37/// The result of a strong op. `Committed`/`Rejected` mean the quorum was reached
38/// and gave a definitive, linearizable answer; `Unavailable` means it was not
39/// reached and **nothing was committed**.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub enum StrongResult {
42    Committed,
43    Rejected(RejectReason),
44    Unavailable(UnavailableReason),
45}
46
47impl StrongResult {
48    pub fn is_committed(&self) -> bool {
49        matches!(self, StrongResult::Committed)
50    }
51
52    /// Whether the quorum was reached (a definitive answer, committed or refused).
53    pub fn is_linearizable(&self) -> bool {
54        !matches!(self, StrongResult::Unavailable(_))
55    }
56
57    /// Map to the honesty contract's consistency level: a reached quorum is
58    /// `Committed(Strong)` (linearizably decided); otherwise `Unavailable`.
59    pub fn consistency(&self) -> Outcome {
60        match self {
61            StrongResult::Committed | StrongResult::Rejected(_) => Outcome::Committed(Tier::Strong),
62            StrongResult::Unavailable(reason) => Outcome::Unavailable(*reason),
63        }
64    }
65}
66
67#[derive(Clone, Debug)]
68struct Member {
69    id: String,
70    online: bool,
71    /// key → (value, version).
72    store: HashMap<String, (Vec<u8>, u64)>,
73}
74
75/// A quorum of members holding versioned registers for strong-tier keys.
76pub struct QuorumGroup {
77    members: Vec<Member>,
78}
79
80impl QuorumGroup {
81    /// A group of the given members, all initially reachable.
82    pub fn new<I, S>(member_ids: I) -> Self
83    where
84        I: IntoIterator<Item = S>,
85        S: Into<String>,
86    {
87        let members = member_ids
88            .into_iter()
89            .map(|id| Member {
90                id: id.into(),
91                online: true,
92                store: HashMap::new(),
93            })
94            .collect();
95        Self { members }
96    }
97
98    pub fn size(&self) -> usize {
99        self.members.len()
100    }
101
102    /// The number of members that must agree (a strict majority).
103    pub fn majority(&self) -> usize {
104        self.members.len() / 2 + 1
105    }
106
107    pub fn online_count(&self) -> usize {
108        self.members.iter().filter(|m| m.online).count()
109    }
110
111    /// Take a member offline (simulate it being on the far side of a partition).
112    pub fn partition(&mut self, member_id: &str) -> bool {
113        self.set_online(member_id, false)
114    }
115
116    /// Bring a member back online.
117    pub fn heal(&mut self, member_id: &str) -> bool {
118        self.set_online(member_id, true)
119    }
120
121    fn set_online(&mut self, member_id: &str, online: bool) -> bool {
122        match self.members.iter_mut().find(|m| m.id == member_id) {
123            Some(m) => {
124                m.online = online;
125                true
126            }
127            None => false,
128        }
129    }
130
131    fn online_indices(&self) -> Vec<usize> {
132        (0..self.members.len())
133            .filter(|&i| self.members[i].online)
134            .collect()
135    }
136
137    /// Read the latest committed `(value, version)` for `key` from a quorum. The
138    /// highest version across any reachable majority is the latest committed
139    /// value (any two majorities overlap). Errors if a majority isn't reachable.
140    pub fn read(&self, key: &str) -> Result<(Option<Vec<u8>>, u64), UnavailableReason> {
141        let online = self.online_indices();
142        if online.len() < self.majority() {
143            return Err(UnavailableReason::QuorumUnreachable);
144        }
145        let best = online
146            .iter()
147            .filter_map(|&i| self.members[i].store.get(key))
148            .max_by_key(|(_, version)| *version);
149        Ok(match best {
150            Some((value, version)) => (Some(value.clone()), *version),
151            None => (None, 0),
152        })
153    }
154
155    /// Compare-and-set: commit `new_value` at `expected_version + 1` to a majority,
156    /// but only if the current committed version is still `expected_version`.
157    /// `Unavailable` if no majority is reachable (nothing is written); `Rejected`
158    /// if a concurrent writer already advanced the version.
159    pub fn cas(&mut self, key: &str, expected_version: u64, new_value: Vec<u8>) -> StrongResult {
160        let online = self.online_indices();
161        if online.len() < self.majority() {
162            return StrongResult::Unavailable(UnavailableReason::QuorumUnreachable);
163        }
164        let current = online
165            .iter()
166            .filter_map(|&i| self.members[i].store.get(key).map(|(_, v)| *v))
167            .max()
168            .unwrap_or(0);
169        if current != expected_version {
170            return StrongResult::Rejected(RejectReason::VersionConflict);
171        }
172        let new_version = expected_version + 1;
173        for &i in &online {
174            self.members[i]
175                .store
176                .insert(key.to_string(), (new_value.clone(), new_version));
177        }
178        StrongResult::Committed
179    }
180
181    /// Claim a uniqueness `key` for `owner`. Succeeds only if unclaimed; a second
182    /// claimant is [`RejectReason::AlreadyClaimed`].
183    pub fn claim_unique(&mut self, key: &str, owner: &[u8]) -> StrongResult {
184        let (current, version) = match self.read(key) {
185            Ok(read) => read,
186            Err(reason) => return StrongResult::Unavailable(reason),
187        };
188        if current.is_some() {
189            return StrongResult::Rejected(RejectReason::AlreadyClaimed);
190        }
191        self.cas(key, version, owner.to_vec())
192    }
193
194    /// Initialize a non-negative resource `key` with `count` units.
195    pub fn init_seats(&mut self, key: &str, count: u64) -> StrongResult {
196        let (_, version) = match self.read(key) {
197            Ok(read) => read,
198            Err(reason) => return StrongResult::Unavailable(reason),
199        };
200        self.cas(key, version, count.to_le_bytes().to_vec())
201    }
202
203    /// Acquire one unit of a non-negative resource. [`RejectReason::Exhausted`] at
204    /// zero — never oversells.
205    pub fn acquire_seat(&mut self, key: &str) -> StrongResult {
206        let (current, version) = match self.read(key) {
207            Ok(read) => read,
208            Err(reason) => return StrongResult::Unavailable(reason),
209        };
210        let remaining = decode_count(current.as_deref());
211        if remaining == 0 {
212            return StrongResult::Rejected(RejectReason::Exhausted);
213        }
214        self.cas(key, version, (remaining - 1).to_le_bytes().to_vec())
215    }
216
217    /// The units remaining for a resource `key`.
218    pub fn seats_remaining(&self, key: &str) -> Result<u64, UnavailableReason> {
219        let (current, _) = self.read(key)?;
220        Ok(decode_count(current.as_deref()))
221    }
222}
223
224fn decode_count(bytes: Option<&[u8]>) -> u64 {
225    match bytes {
226        Some(b) if b.len() == 8 => u64::from_le_bytes(b.try_into().unwrap()),
227        _ => 0,
228    }
229}