use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering};
use crate::api::{RedDBError, RedDBOptions, RedDBResult};
use crate::cluster::{
admit_durable_write, CollectionId, DurableWriteReject, LeasedOwner, NodeIdentity,
OwnershipEpoch, OwnershipLease, PlacementMetadata, RangeBounds, RangeId, RangeOwnership,
ShardKeyMode, ShardOwnershipCatalog, SupervisorTerm,
};
use crate::replication::cdc::RangeAuthority;
use crate::replication::flow_control::{Admission, FlowController};
use crate::replication::ReplicationRole;
use crate::telemetry::operator_event::OperatorEvent;
pub const FLOW_CONTROL_SOFT_TARGET_ENV: &str = "RED_REPLICATION_FLOW_CONTROL_SOFT_TARGET_LSN";
const RESERVED_GLOBAL_SYSTEM_COLLECTION: &str = "system.global";
const RESERVED_GLOBAL_SYSTEM_RANGE_ID: u64 = 0;
const RESERVED_GLOBAL_SYSTEM_RANGE_KEY: &[u8] = b"reserved-global-system-range";
const DEFAULT_PRIMARY_REPLICA_OWNER: &str = "CN=primary-replica-primary,O=reddb";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteKind {
Dml,
Ddl,
IndexBuild,
Maintenance,
Backup,
Serverless,
}
impl WriteKind {
fn label(self) -> &'static str {
match self {
WriteKind::Dml => "DML",
WriteKind::Ddl => "DDL",
WriteKind::IndexBuild => "index build",
WriteKind::Maintenance => "maintenance",
WriteKind::Backup => "backup trigger",
WriteKind::Serverless => "serverless lifecycle",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum LeaseGateState {
NotRequired = 0,
Held = 1,
NotHeld = 2,
}
impl LeaseGateState {
fn from_u8(raw: u8) -> Self {
match raw {
1 => Self::Held,
2 => Self::NotHeld,
_ => Self::NotRequired,
}
}
pub fn label(self) -> &'static str {
match self {
Self::NotRequired => "not_required",
Self::Held => "held",
Self::NotHeld => "not_held",
}
}
}
#[derive(Debug)]
pub struct WriteGate {
read_only: AtomicBool,
role: ReplicationRole,
lease: AtomicU8,
auto_paused: AtomicBool,
last_archive_at_ms: AtomicU64,
pause_threshold_secs: AtomicU64,
flow: FlowController,
ownership: parking_lot::RwLock<Option<OwnershipAdmissionGate>>,
}
impl WriteGate {
pub fn from_options(options: &RedDBOptions) -> Self {
let soft_target = std::env::var(FLOW_CONTROL_SOFT_TARGET_ENV)
.ok()
.and_then(|raw| raw.trim().parse::<u64>().ok())
.unwrap_or(0);
let ownership = match options.replication.role {
ReplicationRole::Primary | ReplicationRole::Replica { .. } => {
Some(OwnershipAdmissionGate::primary_replica(
DEFAULT_PRIMARY_REPLICA_OWNER,
options.replication.term,
))
}
ReplicationRole::Standalone => None,
};
Self {
read_only: AtomicBool::new(options.read_only),
role: options.replication.role.clone(),
lease: AtomicU8::new(LeaseGateState::NotRequired as u8),
auto_paused: AtomicBool::new(false),
last_archive_at_ms: AtomicU64::new(0),
pause_threshold_secs: AtomicU64::new(0),
flow: FlowController::new(soft_target, options.replication.quorum.clone()),
ownership: parking_lot::RwLock::new(ownership),
}
}
pub fn check(&self, kind: WriteKind) -> RedDBResult<()> {
self.check_consent(kind).map(|_| ())
}
pub fn check_consent(&self, kind: WriteKind) -> RedDBResult<crate::application::WriteConsent> {
if matches!(self.role, ReplicationRole::Replica { .. }) {
return Err(RedDBError::ReadOnly(format!(
"instance is a replica — {} rejected on public surface",
kind.label()
)));
}
if matches!(self.lease_state(), LeaseGateState::NotHeld) {
return Err(RedDBError::ReadOnly(format!(
"writer lease not held — {} rejected (serverless fence)",
kind.label()
)));
}
self.check_ownership(kind)?;
if self.read_only.load(Ordering::Acquire) {
return Err(RedDBError::ReadOnly(format!(
"instance is configured read_only — {} rejected",
kind.label()
)));
}
if self.auto_paused.load(Ordering::Acquire) {
return Err(RedDBError::ReadOnly(format!(
"instance is paused — WAL archive lag exceeded threshold — {} rejected",
kind.label()
)));
}
if matches!(self.flow.try_admit(), Admission::Throttled) {
return Err(RedDBError::ReadOnly(format!(
"write admission throttled — in-quorum replica lag exceeded soft target ({} records) — {} rejected",
self.flow.soft_target_lsn(),
kind.label()
)));
}
Ok(crate::application::WriteConsent {
kind,
_seal: crate::application::WriteConsentSeal::new(),
})
}
pub fn is_read_only(&self) -> bool {
self.read_only.load(Ordering::Acquire)
|| self.auto_paused.load(Ordering::Acquire)
|| matches!(self.role, ReplicationRole::Replica { .. })
|| matches!(self.lease_state(), LeaseGateState::NotHeld)
|| self
.ownership
.read()
.as_ref()
.is_some_and(|gate| gate.is_fenced())
}
pub fn is_manual_read_only(&self) -> bool {
self.read_only.load(Ordering::Acquire)
}
pub fn is_auto_paused(&self) -> bool {
self.auto_paused.load(Ordering::Acquire)
}
pub fn role(&self) -> &ReplicationRole {
&self.role
}
pub fn flow_control(&self) -> &FlowController {
&self.flow
}
pub fn is_flow_throttled(&self) -> bool {
self.flow.is_throttled()
}
pub fn set_read_only(&self, enabled: bool) -> bool {
self.read_only.swap(enabled, Ordering::AcqRel)
}
pub fn lease_state(&self) -> LeaseGateState {
LeaseGateState::from_u8(self.lease.load(Ordering::Acquire))
}
pub fn configure_archive_lag_pause(&self, threshold_secs: u64, baseline_ms: u64) {
self.pause_threshold_secs
.store(threshold_secs, Ordering::Release);
self.last_archive_at_ms
.store(baseline_ms, Ordering::Release);
}
pub fn record_archive_success(&self, now_ms: u64) {
self.last_archive_at_ms.store(now_ms, Ordering::Release);
}
pub fn archive_pause_threshold_secs(&self) -> u64 {
self.pause_threshold_secs.load(Ordering::Acquire)
}
pub fn last_archive_at_ms(&self) -> u64 {
self.last_archive_at_ms.load(Ordering::Acquire)
}
pub fn evaluate_archive_lag(&self, now_ms: u64) -> bool {
let threshold = self.pause_threshold_secs.load(Ordering::Acquire);
if threshold == 0 {
return self.auto_paused.load(Ordering::Acquire);
}
if self.read_only.load(Ordering::Acquire) {
return self.auto_paused.load(Ordering::Acquire);
}
let last_ms = self.last_archive_at_ms.load(Ordering::Acquire);
let lag_secs = now_ms.saturating_sub(last_ms) / 1000;
let should_pause = lag_secs > threshold;
self.auto_paused.store(should_pause, Ordering::Release);
should_pause
}
pub(crate) fn set_lease_state(&self, state: LeaseGateState) -> LeaseGateState {
LeaseGateState::from_u8(self.lease.swap(state as u8, Ordering::AcqRel))
}
fn check_ownership(&self, kind: WriteKind) -> RedDBResult<()> {
let Some(gate) = self.ownership.read().as_ref().cloned() else {
return Ok(());
};
match gate.admit() {
Ok(()) => Ok(()),
Err(reject) => {
let detail = OwnershipFenceDetail::from_reject(&reject);
OperatorEvent::OwnershipFenced {
reason: detail.reason.clone(),
ownership_epoch: detail.current_epoch,
range_identity: detail.range_identity.clone(),
}
.emit_global();
Err(RedDBError::ReadOnly(format!(
"ownership_fenced reason={} current_epoch={} range={} — {} rejected below routing",
detail.reason,
detail.current_epoch,
detail.range_identity,
kind.label()
)))
}
}
}
pub fn promote_primary_replica_owner(
&self,
new_owner_subject: &str,
new_term: u64,
) -> RedDBResult<()> {
let new_owner = node_identity(new_owner_subject)?;
let mut guard = self.ownership.write();
let Some(gate) = guard.as_mut() else {
return Ok(());
};
gate.promote_to(new_owner, new_term)
}
pub fn primary_replica_range_authority(&self) -> Option<RangeAuthority> {
self.ownership
.read()
.as_ref()
.map(OwnershipAdmissionGate::range_authority)
}
}
#[derive(Debug, Clone)]
struct OwnershipAdmissionGate {
catalog: ShardOwnershipCatalog,
holder: LeasedOwner,
node: NodeIdentity,
collection: CollectionId,
range_id: RangeId,
key: Vec<u8>,
current_term: SupervisorTerm,
now_ms: u64,
}
impl OwnershipAdmissionGate {
fn primary_replica(owner_subject: &str, term: u64) -> Self {
let owner = node_identity(owner_subject).expect("static primary-replica owner identity");
let collection =
CollectionId::new(RESERVED_GLOBAL_SYSTEM_COLLECTION).expect("static collection id");
let range_id = RangeId::new(RESERVED_GLOBAL_SYSTEM_RANGE_ID);
let range = RangeOwnership::establish(
collection.clone(),
range_id,
ShardKeyMode::Ordered,
RangeBounds::full(),
owner.clone(),
Vec::<NodeIdentity>::new(),
PlacementMetadata::with_replication_factor(1),
);
let current_term = SupervisorTerm::new(term);
let lease = OwnershipLease::grant(
current_term,
collection.clone(),
range_id,
owner.clone(),
OwnershipEpoch::initial(),
0,
u64::MAX,
);
let mut catalog = ShardOwnershipCatalog::new();
catalog
.apply_update(range)
.expect("initial reserved global system range is valid");
Self {
catalog,
holder: LeasedOwner::with_lease(lease),
node: owner,
collection,
range_id,
key: RESERVED_GLOBAL_SYSTEM_RANGE_KEY.to_vec(),
current_term,
now_ms: 0,
}
}
fn admit(&self) -> Result<(), DurableWriteReject> {
admit_durable_write(
&self.catalog,
&self.holder,
&self.node,
&self.collection,
&self.key,
self.current_term,
self.now_ms,
)
.map(|_| ())
}
fn is_fenced(&self) -> bool {
self.admit().is_err()
}
fn range_authority(&self) -> RangeAuthority {
let epoch = self
.catalog
.range(&self.collection, self.range_id)
.map(|range| range.epoch().value())
.unwrap_or(0);
RangeAuthority {
range_id: self.range_id.value(),
min_term: self.current_term.value(),
min_ownership_epoch: epoch,
}
}
fn promote_to(&mut self, new_owner: NodeIdentity, new_term: u64) -> RedDBResult<()> {
let current = self
.catalog
.range(&self.collection, self.range_id)
.ok_or_else(|| RedDBError::Internal("reserved global system range missing".into()))?
.clone();
self.catalog
.apply_update(current.transfer_to(new_owner, [self.node.clone()]))
.map_err(|err| RedDBError::Catalog(err.to_string()))?;
self.current_term = SupervisorTerm::new(new_term);
Ok(())
}
}
struct OwnershipFenceDetail {
reason: String,
current_epoch: u64,
range_identity: String,
}
impl OwnershipFenceDetail {
fn from_reject(reject: &DurableWriteReject) -> Self {
match reject {
DurableWriteReject::NoRange { collection } => Self {
reason: "no_range".to_string(),
current_epoch: 0,
range_identity: collection.to_string(),
},
DurableWriteReject::StaleOwnership {
collection,
range_id,
current_epoch,
..
} => Self {
reason: "stale_ownership".to_string(),
current_epoch: current_epoch.value(),
range_identity: format!("{collection}/{range_id}"),
},
DurableWriteReject::NotOwner {
collection,
range_id,
epoch,
..
} => Self {
reason: "not_owner".to_string(),
current_epoch: epoch.value(),
range_identity: format!("{collection}/{range_id}"),
},
DurableWriteReject::Fenced {
collection,
range_id,
reason,
} => Self {
reason: fence_reason_label(reason).to_string(),
current_epoch: 0,
range_identity: format!("{collection}/{range_id}"),
},
}
}
}
fn fence_reason_label(reason: &crate::cluster::FenceReason) -> &'static str {
match reason {
crate::cluster::FenceReason::Unleased => "unleased",
crate::cluster::FenceReason::Revoked => "revoked",
crate::cluster::FenceReason::TermSuperseded { .. } => "term_superseded",
crate::cluster::FenceReason::EpochSuperseded { .. } => "epoch_superseded",
crate::cluster::FenceReason::Expired { .. } => "expired",
}
}
fn node_identity(subject: &str) -> RedDBResult<NodeIdentity> {
NodeIdentity::from_certificate_subject(subject)
.map_err(|err| RedDBError::InvalidConfig(err.to_string()))
}
#[cfg(test)]
impl WriteGate {
fn install_primary_replica_ownership_gate_for_test(&self, owner_subject: &str, term: u64) {
*self.ownership.write() =
Some(OwnershipAdmissionGate::primary_replica(owner_subject, term));
}
fn promote_primary_replica_owner_for_test(&self, new_owner_subject: &str, new_term: u64) {
self.promote_primary_replica_owner(new_owner_subject, new_term)
.expect("test promotion succeeds");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn gate(read_only: bool, role: ReplicationRole) -> WriteGate {
WriteGate {
read_only: AtomicBool::new(read_only),
role,
lease: AtomicU8::new(LeaseGateState::NotRequired as u8),
auto_paused: AtomicBool::new(false),
last_archive_at_ms: AtomicU64::new(0),
pause_threshold_secs: AtomicU64::new(0),
flow: FlowController::disabled(),
ownership: parking_lot::RwLock::new(None),
}
}
#[test]
fn standalone_allows_writes() {
let g = gate(false, ReplicationRole::Standalone);
assert!(g.check(WriteKind::Dml).is_ok());
assert!(g.check(WriteKind::Ddl).is_ok());
assert!(!g.is_read_only());
}
#[test]
fn primary_allows_writes() {
let g = gate(false, ReplicationRole::Primary);
assert!(g.check(WriteKind::Dml).is_ok());
assert!(!g.is_read_only());
}
#[test]
fn replica_rejects_every_kind() {
let g = gate(
true,
ReplicationRole::Replica {
primary_addr: "http://primary:55055".into(),
},
);
for kind in [
WriteKind::Dml,
WriteKind::Ddl,
WriteKind::IndexBuild,
WriteKind::Maintenance,
WriteKind::Backup,
WriteKind::Serverless,
] {
let err = g.check(kind).unwrap_err();
match err {
RedDBError::ReadOnly(msg) => assert!(msg.contains("replica")),
other => panic!("expected ReadOnly, got {other:?}"),
}
}
assert!(g.is_read_only());
}
#[test]
fn read_only_flag_rejects_writes_on_standalone() {
let g = gate(true, ReplicationRole::Standalone);
let err = g.check(WriteKind::Dml).unwrap_err();
match err {
RedDBError::ReadOnly(msg) => assert!(msg.contains("read_only")),
other => panic!("expected ReadOnly, got {other:?}"),
}
}
#[test]
fn lease_not_held_rejects_writes_on_primary() {
let g = gate(false, ReplicationRole::Primary);
g.set_lease_state(LeaseGateState::NotHeld);
let err = g.check(WriteKind::Dml).unwrap_err();
match err {
RedDBError::ReadOnly(msg) => assert!(msg.contains("lease")),
other => panic!("expected ReadOnly, got {other:?}"),
}
assert!(g.is_read_only());
}
#[test]
fn lease_held_allows_writes_on_primary() {
let g = gate(false, ReplicationRole::Primary);
g.set_lease_state(LeaseGateState::Held);
assert!(g.check(WriteKind::Dml).is_ok());
assert!(!g.is_read_only());
}
#[test]
fn lease_state_transitions_return_previous() {
let g = gate(false, ReplicationRole::Primary);
assert_eq!(
g.set_lease_state(LeaseGateState::Held),
LeaseGateState::NotRequired
);
assert_eq!(
g.set_lease_state(LeaseGateState::NotHeld),
LeaseGateState::Held
);
assert_eq!(g.lease_state(), LeaseGateState::NotHeld);
}
#[test]
fn lease_loss_overrides_writable_read_only_flag() {
let g = gate(false, ReplicationRole::Primary);
g.set_lease_state(LeaseGateState::NotHeld);
let err = g.check(WriteKind::Ddl).unwrap_err();
match err {
RedDBError::ReadOnly(msg) => assert!(msg.contains("lease")),
other => panic!("expected ReadOnly, got {other:?}"),
}
}
#[test]
fn archive_lag_disabled_threshold_is_noop() {
let g = gate(false, ReplicationRole::Standalone);
g.configure_archive_lag_pause(0, 1_000);
assert!(!g.evaluate_archive_lag(10_000_000_000));
assert!(!g.is_auto_paused());
assert!(g.check(WriteKind::Dml).is_ok());
}
#[test]
fn archive_lag_triggers_auto_pause_past_threshold() {
let g = gate(false, ReplicationRole::Standalone);
g.configure_archive_lag_pause(60, 1_000_000);
assert!(!g.evaluate_archive_lag(1_000_000 + 30_000));
assert!(g.check(WriteKind::Dml).is_ok());
assert!(g.evaluate_archive_lag(1_000_000 + 120_000));
assert!(g.is_auto_paused());
let err = g.check(WriteKind::Dml).unwrap_err();
match err {
RedDBError::ReadOnly(msg) => assert!(msg.contains("WAL archive lag"), "{msg}"),
other => panic!("expected ReadOnly, got {other:?}"),
}
assert!(g.is_read_only());
}
#[test]
fn archive_lag_auto_resume_after_recovery() {
let g = gate(false, ReplicationRole::Standalone);
g.configure_archive_lag_pause(60, 1_000_000);
assert!(g.evaluate_archive_lag(1_000_000 + 120_000));
assert!(g.is_auto_paused());
g.record_archive_success(1_000_000 + 130_000);
assert!(!g.evaluate_archive_lag(1_000_000 + 130_000));
assert!(!g.is_auto_paused());
assert!(g.check(WriteKind::Dml).is_ok());
}
#[test]
fn manual_read_only_blocks_auto_pause_writes_and_is_sticky() {
let g = gate(true, ReplicationRole::Standalone);
g.configure_archive_lag_pause(60, 1_000_000);
assert!(!g.evaluate_archive_lag(1_000_000 + 120_000));
assert!(!g.is_auto_paused());
assert!(g.is_manual_read_only());
let err = g.check(WriteKind::Dml).unwrap_err();
match err {
RedDBError::ReadOnly(msg) => {
assert!(msg.contains("read_only"), "{msg}");
assert!(!msg.contains("WAL archive lag"), "{msg}");
}
other => panic!("expected ReadOnly, got {other:?}"),
}
g.record_archive_success(1_000_000 + 130_000);
assert!(!g.evaluate_archive_lag(1_000_000 + 130_000));
assert!(g.is_manual_read_only(), "manual must stay set");
assert!(!g.is_auto_paused());
assert!(g.check(WriteKind::Dml).is_err());
}
#[test]
fn manual_clearing_resumes_auto_evaluation() {
let g = gate(true, ReplicationRole::Standalone);
g.configure_archive_lag_pause(60, 1_000_000);
assert!(!g.evaluate_archive_lag(1_000_000 + 120_000));
g.set_read_only(false);
assert!(g.evaluate_archive_lag(1_000_000 + 120_000));
assert!(g.is_auto_paused());
}
#[test]
fn archive_lag_pause_state_independent_from_manual_flag() {
let g = gate(false, ReplicationRole::Standalone);
g.configure_archive_lag_pause(60, 1_000_000);
assert!(g.evaluate_archive_lag(1_000_000 + 120_000));
let prev = g.set_read_only(true);
assert!(!prev);
assert!(g.is_manual_read_only());
assert!(g.is_auto_paused());
g.set_read_only(false);
assert!(g.is_auto_paused());
assert!(g.check(WriteKind::Dml).is_err());
}
fn gate_with_flow(soft_target: u64, quorum: crate::replication::QuorumConfig) -> WriteGate {
WriteGate {
read_only: AtomicBool::new(false),
role: ReplicationRole::Primary,
lease: AtomicU8::new(LeaseGateState::NotRequired as u8),
auto_paused: AtomicBool::new(false),
last_archive_at_ms: AtomicU64::new(0),
pause_threshold_secs: AtomicU64::new(0),
flow: FlowController::new(soft_target, quorum),
ownership: parking_lot::RwLock::new(None),
}
}
fn flow_replica(
id: &str,
region: Option<&str>,
last_acked_lsn: u64,
) -> crate::replication::primary::ReplicaState {
crate::replication::primary::ReplicaState {
id: id.to_string(),
last_acked_lsn,
last_sent_lsn: last_acked_lsn,
last_durable_lsn: last_acked_lsn,
apply_error_count: 0,
divergence_count: 0,
connected_at_unix_ms: 0,
last_seen_at_unix_ms: 0,
region: region.map(String::from),
rebootstrapping: false,
}
}
#[test]
fn flow_throttle_rejects_writes_when_in_quorum_replica_lags_and_releases() {
let g = gate_with_flow(100, crate::replication::QuorumConfig::sync(1));
assert!(g.check(WriteKind::Dml).is_ok());
assert!(!g.is_flow_throttled());
g.flow_control()
.observe(&[flow_replica("r1", Some("us"), 350)], 500);
assert!(g.is_flow_throttled());
let err = g.check(WriteKind::Dml).unwrap_err();
match err {
RedDBError::ReadOnly(msg) => assert!(msg.contains("throttled"), "{msg}"),
other => panic!("expected ReadOnly throttle, got {other:?}"),
}
assert!(!g.is_read_only());
g.flow_control()
.observe(&[flow_replica("r1", Some("us"), 450)], 500);
assert!(!g.is_flow_throttled());
assert!(g.check(WriteKind::Dml).is_ok());
}
#[test]
fn flow_throttle_excludes_async_read_replica() {
let g = gate_with_flow(100, crate::replication::QuorumConfig::regions(["us"]));
g.flow_control().observe(
&[
flow_replica("in-quorum-us", Some("us"), 500),
flow_replica("async-ap", Some("ap"), 0),
],
500,
);
assert!(!g.is_flow_throttled());
assert!(g.check(WriteKind::Dml).is_ok());
}
#[test]
fn flow_throttle_disabled_by_default() {
let g = gate(false, ReplicationRole::Primary);
g.flow_control()
.observe(&[flow_replica("r1", Some("us"), 0)], 1_000_000);
assert!(!g.is_flow_throttled());
assert!(g.check(WriteKind::Dml).is_ok());
}
#[test]
fn replica_role_overrides_missing_read_only_flag() {
let g = gate(
false,
ReplicationRole::Replica {
primary_addr: "http://primary:55055".into(),
},
);
let err = g.check(WriteKind::Dml).unwrap_err();
match err {
RedDBError::ReadOnly(msg) => assert!(msg.contains("replica")),
other => panic!("expected ReadOnly, got {other:?}"),
}
}
#[test]
fn replica_role_carries_apply_authority_but_stays_public_read_only() {
let options = RedDBOptions::in_memory().with_replication(
crate::replication::ReplicationConfig::replica("http://primary:55055").with_term(8),
);
let g = WriteGate::from_options(&options);
let authority = g
.primary_replica_range_authority()
.expect("replica apply authority");
assert_eq!(authority.range_id, RESERVED_GLOBAL_SYSTEM_RANGE_ID);
assert_eq!(authority.min_term, 8);
assert_eq!(authority.min_ownership_epoch, 1);
let err = g.check(WriteKind::Dml).unwrap_err();
match err {
RedDBError::ReadOnly(msg) => assert!(msg.contains("replica"), "{msg}"),
other => panic!("expected replica read-only, got {other:?}"),
}
}
#[test]
fn ownership_admission_fences_deposed_primary() {
let g = gate(false, ReplicationRole::Primary);
g.install_primary_replica_ownership_gate_for_test("CN=node-a,O=reddb", 7);
assert!(g.check(WriteKind::Dml).is_ok());
g.promote_primary_replica_owner_for_test("CN=node-b,O=reddb", 8);
let err = g.check(WriteKind::Dml).unwrap_err();
match err {
RedDBError::ReadOnly(msg) => {
assert!(msg.contains("ownership_fenced"), "{msg}");
assert!(msg.contains("reason=stale_ownership"), "{msg}");
assert!(msg.contains("current_epoch=2"), "{msg}");
assert!(msg.contains("range=system.global/0"), "{msg}");
}
other => panic!("expected ownership fencing ReadOnly, got {other:?}"),
}
}
#[test]
fn primary_replica_authority_reports_current_term_epoch_and_range() {
let g = gate(false, ReplicationRole::Primary);
g.install_primary_replica_ownership_gate_for_test("CN=node-a,O=reddb", 7);
g.promote_primary_replica_owner_for_test("CN=node-b,O=reddb", 8);
let authority = g
.primary_replica_range_authority()
.expect("primary-replica authority");
assert_eq!(authority.range_id, RESERVED_GLOBAL_SYSTEM_RANGE_ID);
assert_eq!(authority.min_term, 8);
assert_eq!(authority.min_ownership_epoch, 2);
}
}