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, AtomicU64, AtomicU8, Ordering};
25
26use crate::api::{RedDBError, RedDBOptions, RedDBResult};
27use crate::cluster::{
28    admit_durable_write, CollectionId, DurableWriteReject, LeasedOwner, NodeIdentity,
29    OwnershipEpoch, OwnershipLease, PlacementMetadata, RangeBounds, RangeId, RangeOwnership,
30    ShardKeyMode, ShardOwnershipCatalog, SupervisorTerm,
31};
32use crate::replication::cdc::RangeAuthority;
33use crate::replication::flow_control::{Admission, FlowController};
34use crate::replication::ReplicationRole;
35use crate::telemetry::operator_event::OperatorEvent;
36
37/// Env var that sets the flow-control soft target (in LSN records).
38/// `0` / unset disables write-admission throttling (the default).
39pub const FLOW_CONTROL_SOFT_TARGET_ENV: &str = "RED_REPLICATION_FLOW_CONTROL_SOFT_TARGET_LSN";
40
41const RESERVED_GLOBAL_SYSTEM_COLLECTION: &str = "system.global";
42const RESERVED_GLOBAL_SYSTEM_RANGE_ID: u64 = 0;
43const RESERVED_GLOBAL_SYSTEM_RANGE_KEY: &[u8] = b"reserved-global-system-range";
44const DEFAULT_PRIMARY_REPLICA_OWNER: &str = "CN=primary-replica-primary,O=reddb";
45
46/// Categorises the write so the rejection error can name a sensible
47/// surface in operator-facing logs without leaking internal call sites.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum WriteKind {
50    /// INSERT / UPDATE / DELETE on a user-visible collection.
51    Dml,
52    /// CREATE / DROP / ALTER TABLE, CREATE / DROP INDEX, etc.
53    Ddl,
54    /// Index build / rebuild outside a DDL statement (e.g. background reindex).
55    IndexBuild,
56    /// Reclaim / repair / retention sweeps that mutate state.
57    Maintenance,
58    /// Operator-triggered backup that mutates remote state.
59    Backup,
60    /// Serverless lifecycle endpoints that mutate state (attach / warmup
61    /// / reclaim).
62    Serverless,
63}
64
65impl WriteKind {
66    fn label(self) -> &'static str {
67        match self {
68            WriteKind::Dml => "DML",
69            WriteKind::Ddl => "DDL",
70            WriteKind::IndexBuild => "index build",
71            WriteKind::Maintenance => "maintenance",
72            WriteKind::Backup => "backup trigger",
73            WriteKind::Serverless => "serverless lifecycle",
74        }
75    }
76}
77
78/// Serverless writer-lease state wired through the gate.
79///
80/// `NotRequired` is the default — standalone, replica, and
81/// lease-disabled serverless deployments all share it. `Held` /
82/// `NotHeld` only matter for instances that opted into lease-fenced
83/// writes; the lease loop flips the value as it acquires / refreshes /
84/// loses the slot.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86#[repr(u8)]
87pub enum LeaseGateState {
88    NotRequired = 0,
89    Held = 1,
90    NotHeld = 2,
91}
92
93impl LeaseGateState {
94    fn from_u8(raw: u8) -> Self {
95        match raw {
96            1 => Self::Held,
97            2 => Self::NotHeld,
98            _ => Self::NotRequired,
99        }
100    }
101
102    pub fn label(self) -> &'static str {
103        match self {
104            Self::NotRequired => "not_required",
105            Self::Held => "held",
106            Self::NotHeld => "not_held",
107        }
108    }
109}
110
111/// Live policy for public-mutation surfaces.
112///
113/// `read_only` was originally a `bool` snapshot taken at runtime
114/// construction. PLAN.md Phase 4.3 promotes it to an `AtomicBool` so
115/// `POST /admin/readonly` can flip the policy without a restart. The
116/// `ReplicationRole` stays immutable — flipping a replica into a
117/// primary mid-process would need a full handshake (Phase 3 work in
118/// the data-safety plan), and shouldn't be a single-flag decision.
119#[derive(Debug)]
120pub struct WriteGate {
121    /// Operator-set read-only flag. Mutated by `POST /admin/readonly`
122    /// and by boot-time resolution (CLI/env/persisted state). Sticky:
123    /// the archive-lag auto-pause path (#519) never touches this — only
124    /// the operator can clear an operator-set pin.
125    read_only: AtomicBool,
126    role: ReplicationRole,
127    lease: AtomicU8,
128    /// Issue #519 — engine-managed graceful read-only triggered by
129    /// `REDDB_BACKUP_PAUSE_ON_LAG_SECS` when WAL archive lag exceeds
130    /// the threshold. Independent of `read_only` so the two precedences
131    /// (manual sticky, auto auto-resumes) cannot stomp each other.
132    auto_paused: AtomicBool,
133    /// Unix-ms timestamp of the last *successful* remote archive. `0`
134    /// means "never observed since boot"; the lag evaluator treats
135    /// that as "lag since boot" once it has been initialised by the
136    /// caller (typical pattern: stamp `now` at construction so the
137    /// instance gets a `threshold_secs` grace window).
138    last_archive_at_ms: AtomicU64,
139    /// Threshold from `REDDB_BACKUP_PAUSE_ON_LAG_SECS`. `0` = feature
140    /// disabled — `evaluate_archive_lag` short-circuits without
141    /// touching `auto_paused`.
142    pause_threshold_secs: AtomicU64,
143    /// Issue #826 — write-admission flow control keyed on in-quorum
144    /// replica lag. Independent of `read_only` / `auto_paused`: the
145    /// throttle engages and releases automatically as a quorum member
146    /// lags and recovers, and an operator read-only pin never clears it.
147    /// Disabled by default (soft target `0`).
148    flow: FlowController,
149    /// ADR 0037/0058 durable-write ownership admission for range-owned state.
150    /// `None` for standalone/single-node deployments; primary-replica primaries
151    /// install the reserved global system range gate.
152    ownership: parking_lot::RwLock<Option<OwnershipAdmissionGate>>,
153}
154
155impl WriteGate {
156    pub fn from_options(options: &RedDBOptions) -> Self {
157        let soft_target = std::env::var(FLOW_CONTROL_SOFT_TARGET_ENV)
158            .ok()
159            .and_then(|raw| raw.trim().parse::<u64>().ok())
160            .unwrap_or(0);
161        let ownership = match options.replication.role {
162            ReplicationRole::Primary | ReplicationRole::Replica { .. } => {
163                Some(OwnershipAdmissionGate::primary_replica(
164                    DEFAULT_PRIMARY_REPLICA_OWNER,
165                    options.replication.term,
166                ))
167            }
168            ReplicationRole::Standalone => None,
169        };
170        Self {
171            read_only: AtomicBool::new(options.read_only),
172            role: options.replication.role.clone(),
173            lease: AtomicU8::new(LeaseGateState::NotRequired as u8),
174            auto_paused: AtomicBool::new(false),
175            last_archive_at_ms: AtomicU64::new(0),
176            pause_threshold_secs: AtomicU64::new(0),
177            flow: FlowController::new(soft_target, options.replication.quorum.clone()),
178            ownership: parking_lot::RwLock::new(ownership),
179        }
180    }
181
182    /// Returns `Ok(())` if the public surface is allowed to perform `kind`.
183    /// Returns `RedDBError::ReadOnly` otherwise.
184    ///
185    /// Reasoning order is intentional:
186    /// 1. Replica role — a replica booted with `read_only = false`
187    ///    must still reject; this is a structural property.
188    /// 2. Lease lost — the strongest serverless correctness signal.
189    ///    A writer that lost its lease must stop *immediately*; running
190    ///    while another holder has been promoted causes split-brain.
191    /// 3. Operator read-only flag — explicit /admin/readonly toggle
192    ///    or boot-time pin; lower priority than lease loss because the
193    ///    operator can revoke it without external coordination.
194    pub fn check(&self, kind: WriteKind) -> RedDBResult<()> {
195        self.check_consent(kind).map(|_| ())
196    }
197
198    /// Same as `check` but on success returns a sealed
199    /// `WriteConsent` token. Mutating port methods that take
200    /// `&OperationContext` demand `ctx.write_consent.is_some()`;
201    /// the only way to mint such a token is to call this method,
202    /// so forgetting to consult the gate becomes a structural
203    /// property — not a discipline question.
204    pub fn check_consent(&self, kind: WriteKind) -> RedDBResult<crate::application::WriteConsent> {
205        if matches!(self.role, ReplicationRole::Replica { .. }) {
206            return Err(RedDBError::ReadOnly(format!(
207                "instance is a replica — {} rejected on public surface",
208                kind.label()
209            )));
210        }
211        if matches!(self.lease_state(), LeaseGateState::NotHeld) {
212            return Err(RedDBError::ReadOnly(format!(
213                "writer lease not held — {} rejected (serverless fence)",
214                kind.label()
215            )));
216        }
217        self.check_ownership(kind)?;
218        if self.read_only.load(Ordering::Acquire) {
219            return Err(RedDBError::ReadOnly(format!(
220                "instance is configured read_only — {} rejected",
221                kind.label()
222            )));
223        }
224        if self.auto_paused.load(Ordering::Acquire) {
225            return Err(RedDBError::ReadOnly(format!(
226                "instance is paused — WAL archive lag exceeded threshold — {} rejected",
227                kind.label()
228            )));
229        }
230        // Issue #826 — write-admission flow control. Lowest-precedence
231        // gate: only consulted once the structural / operator / archive
232        // checks pass. Throttles when an in-quorum replica's lag exceeds
233        // the soft target; releases automatically as it catches up.
234        if matches!(self.flow.try_admit(), Admission::Throttled) {
235            return Err(RedDBError::ReadOnly(format!(
236                "write admission throttled — in-quorum replica lag exceeded soft target ({} records) — {} rejected",
237                self.flow.soft_target_lsn(),
238                kind.label()
239            )));
240        }
241        Ok(crate::application::WriteConsent {
242            kind,
243            _seal: crate::application::WriteConsentSeal::new(),
244        })
245    }
246
247    pub fn is_read_only(&self) -> bool {
248        self.read_only.load(Ordering::Acquire)
249            || self.auto_paused.load(Ordering::Acquire)
250            || matches!(self.role, ReplicationRole::Replica { .. })
251            || matches!(self.lease_state(), LeaseGateState::NotHeld)
252            || self
253                .ownership
254                .read()
255                .as_ref()
256                .is_some_and(|gate| gate.is_fenced())
257    }
258
259    /// Whether the operator explicitly pinned this instance read-only
260    /// (via boot config or `POST /admin/readonly`). Distinct from
261    /// [`is_read_only`] which also returns `true` for structural
262    /// reasons (replica role, lease lost, archive-lag pause).
263    pub fn is_manual_read_only(&self) -> bool {
264        self.read_only.load(Ordering::Acquire)
265    }
266
267    /// Whether the engine-managed archive-lag pause (#519) is
268    /// currently active. Mutually independent of [`is_manual_read_only`]
269    /// so callers like `/backup/status` can report both.
270    pub fn is_auto_paused(&self) -> bool {
271        self.auto_paused.load(Ordering::Acquire)
272    }
273
274    pub fn role(&self) -> &ReplicationRole {
275        &self.role
276    }
277
278    /// Issue #826 — borrow the write-admission flow controller. Callers
279    /// drive `observe()` from the primary's replica registry and read
280    /// throttle state for `/metrics`.
281    pub fn flow_control(&self) -> &FlowController {
282        &self.flow
283    }
284
285    /// Whether write admission is currently throttled by in-quorum
286    /// replica lag (issue #826). Distinct from [`is_read_only`] — a
287    /// throttle is transient back-pressure, not a read-only posture.
288    pub fn is_flow_throttled(&self) -> bool {
289        self.flow.is_throttled()
290    }
291
292    /// PLAN.md Phase 4.3 — dynamic read-only toggle. Flipping a
293    /// replica back to writable here is a no-op for `check()` because
294    /// the role check fires first; the operator must change the
295    /// replication role through a separate, audited path.
296    ///
297    /// Returns the previous read_only value so callers can detect
298    /// idempotent calls (toggle to the same value = no work to do).
299    pub fn set_read_only(&self, enabled: bool) -> bool {
300        self.read_only.swap(enabled, Ordering::AcqRel)
301    }
302
303    /// Current writer-lease gate state. `NotRequired` for standalone,
304    /// replica, and lease-disabled serverless instances.
305    pub fn lease_state(&self) -> LeaseGateState {
306        LeaseGateState::from_u8(self.lease.load(Ordering::Acquire))
307    }
308
309    /// Issue #519 — install the archive-lag pause threshold and the
310    /// baseline "last archive observed at" stamp. Threshold `0`
311    /// disables auto-pause; subsequent `record_archive_success` /
312    /// `evaluate_archive_lag` calls then become no-ops.
313    ///
314    /// Idempotent: callers should invoke once during startup after
315    /// parsing `REDDB_BACKUP_PAUSE_ON_LAG_SECS`. Stamping `last_archive_at_ms`
316    /// to "now" at construction grants a `threshold_secs` grace
317    /// window before the first auto-pause can fire — without it, a
318    /// freshly-booted instance with a never-archived WAL would flip
319    /// to read-only on the first poll.
320    pub fn configure_archive_lag_pause(&self, threshold_secs: u64, baseline_ms: u64) {
321        self.pause_threshold_secs
322            .store(threshold_secs, Ordering::Release);
323        self.last_archive_at_ms
324            .store(baseline_ms, Ordering::Release);
325    }
326
327    /// Stamp `last_archive_at_ms` after a successful remote archive.
328    /// Called by the WAL-archive task wrapper in `service_cli` after
329    /// `runtime.trigger_backup()` returns `Ok`.
330    pub fn record_archive_success(&self, now_ms: u64) {
331        self.last_archive_at_ms.store(now_ms, Ordering::Release);
332    }
333
334    /// Current archive-lag threshold in seconds. `0` means the
335    /// feature is disabled.
336    pub fn archive_pause_threshold_secs(&self) -> u64 {
337        self.pause_threshold_secs.load(Ordering::Acquire)
338    }
339
340    /// Last archive observation timestamp (unix ms).
341    pub fn last_archive_at_ms(&self) -> u64 {
342        self.last_archive_at_ms.load(Ordering::Acquire)
343    }
344
345    /// Re-evaluate the archive-lag state. Returns the resulting
346    /// `auto_paused` value.
347    ///
348    /// Semantics (issue #519):
349    /// * Threshold `0` → feature disabled, returns current state
350    ///   without writing.
351    /// * Manual read-only is **sticky** — when [`is_manual_read_only`]
352    ///   is true, this method never modifies `auto_paused`. The
353    ///   operator must clear the manual pin first; only then does the
354    ///   auto-path take over again on the next tick.
355    /// * Lag > threshold and manual=false → set `auto_paused = true`.
356    /// * Lag <= threshold and `auto_paused = true` → clear it
357    ///   (auto-resume). If `auto_paused` was already false, no-op.
358    pub fn evaluate_archive_lag(&self, now_ms: u64) -> bool {
359        let threshold = self.pause_threshold_secs.load(Ordering::Acquire);
360        if threshold == 0 {
361            return self.auto_paused.load(Ordering::Acquire);
362        }
363        if self.read_only.load(Ordering::Acquire) {
364            return self.auto_paused.load(Ordering::Acquire);
365        }
366        let last_ms = self.last_archive_at_ms.load(Ordering::Acquire);
367        let lag_secs = now_ms.saturating_sub(last_ms) / 1000;
368        let should_pause = lag_secs > threshold;
369        self.auto_paused.store(should_pause, Ordering::Release);
370        should_pause
371    }
372
373    /// Flip the lease gate state. Only `LeaseLifecycle` should call
374    /// this — other callers must go through the lifecycle so the
375    /// gate flip and the corresponding `lease/*` audit record
376    /// stay paired.
377    ///
378    /// Returns the previous state so the caller can detect idempotent
379    /// transitions and avoid spamming audit / metrics.
380    pub(crate) fn set_lease_state(&self, state: LeaseGateState) -> LeaseGateState {
381        LeaseGateState::from_u8(self.lease.swap(state as u8, Ordering::AcqRel))
382    }
383
384    fn check_ownership(&self, kind: WriteKind) -> RedDBResult<()> {
385        let Some(gate) = self.ownership.read().as_ref().cloned() else {
386            return Ok(());
387        };
388        match gate.admit() {
389            Ok(()) => Ok(()),
390            Err(reject) => {
391                let detail = OwnershipFenceDetail::from_reject(&reject);
392                OperatorEvent::OwnershipFenced {
393                    reason: detail.reason.clone(),
394                    ownership_epoch: detail.current_epoch,
395                    range_identity: detail.range_identity.clone(),
396                }
397                .emit_global();
398                Err(RedDBError::ReadOnly(format!(
399                    "ownership_fenced reason={} current_epoch={} range={} — {} rejected below routing",
400                    detail.reason,
401                    detail.current_epoch,
402                    detail.range_identity,
403                    kind.label()
404                )))
405            }
406        }
407    }
408
409    pub fn promote_primary_replica_owner(
410        &self,
411        new_owner_subject: &str,
412        new_term: u64,
413    ) -> RedDBResult<()> {
414        let new_owner = node_identity(new_owner_subject)?;
415        let mut guard = self.ownership.write();
416        let Some(gate) = guard.as_mut() else {
417            return Ok(());
418        };
419        gate.promote_to(new_owner, new_term)
420    }
421
422    pub fn primary_replica_range_authority(&self) -> Option<RangeAuthority> {
423        self.ownership
424            .read()
425            .as_ref()
426            .map(OwnershipAdmissionGate::range_authority)
427    }
428}
429
430#[derive(Debug, Clone)]
431struct OwnershipAdmissionGate {
432    catalog: ShardOwnershipCatalog,
433    holder: LeasedOwner,
434    node: NodeIdentity,
435    collection: CollectionId,
436    range_id: RangeId,
437    key: Vec<u8>,
438    current_term: SupervisorTerm,
439    now_ms: u64,
440}
441
442impl OwnershipAdmissionGate {
443    fn primary_replica(owner_subject: &str, term: u64) -> Self {
444        let owner = node_identity(owner_subject).expect("static primary-replica owner identity");
445        let collection =
446            CollectionId::new(RESERVED_GLOBAL_SYSTEM_COLLECTION).expect("static collection id");
447        let range_id = RangeId::new(RESERVED_GLOBAL_SYSTEM_RANGE_ID);
448        let range = RangeOwnership::establish(
449            collection.clone(),
450            range_id,
451            ShardKeyMode::Ordered,
452            RangeBounds::full(),
453            owner.clone(),
454            Vec::<NodeIdentity>::new(),
455            PlacementMetadata::with_replication_factor(1),
456        );
457        let current_term = SupervisorTerm::new(term);
458        let lease = OwnershipLease::grant(
459            current_term,
460            collection.clone(),
461            range_id,
462            owner.clone(),
463            OwnershipEpoch::initial(),
464            0,
465            u64::MAX,
466        );
467        let mut catalog = ShardOwnershipCatalog::new();
468        catalog
469            .apply_update(range)
470            .expect("initial reserved global system range is valid");
471        Self {
472            catalog,
473            holder: LeasedOwner::with_lease(lease),
474            node: owner,
475            collection,
476            range_id,
477            key: RESERVED_GLOBAL_SYSTEM_RANGE_KEY.to_vec(),
478            current_term,
479            now_ms: 0,
480        }
481    }
482
483    fn admit(&self) -> Result<(), DurableWriteReject> {
484        admit_durable_write(
485            &self.catalog,
486            &self.holder,
487            &self.node,
488            &self.collection,
489            &self.key,
490            self.current_term,
491            self.now_ms,
492        )
493        .map(|_| ())
494    }
495
496    fn is_fenced(&self) -> bool {
497        self.admit().is_err()
498    }
499
500    fn range_authority(&self) -> RangeAuthority {
501        let epoch = self
502            .catalog
503            .range(&self.collection, self.range_id)
504            .map(|range| range.epoch().value())
505            .unwrap_or(0);
506        RangeAuthority {
507            range_id: self.range_id.value(),
508            min_term: self.current_term.value(),
509            min_ownership_epoch: epoch,
510        }
511    }
512
513    fn promote_to(&mut self, new_owner: NodeIdentity, new_term: u64) -> RedDBResult<()> {
514        let current = self
515            .catalog
516            .range(&self.collection, self.range_id)
517            .ok_or_else(|| RedDBError::Internal("reserved global system range missing".into()))?
518            .clone();
519        self.catalog
520            .apply_update(current.transfer_to(new_owner, [self.node.clone()]))
521            .map_err(|err| RedDBError::Catalog(err.to_string()))?;
522        self.current_term = SupervisorTerm::new(new_term);
523        Ok(())
524    }
525}
526
527struct OwnershipFenceDetail {
528    reason: String,
529    current_epoch: u64,
530    range_identity: String,
531}
532
533impl OwnershipFenceDetail {
534    fn from_reject(reject: &DurableWriteReject) -> Self {
535        match reject {
536            DurableWriteReject::NoRange { collection } => Self {
537                reason: "no_range".to_string(),
538                current_epoch: 0,
539                range_identity: collection.to_string(),
540            },
541            DurableWriteReject::StaleOwnership {
542                collection,
543                range_id,
544                current_epoch,
545                ..
546            } => Self {
547                reason: "stale_ownership".to_string(),
548                current_epoch: current_epoch.value(),
549                range_identity: format!("{collection}/{range_id}"),
550            },
551            DurableWriteReject::NotOwner {
552                collection,
553                range_id,
554                epoch,
555                ..
556            } => Self {
557                reason: "not_owner".to_string(),
558                current_epoch: epoch.value(),
559                range_identity: format!("{collection}/{range_id}"),
560            },
561            DurableWriteReject::Fenced {
562                collection,
563                range_id,
564                reason,
565            } => Self {
566                reason: fence_reason_label(reason).to_string(),
567                current_epoch: 0,
568                range_identity: format!("{collection}/{range_id}"),
569            },
570        }
571    }
572}
573
574fn fence_reason_label(reason: &crate::cluster::FenceReason) -> &'static str {
575    match reason {
576        crate::cluster::FenceReason::Unleased => "unleased",
577        crate::cluster::FenceReason::Revoked => "revoked",
578        crate::cluster::FenceReason::TermSuperseded { .. } => "term_superseded",
579        crate::cluster::FenceReason::EpochSuperseded { .. } => "epoch_superseded",
580        crate::cluster::FenceReason::Expired { .. } => "expired",
581    }
582}
583
584fn node_identity(subject: &str) -> RedDBResult<NodeIdentity> {
585    NodeIdentity::from_certificate_subject(subject)
586        .map_err(|err| RedDBError::InvalidConfig(err.to_string()))
587}
588
589#[cfg(test)]
590impl WriteGate {
591    fn install_primary_replica_ownership_gate_for_test(&self, owner_subject: &str, term: u64) {
592        *self.ownership.write() =
593            Some(OwnershipAdmissionGate::primary_replica(owner_subject, term));
594    }
595
596    fn promote_primary_replica_owner_for_test(&self, new_owner_subject: &str, new_term: u64) {
597        self.promote_primary_replica_owner(new_owner_subject, new_term)
598            .expect("test promotion succeeds");
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    fn gate(read_only: bool, role: ReplicationRole) -> WriteGate {
607        WriteGate {
608            read_only: AtomicBool::new(read_only),
609            role,
610            lease: AtomicU8::new(LeaseGateState::NotRequired as u8),
611            auto_paused: AtomicBool::new(false),
612            last_archive_at_ms: AtomicU64::new(0),
613            pause_threshold_secs: AtomicU64::new(0),
614            flow: FlowController::disabled(),
615            ownership: parking_lot::RwLock::new(None),
616        }
617    }
618
619    #[test]
620    fn standalone_allows_writes() {
621        let g = gate(false, ReplicationRole::Standalone);
622        assert!(g.check(WriteKind::Dml).is_ok());
623        assert!(g.check(WriteKind::Ddl).is_ok());
624        assert!(!g.is_read_only());
625    }
626
627    #[test]
628    fn primary_allows_writes() {
629        let g = gate(false, ReplicationRole::Primary);
630        assert!(g.check(WriteKind::Dml).is_ok());
631        assert!(!g.is_read_only());
632    }
633
634    #[test]
635    fn replica_rejects_every_kind() {
636        let g = gate(
637            true,
638            ReplicationRole::Replica {
639                primary_addr: "http://primary:55055".into(),
640            },
641        );
642        for kind in [
643            WriteKind::Dml,
644            WriteKind::Ddl,
645            WriteKind::IndexBuild,
646            WriteKind::Maintenance,
647            WriteKind::Backup,
648            WriteKind::Serverless,
649        ] {
650            let err = g.check(kind).unwrap_err();
651            match err {
652                RedDBError::ReadOnly(msg) => assert!(msg.contains("replica")),
653                other => panic!("expected ReadOnly, got {other:?}"),
654            }
655        }
656        assert!(g.is_read_only());
657    }
658
659    #[test]
660    fn read_only_flag_rejects_writes_on_standalone() {
661        let g = gate(true, ReplicationRole::Standalone);
662        let err = g.check(WriteKind::Dml).unwrap_err();
663        match err {
664            RedDBError::ReadOnly(msg) => assert!(msg.contains("read_only")),
665            other => panic!("expected ReadOnly, got {other:?}"),
666        }
667    }
668
669    #[test]
670    fn lease_not_held_rejects_writes_on_primary() {
671        let g = gate(false, ReplicationRole::Primary);
672        g.set_lease_state(LeaseGateState::NotHeld);
673        let err = g.check(WriteKind::Dml).unwrap_err();
674        match err {
675            RedDBError::ReadOnly(msg) => assert!(msg.contains("lease")),
676            other => panic!("expected ReadOnly, got {other:?}"),
677        }
678        assert!(g.is_read_only());
679    }
680
681    #[test]
682    fn lease_held_allows_writes_on_primary() {
683        let g = gate(false, ReplicationRole::Primary);
684        g.set_lease_state(LeaseGateState::Held);
685        assert!(g.check(WriteKind::Dml).is_ok());
686        assert!(!g.is_read_only());
687    }
688
689    #[test]
690    fn lease_state_transitions_return_previous() {
691        let g = gate(false, ReplicationRole::Primary);
692        assert_eq!(
693            g.set_lease_state(LeaseGateState::Held),
694            LeaseGateState::NotRequired
695        );
696        assert_eq!(
697            g.set_lease_state(LeaseGateState::NotHeld),
698            LeaseGateState::Held
699        );
700        assert_eq!(g.lease_state(), LeaseGateState::NotHeld);
701    }
702
703    #[test]
704    fn lease_loss_overrides_writable_read_only_flag() {
705        // Even with read_only=false, losing the lease must reject.
706        let g = gate(false, ReplicationRole::Primary);
707        g.set_lease_state(LeaseGateState::NotHeld);
708        let err = g.check(WriteKind::Ddl).unwrap_err();
709        match err {
710            RedDBError::ReadOnly(msg) => assert!(msg.contains("lease")),
711            other => panic!("expected ReadOnly, got {other:?}"),
712        }
713    }
714
715    // ---------------------------------------------------------------
716    // Issue #519 — graceful read-only mode when WAL archive lag
717    // exceeds REDDB_BACKUP_PAUSE_ON_LAG_SECS.
718    // ---------------------------------------------------------------
719
720    #[test]
721    fn archive_lag_disabled_threshold_is_noop() {
722        let g = gate(false, ReplicationRole::Standalone);
723        g.configure_archive_lag_pause(0, 1_000);
724        // Even with an ancient timestamp, threshold=0 must not pause.
725        assert!(!g.evaluate_archive_lag(10_000_000_000));
726        assert!(!g.is_auto_paused());
727        assert!(g.check(WriteKind::Dml).is_ok());
728    }
729
730    #[test]
731    fn archive_lag_triggers_auto_pause_past_threshold() {
732        let g = gate(false, ReplicationRole::Standalone);
733        // Last archive at t=1_000_000ms; threshold = 60s.
734        g.configure_archive_lag_pause(60, 1_000_000);
735        // 30s later — still under threshold.
736        assert!(!g.evaluate_archive_lag(1_000_000 + 30_000));
737        assert!(g.check(WriteKind::Dml).is_ok());
738
739        // 120s later — over threshold; must auto-pause.
740        assert!(g.evaluate_archive_lag(1_000_000 + 120_000));
741        assert!(g.is_auto_paused());
742        let err = g.check(WriteKind::Dml).unwrap_err();
743        match err {
744            RedDBError::ReadOnly(msg) => assert!(msg.contains("WAL archive lag"), "{msg}"),
745            other => panic!("expected ReadOnly, got {other:?}"),
746        }
747        assert!(g.is_read_only());
748    }
749
750    #[test]
751    fn archive_lag_auto_resume_after_recovery() {
752        let g = gate(false, ReplicationRole::Standalone);
753        g.configure_archive_lag_pause(60, 1_000_000);
754        // Trip the auto-pause.
755        assert!(g.evaluate_archive_lag(1_000_000 + 120_000));
756        assert!(g.is_auto_paused());
757        // Archiver catches up — stamp success and re-evaluate.
758        g.record_archive_success(1_000_000 + 130_000);
759        assert!(!g.evaluate_archive_lag(1_000_000 + 130_000));
760        assert!(!g.is_auto_paused());
761        assert!(g.check(WriteKind::Dml).is_ok());
762    }
763
764    #[test]
765    fn manual_read_only_blocks_auto_pause_writes_and_is_sticky() {
766        // Operator pinned read-only *before* lag condition. The
767        // auto-pause path must be a no-op while manual is set, and
768        // archive recovery must NOT auto-clear the manual pin.
769        let g = gate(true, ReplicationRole::Standalone);
770        g.configure_archive_lag_pause(60, 1_000_000);
771
772        // Lag past threshold; but manual is set so auto stays false.
773        assert!(!g.evaluate_archive_lag(1_000_000 + 120_000));
774        assert!(!g.is_auto_paused());
775        assert!(g.is_manual_read_only());
776        // Writes still rejected — for the manual reason.
777        let err = g.check(WriteKind::Dml).unwrap_err();
778        match err {
779            RedDBError::ReadOnly(msg) => {
780                assert!(msg.contains("read_only"), "{msg}");
781                assert!(!msg.contains("WAL archive lag"), "{msg}");
782            }
783            other => panic!("expected ReadOnly, got {other:?}"),
784        }
785
786        // Archiver recovers; re-evaluate. Manual still set ⇒ auto stays false,
787        // manual stays true ⇒ instance stays read-only by operator intent.
788        g.record_archive_success(1_000_000 + 130_000);
789        assert!(!g.evaluate_archive_lag(1_000_000 + 130_000));
790        assert!(g.is_manual_read_only(), "manual must stay set");
791        assert!(!g.is_auto_paused());
792        assert!(g.check(WriteKind::Dml).is_err());
793    }
794
795    #[test]
796    fn manual_clearing_resumes_auto_evaluation() {
797        // Manual was set; operator clears it; lag is still bad.
798        // Next evaluation must auto-pause.
799        let g = gate(true, ReplicationRole::Standalone);
800        g.configure_archive_lag_pause(60, 1_000_000);
801        // No-op while manual.
802        assert!(!g.evaluate_archive_lag(1_000_000 + 120_000));
803        // Operator unsets manual.
804        g.set_read_only(false);
805        // Now the lag condition must fire.
806        assert!(g.evaluate_archive_lag(1_000_000 + 120_000));
807        assert!(g.is_auto_paused());
808    }
809
810    #[test]
811    fn archive_lag_pause_state_independent_from_manual_flag() {
812        let g = gate(false, ReplicationRole::Standalone);
813        g.configure_archive_lag_pause(60, 1_000_000);
814        assert!(g.evaluate_archive_lag(1_000_000 + 120_000));
815        // Operator separately pins manual on top; still both true.
816        let prev = g.set_read_only(true);
817        assert!(!prev);
818        assert!(g.is_manual_read_only());
819        assert!(g.is_auto_paused());
820        // Operator clears manual; auto pause survives.
821        g.set_read_only(false);
822        assert!(g.is_auto_paused());
823        assert!(g.check(WriteKind::Dml).is_err());
824    }
825
826    // ---------------------------------------------------------------
827    // Issue #826 — write-admission flow control on in-quorum replica lag.
828    // ---------------------------------------------------------------
829
830    fn gate_with_flow(soft_target: u64, quorum: crate::replication::QuorumConfig) -> WriteGate {
831        WriteGate {
832            read_only: AtomicBool::new(false),
833            role: ReplicationRole::Primary,
834            lease: AtomicU8::new(LeaseGateState::NotRequired as u8),
835            auto_paused: AtomicBool::new(false),
836            last_archive_at_ms: AtomicU64::new(0),
837            pause_threshold_secs: AtomicU64::new(0),
838            flow: FlowController::new(soft_target, quorum),
839            ownership: parking_lot::RwLock::new(None),
840        }
841    }
842
843    fn flow_replica(
844        id: &str,
845        region: Option<&str>,
846        last_acked_lsn: u64,
847    ) -> crate::replication::primary::ReplicaState {
848        crate::replication::primary::ReplicaState {
849            id: id.to_string(),
850            last_acked_lsn,
851            last_sent_lsn: last_acked_lsn,
852            last_durable_lsn: last_acked_lsn,
853            apply_error_count: 0,
854            divergence_count: 0,
855            connected_at_unix_ms: 0,
856            last_seen_at_unix_ms: 0,
857            region: region.map(String::from),
858            rebootstrapping: false,
859        }
860    }
861
862    #[test]
863    fn flow_throttle_rejects_writes_when_in_quorum_replica_lags_and_releases() {
864        let g = gate_with_flow(100, crate::replication::QuorumConfig::sync(1));
865        // Healthy: no observation yet → writes admitted.
866        assert!(g.check(WriteKind::Dml).is_ok());
867        assert!(!g.is_flow_throttled());
868
869        // In-quorum replica lags 150 > soft target 100 → throttle engages.
870        g.flow_control()
871            .observe(&[flow_replica("r1", Some("us"), 350)], 500);
872        assert!(g.is_flow_throttled());
873        let err = g.check(WriteKind::Dml).unwrap_err();
874        match err {
875            RedDBError::ReadOnly(msg) => assert!(msg.contains("throttled"), "{msg}"),
876            other => panic!("expected ReadOnly throttle, got {other:?}"),
877        }
878        // Throttle is not the read-only posture.
879        assert!(!g.is_read_only());
880
881        // Replica catches up (lag 50 <= 100) → throttle releases.
882        g.flow_control()
883            .observe(&[flow_replica("r1", Some("us"), 450)], 500);
884        assert!(!g.is_flow_throttled());
885        assert!(g.check(WriteKind::Dml).is_ok());
886    }
887
888    #[test]
889    fn flow_throttle_excludes_async_read_replica() {
890        // Regions quorum requires "us"; an async read-replica in "ap"
891        // lags hugely but must never engage throttling.
892        let g = gate_with_flow(100, crate::replication::QuorumConfig::regions(["us"]));
893        g.flow_control().observe(
894            &[
895                flow_replica("in-quorum-us", Some("us"), 500),
896                flow_replica("async-ap", Some("ap"), 0),
897            ],
898            500,
899        );
900        assert!(!g.is_flow_throttled());
901        assert!(g.check(WriteKind::Dml).is_ok());
902    }
903
904    #[test]
905    fn flow_throttle_disabled_by_default() {
906        let g = gate(false, ReplicationRole::Primary);
907        // Even a huge lag observation cannot throttle a disabled controller.
908        g.flow_control()
909            .observe(&[flow_replica("r1", Some("us"), 0)], 1_000_000);
910        assert!(!g.is_flow_throttled());
911        assert!(g.check(WriteKind::Dml).is_ok());
912    }
913
914    #[test]
915    fn replica_role_overrides_missing_read_only_flag() {
916        let g = gate(
917            false,
918            ReplicationRole::Replica {
919                primary_addr: "http://primary:55055".into(),
920            },
921        );
922        let err = g.check(WriteKind::Dml).unwrap_err();
923        match err {
924            RedDBError::ReadOnly(msg) => assert!(msg.contains("replica")),
925            other => panic!("expected ReadOnly, got {other:?}"),
926        }
927    }
928
929    #[test]
930    fn replica_role_carries_apply_authority_but_stays_public_read_only() {
931        let options = RedDBOptions::in_memory().with_replication(
932            crate::replication::ReplicationConfig::replica("http://primary:55055").with_term(8),
933        );
934        let g = WriteGate::from_options(&options);
935
936        let authority = g
937            .primary_replica_range_authority()
938            .expect("replica apply authority");
939        assert_eq!(authority.range_id, RESERVED_GLOBAL_SYSTEM_RANGE_ID);
940        assert_eq!(authority.min_term, 8);
941        assert_eq!(authority.min_ownership_epoch, 1);
942
943        let err = g.check(WriteKind::Dml).unwrap_err();
944        match err {
945            RedDBError::ReadOnly(msg) => assert!(msg.contains("replica"), "{msg}"),
946            other => panic!("expected replica read-only, got {other:?}"),
947        }
948    }
949
950    #[test]
951    fn ownership_admission_fences_deposed_primary() {
952        let g = gate(false, ReplicationRole::Primary);
953        g.install_primary_replica_ownership_gate_for_test("CN=node-a,O=reddb", 7);
954
955        assert!(g.check(WriteKind::Dml).is_ok());
956
957        g.promote_primary_replica_owner_for_test("CN=node-b,O=reddb", 8);
958        let err = g.check(WriteKind::Dml).unwrap_err();
959        match err {
960            RedDBError::ReadOnly(msg) => {
961                assert!(msg.contains("ownership_fenced"), "{msg}");
962                assert!(msg.contains("reason=stale_ownership"), "{msg}");
963                assert!(msg.contains("current_epoch=2"), "{msg}");
964                assert!(msg.contains("range=system.global/0"), "{msg}");
965            }
966            other => panic!("expected ownership fencing ReadOnly, got {other:?}"),
967        }
968    }
969
970    #[test]
971    fn primary_replica_authority_reports_current_term_epoch_and_range() {
972        let g = gate(false, ReplicationRole::Primary);
973        g.install_primary_replica_ownership_gate_for_test("CN=node-a,O=reddb", 7);
974        g.promote_primary_replica_owner_for_test("CN=node-b,O=reddb", 8);
975
976        let authority = g
977            .primary_replica_range_authority()
978            .expect("primary-replica authority");
979        assert_eq!(authority.range_id, RESERVED_GLOBAL_SYSTEM_RANGE_ID);
980        assert_eq!(authority.min_term, 8);
981        assert_eq!(authority.min_ownership_epoch, 2);
982    }
983}