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