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