1pub mod bookmark;
24pub mod cascade;
25pub mod cdc;
26pub mod commit_policy;
27pub mod commit_waiter;
28pub mod control_plane;
29pub mod dst;
30pub mod election;
31pub mod failover;
32pub mod fence;
33pub mod flow_control;
34pub mod lease;
35pub mod logical;
36pub mod primary;
37pub mod quorum;
38pub mod reconnect;
39pub mod replica;
40pub mod rollback;
41pub mod scheduler;
42pub mod signal_plane;
43pub mod swap_db;
44pub mod topology_advertiser;
45pub mod witness;
46
47pub use bookmark::{BookmarkDecodeError, CausalBookmark};
48pub use cascade::{
49 plan_upstream, CascadeRefusal, CascadeRelay, CascadeUpstream, DownstreamSlot, ReplicaClass,
50 UpstreamChoice,
51};
52pub use commit_policy::CommitPolicy;
53pub use commit_waiter::{AwaitOutcome, CommitWaiter};
54pub use control_plane::{
55 ControlPlaneConsensus, ControlPlaneEntry, ControlPlaneEntryKind, ControlPlaneLogIndex,
56 ControlPlanePayload, ControlPlaneRole, MemberId, ProposeRefusal,
57};
58pub use election::{
59 quorum_threshold, randomized_election_timeout, ElectionCoordinator, ElectionOutcome,
60 ElectionRequest, ElectionTransport, FileLastVoteStore, LastVote, LastVoteError, LastVoteStore,
61 Member, MemberKind, MemoryLastVoteStore, RefusalReason, VoteDecision, VoteRequest, Voter,
62 VotingState,
63};
64pub use failover::{
65 FailoverCoordinator, FailoverError, FailoverMode, FailoverNode, FailoverOutcome,
66 FailoverRequest, FailoverTransport, NodeRole, RoleAssignment,
67};
68pub use fence::{
69 term_is_stale, FenceBoundary, FenceVerdict, FileTermStore, MemoryTermStore, StaleTermFenced,
70 StaleTermRejection, StreamHandshake, TermFence, TermStore, TermStoreError,
71};
72pub use flow_control::{Admission, FlowController};
73pub use lease::{LeaseError, LeaseStore, WriterLease};
74pub use quorum::{QuorumConfig, QuorumCoordinator, QuorumError};
75pub use rollback::{
76 DivergentTail, RollbackCoordinator, RollbackError, RollbackEvent, RollbackOutcome,
77 RollbackPlan, RollbackRequest, RollbackTransport, TailRecord,
78};
79pub use signal_plane::{
80 CatalogVersionHint, IntraClusterSignalBus, LivenessObservation, LivenessStatus, LoadBucket,
81 LoadMetricSample, MemberHealthInput, ReceivedSignal, SharedSignalTransport, SignalFrame,
82 SignalPlane, SignalPlaneLimits, SignalPlaneMessage, SignalPlaneMetrics, SignalPlaneSchedule,
83 SignalTransportError, SimulatedSignalPlane, TopologyHint, TransportSignalPlane,
84};
85pub use swap_db::{RebootstrapInProgress, SwapDb};
86pub use topology_advertiser::{
87 LagConfig, TopologyAdvertiser, TopologyAuthGate, DEFAULT_REPLICA_TIMEOUT_MS,
88 TOPOLOGY_READ_CAPABILITY,
89};
90pub use witness::{RuntimeProfile, WitnessSupervisor};
91
92pub const DEFAULT_REPLICATION_TERM: u64 = reddb_wire::replication::DEFAULT_REPLICATION_TERM;
93pub const DEFAULT_SLOT_RETENTION_MAX_LAG_LSN: u64 = 100_000;
94pub const DEFAULT_SLOT_IDLE_TIMEOUT_MS: u64 = 86_400_000;
95pub const SHIPPED_FAILOVER_PROFILES: [FailoverProfile; 3] = [
96 FailoverProfile::CONSERVATIVE,
97 FailoverProfile::BALANCED,
98 FailoverProfile::AGGRESSIVE,
99];
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub enum FailoverProfileName {
104 Conservative,
105 Balanced,
106 Aggressive,
107 Custom,
108}
109
110impl FailoverProfileName {
111 pub const fn as_str(self) -> &'static str {
112 match self {
113 Self::Conservative => "conservative",
114 Self::Balanced => "balanced",
115 Self::Aggressive => "aggressive",
116 Self::Custom => "custom",
117 }
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub struct FailoverProfile {
133 pub name: FailoverProfileName,
134 pub lease_window_ms: u64,
135 pub member_health_score_threshold: u8,
136 pub promotion_grace_ms: u64,
137 pub max_clock_drift_ms: u64,
138}
139
140impl FailoverProfile {
141 pub const CONSERVATIVE: Self = Self {
142 name: FailoverProfileName::Conservative,
143 lease_window_ms: 60_000,
144 member_health_score_threshold: 90,
145 promotion_grace_ms: 30_000,
146 max_clock_drift_ms: 5_000,
147 };
148
149 pub const BALANCED: Self = Self {
150 name: FailoverProfileName::Balanced,
151 lease_window_ms: 30_000,
152 member_health_score_threshold: 75,
153 promotion_grace_ms: 10_000,
154 max_clock_drift_ms: 5_000,
155 };
156
157 pub const AGGRESSIVE: Self = Self {
158 name: FailoverProfileName::Aggressive,
159 lease_window_ms: 15_000,
160 member_health_score_threshold: 60,
161 promotion_grace_ms: 5_000,
162 max_clock_drift_ms: 2_000,
163 };
164
165 pub const fn shipped() -> &'static [Self] {
166 &SHIPPED_FAILOVER_PROFILES
167 }
168
169 pub const fn name_str(self) -> &'static str {
170 self.name.as_str()
171 }
172
173 pub fn validate(self) -> Result<Self, String> {
174 if self.lease_window_ms == 0 {
175 return Err("failover profile lease_window_ms must be positive".to_string());
176 }
177 if self.member_health_score_threshold > 100 {
178 return Err(
179 "failover profile member_health_score_threshold must be <= 100".to_string(),
180 );
181 }
182 if self.promotion_grace_ms >= self.lease_window_ms {
183 return Err(
184 "failover profile promotion_grace_ms must be smaller than lease_window_ms"
185 .to_string(),
186 );
187 }
188 let safety_margin_ms = self.lease_window_ms - self.promotion_grace_ms;
189 if safety_margin_ms < self.max_clock_drift_ms {
190 return Err(format!(
191 "failover profile lease safety margin {safety_margin_ms}ms is below configured max clock drift {}ms",
192 self.max_clock_drift_ms
193 ));
194 }
195 Ok(self)
196 }
197
198 pub const fn lease_safety_margin_ms(self) -> u64 {
199 self.lease_window_ms - self.promotion_grace_ms
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn shipped_failover_profiles_satisfy_lease_safety_validation() {
209 for profile in FailoverProfile::shipped() {
210 profile.validate().expect(profile.name_str());
211 assert!(
212 profile.lease_safety_margin_ms() >= profile.max_clock_drift_ms,
213 "{} profile violates lease clock-discipline margin",
214 profile.name_str()
215 );
216 }
217 }
218
219 #[test]
220 fn failover_profile_rejects_margin_below_configured_clock_drift() {
221 let invalid = FailoverProfile {
222 name: FailoverProfileName::Custom,
223 lease_window_ms: 1_000,
224 member_health_score_threshold: 80,
225 promotion_grace_ms: 900,
226 max_clock_drift_ms: 200,
227 };
228
229 let err = invalid.validate().expect_err("profile must be rejected");
230
231 assert!(err.contains("lease safety margin"), "{err}");
232 assert!(err.contains("max clock drift"), "{err}");
233 }
234
235 #[test]
236 fn runtime_open_rejects_invalid_failover_profile() {
237 let invalid = FailoverProfile {
238 name: FailoverProfileName::Custom,
239 lease_window_ms: 1_000,
240 member_health_score_threshold: 80,
241 promotion_grace_ms: 900,
242 max_clock_drift_ms: 200,
243 };
244 let options = crate::RedDBOptions::in_memory()
245 .with_replication(ReplicationConfig::primary().with_failover_profile(invalid));
246
247 let err = match crate::RedDBRuntime::with_options(options) {
248 Ok(_) => panic!("boot must reject config"),
249 Err(err) => err,
250 };
251
252 assert!(err.to_string().contains("invalid config"), "{err}");
253 assert!(err.to_string().contains("lease safety margin"), "{err}");
254 }
255
256 #[test]
257 fn changing_failover_profile_updates_active_profile() {
258 let mut config = ReplicationConfig::primary();
259
260 config
261 .change_failover_profile(FailoverProfile::AGGRESSIVE, "test")
262 .expect("valid profile applies");
263
264 assert_eq!(
265 config.failover_profile.name,
266 FailoverProfileName::Aggressive
267 );
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Default)]
273pub enum ReplicationRole {
274 #[default]
276 Standalone,
277 Primary,
279 Replica {
281 primary_addr: String,
283 },
284}
285
286#[derive(Debug, Clone)]
288pub struct ReplicationConfig {
289 pub role: ReplicationRole,
290 pub term: u64,
292 pub poll_interval_ms: u64,
294 pub max_batch_size: usize,
296 pub region: String,
303 pub quorum: QuorumConfig,
305 pub slot_retention_max_lag_lsn: u64,
308 pub slot_idle_timeout_ms: u64,
310 pub replica_class: ReplicaClass,
316 pub cascade_from: Option<CascadeUpstream>,
320 pub failover_profile: FailoverProfile,
323}
324
325impl ReplicationConfig {
326 pub fn standalone() -> Self {
327 Self {
328 role: ReplicationRole::Standalone,
329 term: DEFAULT_REPLICATION_TERM,
330 poll_interval_ms: 100,
331 max_batch_size: 1000,
332 region: "local".to_string(),
333 quorum: QuorumConfig::async_commit(),
334 slot_retention_max_lag_lsn: DEFAULT_SLOT_RETENTION_MAX_LAG_LSN,
335 slot_idle_timeout_ms: DEFAULT_SLOT_IDLE_TIMEOUT_MS,
336 replica_class: ReplicaClass::Voting,
337 cascade_from: None,
338 failover_profile: FailoverProfile::BALANCED,
339 }
340 }
341
342 pub fn primary() -> Self {
343 Self {
344 role: ReplicationRole::Primary,
345 term: DEFAULT_REPLICATION_TERM,
346 poll_interval_ms: 100,
347 max_batch_size: 1000,
348 region: "local".to_string(),
349 quorum: QuorumConfig::async_commit(),
350 slot_retention_max_lag_lsn: DEFAULT_SLOT_RETENTION_MAX_LAG_LSN,
351 slot_idle_timeout_ms: DEFAULT_SLOT_IDLE_TIMEOUT_MS,
352 replica_class: ReplicaClass::Voting,
353 cascade_from: None,
354 failover_profile: FailoverProfile::BALANCED,
355 }
356 }
357
358 pub fn replica(primary_addr: impl Into<String>) -> Self {
359 Self {
360 role: ReplicationRole::Replica {
361 primary_addr: primary_addr.into(),
362 },
363 term: DEFAULT_REPLICATION_TERM,
364 poll_interval_ms: 100,
365 max_batch_size: 1000,
366 region: "local".to_string(),
367 quorum: QuorumConfig::async_commit(),
368 slot_retention_max_lag_lsn: DEFAULT_SLOT_RETENTION_MAX_LAG_LSN,
369 slot_idle_timeout_ms: DEFAULT_SLOT_IDLE_TIMEOUT_MS,
370 replica_class: ReplicaClass::Voting,
371 cascade_from: None,
372 failover_profile: FailoverProfile::BALANCED,
373 }
374 }
375
376 pub fn with_quorum(mut self, quorum: QuorumConfig) -> Self {
378 self.quorum = quorum;
379 self
380 }
381
382 pub fn with_region(mut self, region: impl Into<String>) -> Self {
384 self.region = region.into();
385 self
386 }
387
388 pub fn with_term(mut self, term: u64) -> Self {
390 self.term = term;
391 self
392 }
393
394 pub fn with_slot_retention_max_lag_lsn(mut self, max_lag_lsn: u64) -> Self {
395 self.slot_retention_max_lag_lsn = max_lag_lsn;
396 self
397 }
398
399 pub fn with_slot_idle_timeout_ms(mut self, timeout_ms: u64) -> Self {
400 self.slot_idle_timeout_ms = timeout_ms;
401 self
402 }
403
404 pub fn with_failover_profile(mut self, profile: FailoverProfile) -> Self {
405 self.failover_profile = profile;
406 self
407 }
408
409 pub fn validate_failover_profile(&self) -> Result<(), String> {
410 self.failover_profile.validate().map(|_| ())
411 }
412
413 pub fn change_failover_profile(
414 &mut self,
415 profile: FailoverProfile,
416 changed_by: impl Into<String>,
417 ) -> Result<(), String> {
418 let profile = profile.validate()?;
419 let old_profile = self.failover_profile.name_str().to_string();
420 self.failover_profile = profile;
421 crate::telemetry::operator_event::OperatorEvent::FailoverProfileChanged {
422 old_profile,
423 new_profile: profile.name_str().to_string(),
424 changed_by: changed_by.into(),
425 }
426 .emit_global();
427 Ok(())
428 }
429
430 pub fn with_replica_class(mut self, class: ReplicaClass) -> Self {
432 self.replica_class = class;
433 self
434 }
435
436 pub fn cascading_from(mut self, node_id: impl Into<String>, addr: impl Into<String>) -> Self {
442 self.replica_class = ReplicaClass::AsyncReadReplica;
443 self.cascade_from = Some(CascadeUpstream::new(node_id, addr));
444 self
445 }
446
447 pub fn resolved_upstream(
454 &self,
455 self_node_id: &str,
456 ) -> (UpstreamChoice, Option<CascadeRefusal>) {
457 plan_upstream(self_node_id, self.replica_class, self.cascade_from.as_ref())
458 }
459}