1use 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
38pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum WriteKind {
51 Dml,
53 Ddl,
55 IndexBuild,
57 Maintenance,
59 Backup,
61 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#[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#[derive(Debug)]
132pub struct WriteGate {
133 read_only: AtomicBool,
138 role: ReplicationRole,
139 lease: AtomicU8,
140 lease_deadline: parking_lot::RwLock<Option<std::time::Instant>>,
141 auto_paused: AtomicBool,
146 last_archive_at_ms: AtomicU64,
152 pause_threshold_secs: AtomicU64,
156 flow: FlowController,
162 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 pub fn check(&self, kind: WriteKind) -> RedDBResult<()> {
209 self.check_consent(kind).map(|_| ())
210 }
211
212 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 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 pub fn is_manual_read_only(&self) -> bool {
278 self.read_only.load(Ordering::Acquire)
279 }
280
281 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 pub fn flow_control(&self) -> &FlowController {
296 &self.flow
297 }
298
299 pub fn is_flow_throttled(&self) -> bool {
303 self.flow.is_throttled()
304 }
305
306 pub fn set_read_only(&self, enabled: bool) -> bool {
314 self.read_only.swap(enabled, Ordering::AcqRel)
315 }
316
317 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 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 pub fn record_archive_success(&self, now_ms: u64) {
370 self.last_archive_at_ms.store(now_ms, Ordering::Release);
371 }
372
373 pub fn archive_pause_threshold_secs(&self) -> u64 {
376 self.pause_threshold_secs.load(Ordering::Acquire)
377 }
378
379 pub fn last_archive_at_ms(&self) -> u64 {
381 self.last_archive_at_ms.load(Ordering::Acquire)
382 }
383
384 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 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 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 #[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 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 g.configure_archive_lag_pause(60, 1_000_000);
916 assert!(!g.evaluate_archive_lag(1_000_000 + 30_000));
918 assert!(g.check(WriteKind::Dml).is_ok());
919
920 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 assert!(g.evaluate_archive_lag(1_000_000 + 120_000));
937 assert!(g.is_auto_paused());
938 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 let g = gate(true, ReplicationRole::Standalone);
951 g.configure_archive_lag_pause(60, 1_000_000);
952
953 assert!(!g.evaluate_archive_lag(1_000_000 + 120_000));
955 assert!(!g.is_auto_paused());
956 assert!(g.is_manual_read_only());
957 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 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 let g = gate(true, ReplicationRole::Standalone);
981 g.configure_archive_lag_pause(60, 1_000_000);
982 assert!(!g.evaluate_archive_lag(1_000_000 + 120_000));
984 g.set_read_only(false);
986 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 let prev = g.set_read_only(true);
998 assert!(!prev);
999 assert!(g.is_manual_read_only());
1000 assert!(g.is_auto_paused());
1001 g.set_read_only(false);
1003 assert!(g.is_auto_paused());
1004 assert!(g.check(WriteKind::Dml).is_err());
1005 }
1006
1007 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 assert!(g.check(WriteKind::Dml).is_ok());
1049 assert!(!g.is_flow_throttled());
1050
1051 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 assert!(!g.is_read_only());
1062
1063 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 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 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}