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 auto_paused: AtomicBool,
145 last_archive_at_ms: AtomicU64,
151 pause_threshold_secs: AtomicU64,
155 flow: FlowController,
161 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 pub fn check(&self, kind: WriteKind) -> RedDBResult<()> {
207 self.check_consent(kind).map(|_| ())
208 }
209
210 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 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 pub fn is_manual_read_only(&self) -> bool {
276 self.read_only.load(Ordering::Acquire)
277 }
278
279 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 pub fn flow_control(&self) -> &FlowController {
294 &self.flow
295 }
296
297 pub fn is_flow_throttled(&self) -> bool {
301 self.flow.is_throttled()
302 }
303
304 pub fn set_read_only(&self, enabled: bool) -> bool {
312 self.read_only.swap(enabled, Ordering::AcqRel)
313 }
314
315 pub fn lease_state(&self) -> LeaseGateState {
318 LeaseGateState::from_u8(self.lease.load(Ordering::Acquire))
319 }
320
321 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 pub fn record_archive_success(&self, now_ms: u64) {
343 self.last_archive_at_ms.store(now_ms, Ordering::Release);
344 }
345
346 pub fn archive_pause_threshold_secs(&self) -> u64 {
349 self.pause_threshold_secs.load(Ordering::Acquire)
350 }
351
352 pub fn last_archive_at_ms(&self) -> u64 {
354 self.last_archive_at_ms.load(Ordering::Acquire)
355 }
356
357 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 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 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 #[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 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 g.configure_archive_lag_pause(60, 1_000_000);
861 assert!(!g.evaluate_archive_lag(1_000_000 + 30_000));
863 assert!(g.check(WriteKind::Dml).is_ok());
864
865 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 assert!(g.evaluate_archive_lag(1_000_000 + 120_000));
882 assert!(g.is_auto_paused());
883 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 let g = gate(true, ReplicationRole::Standalone);
896 g.configure_archive_lag_pause(60, 1_000_000);
897
898 assert!(!g.evaluate_archive_lag(1_000_000 + 120_000));
900 assert!(!g.is_auto_paused());
901 assert!(g.is_manual_read_only());
902 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 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 let g = gate(true, ReplicationRole::Standalone);
926 g.configure_archive_lag_pause(60, 1_000_000);
927 assert!(!g.evaluate_archive_lag(1_000_000 + 120_000));
929 g.set_read_only(false);
931 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 let prev = g.set_read_only(true);
943 assert!(!prev);
944 assert!(g.is_manual_read_only());
945 assert!(g.is_auto_paused());
946 g.set_read_only(false);
948 assert!(g.is_auto_paused());
949 assert!(g.check(WriteKind::Dml).is_err());
950 }
951
952 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 assert!(g.check(WriteKind::Dml).is_ok());
993 assert!(!g.is_flow_throttled());
994
995 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 assert!(!g.is_read_only());
1006
1007 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 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 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}