Skip to main content

oxirs_stream/state/
linearizable_reader.rs

1//! # Linearizable reads on Raft-backed operator state
2//!
3//! Reads served from the local cache reflect *committed* state (the cache is
4//! only updated after the underlying Raft proposal succeeds). For workloads
5//! that need a true linearizable read — i.e. "see every value committed before
6//! the read started" — we route the read through the cluster's
7//! [`oxirs_cluster::consensus::ConsensusManager`].
8//!
9//! ## Two read paths
10//!
11//! [`LinearizableReader`] exposes two flavours of read:
12//!
13//! * [`LinearizableReader::get`] — the **leader-stickiness** path. It checks
14//!   that the local node is the leader, snapshots the current term, then
15//!   serves from the local cache (which reflects every committed put issued
16//!   on this node). Cheap; guaranteed monotonic when reading from the leader.
17//! * [`LinearizableReader::get_with_barrier`] — the **strict barrier** path.
18//!   It issues a no-op `BeginTransaction` / `RollbackTransaction` pair through
19//!   [`ConsensusManager::propose_command`] before reading. The pair is a
20//!   round-trip through Raft, so by the time it returns every commit issued
21//!   before this call has been applied. Strictly stronger than the
22//!   leader-stickiness path; pays one Raft round-trip.
23//!
24//! ## Concurrency window
25//!
26//! A pure leader-stickiness check (`get`) has a concurrency window between
27//! `is_leader()` returning `true` and the cache read in which the local node
28//! could lose leadership. Callers that cannot tolerate that window must use
29//! `get_with_barrier`.
30//!
31//! ## Why we don't add a dedicated `RdfCommand::Barrier`
32//!
33//! The cluster's `RdfCommand` enum is shared with the rest of the cluster
34//! crate and adding a fresh variant is a larger ABI change than this slice
35//! owns. Begin/Rollback is an existing, idempotent no-op pair that the
36//! state machine already understands.
37
38use std::sync::Arc;
39
40use serde::{Deserialize, Serialize};
41use thiserror::Error;
42use tracing::debug;
43
44use oxirs_cluster::consensus::ConsensusManager;
45
46use super::raft_state::{RaftBackedOperatorState, StateValue};
47
48// ─── Errors ─────────────────────────────────────────────────────────────────
49
50/// Errors raised by [`LinearizableReader`].
51#[derive(Debug, Error)]
52pub enum LinearizableReadError {
53    /// Local node is not the cluster leader and `require_leader` is set.
54    #[error("local node is not the leader")]
55    NotLeader,
56    /// Underlying state error.
57    #[error("state error: {0}")]
58    State(String),
59}
60
61/// Convenience alias.
62pub type LinearizableReadResult<T> = std::result::Result<T, LinearizableReadError>;
63
64// ─── Reader ────────────────────────────────────────────────────────────────
65
66/// Configuration for [`LinearizableReader`].
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct LinearizableReadConfig {
69    /// If `true`, [`LinearizableReader::get`] errors with [`LinearizableReadError::NotLeader`]
70    /// when the local node is not the leader. Defaults to `true` so reads are
71    /// served exclusively from the leader's view.
72    pub require_leader: bool,
73}
74
75impl Default for LinearizableReadConfig {
76    fn default() -> Self {
77        Self {
78            require_leader: true,
79        }
80    }
81}
82
83/// A read accessor that goes through Raft consensus before returning a value.
84pub struct LinearizableReader {
85    consensus: Arc<ConsensusManager>,
86    state: Arc<RaftBackedOperatorState>,
87    config: LinearizableReadConfig,
88}
89
90impl LinearizableReader {
91    /// Build a reader.
92    pub fn new(
93        consensus: Arc<ConsensusManager>,
94        state: Arc<RaftBackedOperatorState>,
95        config: LinearizableReadConfig,
96    ) -> Self {
97        Self {
98            consensus,
99            state,
100            config,
101        }
102    }
103
104    /// Returns `true` if the local node currently believes itself the leader.
105    pub async fn is_leader(&self) -> bool {
106        self.consensus.is_leader().await
107    }
108
109    /// Returns the latest known consensus term (for diagnostics).
110    pub async fn term(&self) -> u64 {
111        self.consensus.current_term().await
112    }
113
114    /// Leader-stickiness read of a key.
115    ///
116    /// Cheap read path: checks that the local node is the leader, snapshots
117    /// the current term, then serves from the local cache. The cache only
118    /// reflects committed state, so this is monotonic when read from the
119    /// leader. For strictly linearizable reads under contention, use
120    /// [`Self::get_with_barrier`].
121    pub async fn get(&self, key: &str) -> LinearizableReadResult<Option<StateValue>> {
122        let term = self.consensus.current_term().await;
123        if self.config.require_leader && !self.consensus.is_leader().await {
124            return Err(LinearizableReadError::NotLeader);
125        }
126        debug!(term, key, "linearizable read served (sticky)");
127        Ok(self.state.get_local(key))
128    }
129
130    /// Strict linearizable read of a key.
131    ///
132    /// Issues a `BeginTransaction` / `RollbackTransaction` pair through
133    /// `ConsensusManager::propose_command` before reading the local cache.
134    /// The pair is a Raft round-trip, so on return every commit issued
135    /// before this call has been applied to the local replica.
136    ///
137    /// Costs one Raft round-trip per call; use sparingly.
138    pub async fn get_with_barrier(&self, key: &str) -> LinearizableReadResult<Option<StateValue>> {
139        if self.config.require_leader && !self.consensus.is_leader().await {
140            return Err(LinearizableReadError::NotLeader);
141        }
142        let tx_id = format!("oxirs-stream-barrier-{}", uuid::Uuid::new_v4());
143        // Begin → rollback is an idempotent no-op as far as the state machine
144        // is concerned, but it forces the cluster sink to commit the round
145        // through Raft, giving us our barrier.
146        self.consensus
147            .begin_transaction(tx_id.clone())
148            .await
149            .map_err(|e| LinearizableReadError::State(e.to_string()))?;
150        self.consensus
151            .rollback_transaction(tx_id)
152            .await
153            .map_err(|e| LinearizableReadError::State(e.to_string()))?;
154        Ok(self.state.get_local(key))
155    }
156
157    /// Underlying operator state handle.
158    pub fn state(&self) -> &Arc<RaftBackedOperatorState> {
159        &self.state
160    }
161
162    /// Underlying consensus manager handle.
163    pub fn consensus(&self) -> &Arc<ConsensusManager> {
164        &self.consensus
165    }
166}
167
168// ─── Tests ──────────────────────────────────────────────────────────────────
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::state::raft_state::{RaftBackedOperatorState, RaftStateConfig, StateValue};
174    use async_trait::async_trait;
175    use oxirs_cluster::stream_integration::StreamMessage;
176    use oxirs_cluster::streaming::cluster_sink::{SinkError, StreamSink};
177    use parking_lot::Mutex;
178
179    #[derive(Default)]
180    struct PassThroughSink {
181        committed: Mutex<u64>,
182    }
183
184    #[async_trait]
185    impl StreamSink for PassThroughSink {
186        async fn write_batch(&self, _events: Vec<StreamMessage>) -> Result<(), SinkError> {
187            *self.committed.lock() += 1;
188            Ok(())
189        }
190    }
191
192    #[tokio::test]
193    async fn read_returns_locally_cached_value_when_not_requiring_leader() {
194        let consensus = Arc::new(ConsensusManager::new(1, vec![]));
195        let sink = Arc::new(PassThroughSink::default());
196        let state = Arc::new(RaftBackedOperatorState::new(
197            RaftStateConfig {
198                operator_id: "lin-test".into(),
199                stream_id: None,
200            },
201            sink,
202        ));
203        state.put("k", StateValue::Counter(11)).await.expect("put");
204
205        let reader = LinearizableReader::new(
206            consensus,
207            state,
208            LinearizableReadConfig {
209                require_leader: false,
210            },
211        );
212        let v = reader.get("k").await.expect("ok");
213        assert_eq!(v, Some(StateValue::Counter(11)));
214    }
215
216    #[tokio::test]
217    async fn missing_key_returns_none() {
218        let consensus = Arc::new(ConsensusManager::new(2, vec![]));
219        let sink = Arc::new(PassThroughSink::default());
220        let state = Arc::new(RaftBackedOperatorState::new(
221            RaftStateConfig {
222                operator_id: "lin-test".into(),
223                stream_id: None,
224            },
225            sink,
226        ));
227        let reader = LinearizableReader::new(
228            consensus,
229            state,
230            LinearizableReadConfig {
231                require_leader: false,
232            },
233        );
234        let v = reader.get("ghost").await.expect("ok");
235        assert!(v.is_none());
236    }
237
238    #[tokio::test]
239    async fn get_with_barrier_succeeds_on_leader_after_put() {
240        use oxirs_cluster::raft::{init_global_shared_storage, reset_global_shared_storage};
241        static TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
242        let _g = TEST_LOCK.lock().await;
243        init_global_shared_storage();
244        reset_global_shared_storage().await;
245
246        let consensus = Arc::new(ConsensusManager::new(11, vec![]));
247        let sink = Arc::new(PassThroughSink::default());
248        let state = Arc::new(RaftBackedOperatorState::new(
249            RaftStateConfig {
250                operator_id: "lin-barrier".into(),
251                stream_id: None,
252            },
253            sink,
254        ));
255        state
256            .put("counter", StateValue::Counter(7))
257            .await
258            .expect("put");
259
260        let reader = LinearizableReader::new(
261            consensus,
262            state,
263            LinearizableReadConfig {
264                require_leader: true,
265            },
266        );
267        let v = reader.get_with_barrier("counter").await.expect("ok");
268        assert_eq!(v, Some(StateValue::Counter(7)));
269    }
270
271    #[tokio::test]
272    async fn require_leader_true_serves_when_local_is_leader() {
273        // The non-raft fallback in `RaftNode::is_leader` returns true, so
274        // single-node test setups exercise the require_leader=true happy path.
275        let consensus = Arc::new(ConsensusManager::new(10, vec![]));
276        let sink = Arc::new(PassThroughSink::default());
277        let state = Arc::new(RaftBackedOperatorState::new(
278            RaftStateConfig {
279                operator_id: "lin-test".into(),
280                stream_id: None,
281            },
282            sink,
283        ));
284        state.put("k", StateValue::Counter(99)).await.expect("put");
285        let reader = LinearizableReader::new(
286            consensus,
287            state,
288            LinearizableReadConfig {
289                require_leader: true,
290            },
291        );
292        assert!(reader.is_leader().await);
293        let v = reader.get("k").await.expect("ok");
294        assert_eq!(v, Some(StateValue::Counter(99)));
295    }
296
297    #[tokio::test]
298    async fn term_query_returns_finite_value() {
299        let consensus = Arc::new(ConsensusManager::new(3, vec![]));
300        let sink = Arc::new(PassThroughSink::default());
301        let state = Arc::new(RaftBackedOperatorState::new(
302            RaftStateConfig {
303                operator_id: "lin-test".into(),
304                stream_id: None,
305            },
306            sink,
307        ));
308        let reader = LinearizableReader::new(consensus, state, LinearizableReadConfig::default());
309        // Smoke test: term should always be representable as u64.
310        let _ = reader.term().await;
311    }
312}