Skip to main content

reddb_server/runtime/
write_gate.rs

1//! Public-mutation gate.
2//!
3//! Centralises the check that decides whether a write coming through any
4//! public surface (SQL, HTTP, gRPC, PostgreSQL wire, native wire, admin
5//! mutating endpoints) is allowed for this instance.
6//!
7//! Two inputs:
8//! * `RedDBOptions::read_only` — set explicitly by operators.
9//! * `ReplicationConfig::role`  — `Replica { .. }` is always read-only on
10//!   public surfaces; internal logical-WAL apply (`LogicalChangeApplier`)
11//!   reaches into the store directly and never crosses this gate.
12//!
13//! All public mutation paths consult `WriteGate::check` before dispatching
14//! to storage. The replica internal apply path is the privileged surface
15//! and bypasses the gate by construction.
16//!
17//! Serverless writer-lease state (`PLAN.md` Phase 5 / W6) is wired
18//! through `LeaseGateState` — runtime flips it to `Held` after a
19//! successful acquire/refresh and back to `NotHeld` when the lease is
20//! lost, released, or has expired. Standalone / replica / lease-not-
21//! configured deployments stay on `NotRequired` so the check is a
22//! single atomic load of zero.
23
24use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
25
26use crate::api::{RedDBError, RedDBOptions, RedDBResult};
27use crate::replication::ReplicationRole;
28
29/// Categorises the write so the rejection error can name a sensible
30/// surface in operator-facing logs without leaking internal call sites.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum WriteKind {
33    /// INSERT / UPDATE / DELETE on a user-visible collection.
34    Dml,
35    /// CREATE / DROP / ALTER TABLE, CREATE / DROP INDEX, etc.
36    Ddl,
37    /// Index build / rebuild outside a DDL statement (e.g. background reindex).
38    IndexBuild,
39    /// Reclaim / repair / retention sweeps that mutate state.
40    Maintenance,
41    /// Operator-triggered backup that mutates remote state.
42    Backup,
43    /// Serverless lifecycle endpoints that mutate state (attach / warmup
44    /// / reclaim).
45    Serverless,
46}
47
48impl WriteKind {
49    fn label(self) -> &'static str {
50        match self {
51            WriteKind::Dml => "DML",
52            WriteKind::Ddl => "DDL",
53            WriteKind::IndexBuild => "index build",
54            WriteKind::Maintenance => "maintenance",
55            WriteKind::Backup => "backup trigger",
56            WriteKind::Serverless => "serverless lifecycle",
57        }
58    }
59}
60
61/// Serverless writer-lease state wired through the gate.
62///
63/// `NotRequired` is the default — standalone, replica, and
64/// lease-disabled serverless deployments all share it. `Held` /
65/// `NotHeld` only matter for instances that opted into lease-fenced
66/// writes; the lease loop flips the value as it acquires / refreshes /
67/// loses the slot.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69#[repr(u8)]
70pub enum LeaseGateState {
71    NotRequired = 0,
72    Held = 1,
73    NotHeld = 2,
74}
75
76impl LeaseGateState {
77    fn from_u8(raw: u8) -> Self {
78        match raw {
79            1 => Self::Held,
80            2 => Self::NotHeld,
81            _ => Self::NotRequired,
82        }
83    }
84
85    pub fn label(self) -> &'static str {
86        match self {
87            Self::NotRequired => "not_required",
88            Self::Held => "held",
89            Self::NotHeld => "not_held",
90        }
91    }
92}
93
94/// Live policy for public-mutation surfaces.
95///
96/// `read_only` was originally a `bool` snapshot taken at runtime
97/// construction. PLAN.md Phase 4.3 promotes it to an `AtomicBool` so
98/// `POST /admin/readonly` can flip the policy without a restart. The
99/// `ReplicationRole` stays immutable — flipping a replica into a
100/// primary mid-process would need a full handshake (Phase 3 work in
101/// the data-safety plan), and shouldn't be a single-flag decision.
102#[derive(Debug)]
103pub struct WriteGate {
104    read_only: AtomicBool,
105    role: ReplicationRole,
106    lease: AtomicU8,
107}
108
109impl WriteGate {
110    pub fn from_options(options: &RedDBOptions) -> Self {
111        Self {
112            read_only: AtomicBool::new(options.read_only),
113            role: options.replication.role.clone(),
114            lease: AtomicU8::new(LeaseGateState::NotRequired as u8),
115        }
116    }
117
118    /// Returns `Ok(())` if the public surface is allowed to perform `kind`.
119    /// Returns `RedDBError::ReadOnly` otherwise.
120    ///
121    /// Reasoning order is intentional:
122    /// 1. Replica role — a replica booted with `read_only = false`
123    ///    must still reject; this is a structural property.
124    /// 2. Lease lost — the strongest serverless correctness signal.
125    ///    A writer that lost its lease must stop *immediately*; running
126    ///    while another holder has been promoted causes split-brain.
127    /// 3. Operator read-only flag — explicit /admin/readonly toggle
128    ///    or boot-time pin; lower priority than lease loss because the
129    ///    operator can revoke it without external coordination.
130    pub fn check(&self, kind: WriteKind) -> RedDBResult<()> {
131        self.check_consent(kind).map(|_| ())
132    }
133
134    /// Same as `check` but on success returns a sealed
135    /// `WriteConsent` token. Mutating port methods that take
136    /// `&OperationContext` demand `ctx.write_consent.is_some()`;
137    /// the only way to mint such a token is to call this method,
138    /// so forgetting to consult the gate becomes a structural
139    /// property — not a discipline question.
140    pub fn check_consent(&self, kind: WriteKind) -> RedDBResult<crate::application::WriteConsent> {
141        if matches!(self.role, ReplicationRole::Replica { .. }) {
142            return Err(RedDBError::ReadOnly(format!(
143                "instance is a replica — {} rejected on public surface",
144                kind.label()
145            )));
146        }
147        if matches!(self.lease_state(), LeaseGateState::NotHeld) {
148            return Err(RedDBError::ReadOnly(format!(
149                "writer lease not held — {} rejected (serverless fence)",
150                kind.label()
151            )));
152        }
153        if self.read_only.load(Ordering::Acquire) {
154            return Err(RedDBError::ReadOnly(format!(
155                "instance is configured read_only — {} rejected",
156                kind.label()
157            )));
158        }
159        Ok(crate::application::WriteConsent {
160            kind,
161            _seal: crate::application::WriteConsentSeal::new(),
162        })
163    }
164
165    pub fn is_read_only(&self) -> bool {
166        self.read_only.load(Ordering::Acquire)
167            || matches!(self.role, ReplicationRole::Replica { .. })
168            || matches!(self.lease_state(), LeaseGateState::NotHeld)
169    }
170
171    pub fn role(&self) -> &ReplicationRole {
172        &self.role
173    }
174
175    /// PLAN.md Phase 4.3 — dynamic read-only toggle. Flipping a
176    /// replica back to writable here is a no-op for `check()` because
177    /// the role check fires first; the operator must change the
178    /// replication role through a separate, audited path.
179    ///
180    /// Returns the previous read_only value so callers can detect
181    /// idempotent calls (toggle to the same value = no work to do).
182    pub fn set_read_only(&self, enabled: bool) -> bool {
183        self.read_only.swap(enabled, Ordering::AcqRel)
184    }
185
186    /// Current writer-lease gate state. `NotRequired` for standalone,
187    /// replica, and lease-disabled serverless instances.
188    pub fn lease_state(&self) -> LeaseGateState {
189        LeaseGateState::from_u8(self.lease.load(Ordering::Acquire))
190    }
191
192    /// Flip the lease gate state. Only `LeaseLifecycle` should call
193    /// this — other callers must go through the lifecycle so the
194    /// gate flip and the corresponding `lease/*` audit record
195    /// stay paired.
196    ///
197    /// Returns the previous state so the caller can detect idempotent
198    /// transitions and avoid spamming audit / metrics.
199    pub(crate) fn set_lease_state(&self, state: LeaseGateState) -> LeaseGateState {
200        LeaseGateState::from_u8(self.lease.swap(state as u8, Ordering::AcqRel))
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn gate(read_only: bool, role: ReplicationRole) -> WriteGate {
209        WriteGate {
210            read_only: AtomicBool::new(read_only),
211            role,
212            lease: AtomicU8::new(LeaseGateState::NotRequired as u8),
213        }
214    }
215
216    #[test]
217    fn standalone_allows_writes() {
218        let g = gate(false, ReplicationRole::Standalone);
219        assert!(g.check(WriteKind::Dml).is_ok());
220        assert!(g.check(WriteKind::Ddl).is_ok());
221        assert!(!g.is_read_only());
222    }
223
224    #[test]
225    fn primary_allows_writes() {
226        let g = gate(false, ReplicationRole::Primary);
227        assert!(g.check(WriteKind::Dml).is_ok());
228        assert!(!g.is_read_only());
229    }
230
231    #[test]
232    fn replica_rejects_every_kind() {
233        let g = gate(
234            true,
235            ReplicationRole::Replica {
236                primary_addr: "http://primary:50051".into(),
237            },
238        );
239        for kind in [
240            WriteKind::Dml,
241            WriteKind::Ddl,
242            WriteKind::IndexBuild,
243            WriteKind::Maintenance,
244            WriteKind::Backup,
245            WriteKind::Serverless,
246        ] {
247            let err = g.check(kind).unwrap_err();
248            match err {
249                RedDBError::ReadOnly(msg) => assert!(msg.contains("replica")),
250                other => panic!("expected ReadOnly, got {other:?}"),
251            }
252        }
253        assert!(g.is_read_only());
254    }
255
256    #[test]
257    fn read_only_flag_rejects_writes_on_standalone() {
258        let g = gate(true, ReplicationRole::Standalone);
259        let err = g.check(WriteKind::Dml).unwrap_err();
260        match err {
261            RedDBError::ReadOnly(msg) => assert!(msg.contains("read_only")),
262            other => panic!("expected ReadOnly, got {other:?}"),
263        }
264    }
265
266    #[test]
267    fn lease_not_held_rejects_writes_on_primary() {
268        let g = gate(false, ReplicationRole::Primary);
269        g.set_lease_state(LeaseGateState::NotHeld);
270        let err = g.check(WriteKind::Dml).unwrap_err();
271        match err {
272            RedDBError::ReadOnly(msg) => assert!(msg.contains("lease")),
273            other => panic!("expected ReadOnly, got {other:?}"),
274        }
275        assert!(g.is_read_only());
276    }
277
278    #[test]
279    fn lease_held_allows_writes_on_primary() {
280        let g = gate(false, ReplicationRole::Primary);
281        g.set_lease_state(LeaseGateState::Held);
282        assert!(g.check(WriteKind::Dml).is_ok());
283        assert!(!g.is_read_only());
284    }
285
286    #[test]
287    fn lease_state_transitions_return_previous() {
288        let g = gate(false, ReplicationRole::Primary);
289        assert_eq!(
290            g.set_lease_state(LeaseGateState::Held),
291            LeaseGateState::NotRequired
292        );
293        assert_eq!(
294            g.set_lease_state(LeaseGateState::NotHeld),
295            LeaseGateState::Held
296        );
297        assert_eq!(g.lease_state(), LeaseGateState::NotHeld);
298    }
299
300    #[test]
301    fn lease_loss_overrides_writable_read_only_flag() {
302        // Even with read_only=false, losing the lease must reject.
303        let g = gate(false, ReplicationRole::Primary);
304        g.set_lease_state(LeaseGateState::NotHeld);
305        let err = g.check(WriteKind::Ddl).unwrap_err();
306        match err {
307            RedDBError::ReadOnly(msg) => assert!(msg.contains("lease")),
308            other => panic!("expected ReadOnly, got {other:?}"),
309        }
310    }
311
312    #[test]
313    fn replica_role_overrides_missing_read_only_flag() {
314        let g = gate(
315            false,
316            ReplicationRole::Replica {
317                primary_addr: "http://primary:50051".into(),
318            },
319        );
320        let err = g.check(WriteKind::Dml).unwrap_err();
321        match err {
322            RedDBError::ReadOnly(msg) => assert!(msg.contains("replica")),
323            other => panic!("expected ReadOnly, got {other:?}"),
324        }
325    }
326}