1#[cfg(test)]
7use super::NodeRole;
8use super::{NodeEndpoint, NodeId, ProxyError, Result};
9use crate::backend::{BackendClient, BackendConfig};
10use std::collections::HashMap;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::Arc;
13use std::time::Duration;
14use tokio::sync::{mpsc, RwLock};
15
16#[cfg(feature = "ha-tr")]
18use super::failover_replay::{FailoverReplay, ReplayConfig, ReplayResult};
19#[cfg(feature = "ha-tr")]
20use super::transaction_journal::TransactionJournal;
21
22#[derive(Debug, Clone)]
24pub struct FailoverConfig {
25 pub detection_time: Duration,
27 pub failover_timeout: Duration,
29 pub auto_failover: bool,
31 pub prefer_sync_standby: bool,
33 pub max_lag_bytes: u64,
35 pub retry_failed: bool,
37 pub max_retries: u32,
39}
40
41impl Default for FailoverConfig {
42 fn default() -> Self {
43 Self {
44 detection_time: Duration::from_secs(10),
45 failover_timeout: Duration::from_secs(60),
46 auto_failover: true,
47 prefer_sync_standby: true,
48 max_lag_bytes: 16 * 1024 * 1024, retry_failed: true,
50 max_retries: 3,
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum FailoverMode {
58 Automatic,
60 Manual,
62 Disabled,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum FailoverState {
69 Normal,
71 PrimaryFailed,
73 InProgress,
75 WaitingForSync,
77 Completed,
79 Failed,
81}
82
83#[derive(Debug, Clone)]
85pub enum FailoverEvent {
86 PrimaryFailed { node_id: NodeId },
88 FailoverStarted { from: NodeId, to: NodeId },
90 WaitingForSync { standby: NodeId, lag_bytes: u64 },
92 StandbyPromoted { new_primary: NodeId },
94 FailoverCompleted { duration_ms: u64 },
96 FailoverFailed { reason: String },
98 OldPrimaryRecovered { node_id: NodeId },
100}
101
102#[derive(Debug, Clone)]
104pub struct FailoverCandidate {
105 pub node_id: NodeId,
107 pub endpoint: NodeEndpoint,
109 pub is_sync: bool,
111 pub lag_bytes: u64,
113 pub priority: u32,
115 pub last_heartbeat: Option<chrono::DateTime<chrono::Utc>>,
117}
118
119#[derive(Debug, Clone)]
121pub struct FailoverHistoryEntry {
122 pub id: uuid::Uuid,
124 pub started_at: chrono::DateTime<chrono::Utc>,
126 pub ended_at: Option<chrono::DateTime<chrono::Utc>>,
128 pub old_primary: NodeId,
130 pub new_primary: Option<NodeId>,
132 pub success: bool,
134 pub error: Option<String>,
136}
137
138pub struct FailoverController {
140 config: FailoverConfig,
142 state: Arc<RwLock<FailoverState>>,
144 current_primary: Arc<RwLock<Option<NodeId>>>,
146 candidates: Arc<RwLock<HashMap<NodeId, FailoverCandidate>>>,
148 event_tx: mpsc::Sender<FailoverEvent>,
150 event_rx: Option<mpsc::Receiver<FailoverEvent>>,
152 failover_count: AtomicU64,
154 history: Arc<RwLock<Vec<FailoverHistoryEntry>>>,
156 backend_template: Option<BackendConfig>,
162}
163
164impl FailoverController {
165 pub fn new(config: FailoverConfig) -> Self {
167 let (event_tx, event_rx) = mpsc::channel(100);
168
169 Self {
170 config,
171 state: Arc::new(RwLock::new(FailoverState::Normal)),
172 current_primary: Arc::new(RwLock::new(None)),
173 candidates: Arc::new(RwLock::new(HashMap::new())),
174 event_tx,
175 event_rx: Some(event_rx),
176 failover_count: AtomicU64::new(0),
177 history: Arc::new(RwLock::new(Vec::new())),
178 backend_template: None,
179 }
180 }
181
182 pub fn with_backend_template(mut self, template: BackendConfig) -> Self {
185 self.backend_template = Some(template);
186 self
187 }
188
189 fn backend_config_for(&self, endpoint: &NodeEndpoint) -> Option<BackendConfig> {
192 self.backend_template.as_ref().map(|t| {
193 let mut c = t.clone();
194 c.host = endpoint.host.clone();
195 c.port = endpoint.port;
196 c
197 })
198 }
199
200 pub async fn set_primary(&self, node_id: NodeId) {
202 *self.current_primary.write().await = Some(node_id);
203 tracing::info!("Primary set to {:?}", node_id);
204 }
205
206 pub async fn get_primary(&self) -> Option<NodeId> {
208 *self.current_primary.read().await
209 }
210
211 pub async fn register_candidate(&self, candidate: FailoverCandidate) {
213 let node_id = candidate.node_id;
214 self.candidates.write().await.insert(node_id, candidate);
215 tracing::debug!("Registered failover candidate {:?}", node_id);
216 }
217
218 pub async fn remove_candidate(&self, node_id: &NodeId) {
220 self.candidates.write().await.remove(node_id);
221 }
222
223 pub async fn update_candidate_lag(&self, node_id: &NodeId, lag_bytes: u64) {
225 if let Some(candidate) = self.candidates.write().await.get_mut(node_id) {
226 candidate.lag_bytes = lag_bytes;
227 candidate.last_heartbeat = Some(chrono::Utc::now());
228 }
229 }
230
231 pub async fn state(&self) -> FailoverState {
233 *self.state.read().await
234 }
235
236 pub async fn on_primary_failed(&self, node_id: NodeId) -> Result<()> {
238 let current_primary = self.current_primary.read().await;
239 if *current_primary != Some(node_id) {
240 return Ok(()); }
242 drop(current_primary);
243
244 *self.state.write().await = FailoverState::PrimaryFailed;
245
246 let _ = self
247 .event_tx
248 .send(FailoverEvent::PrimaryFailed { node_id })
249 .await;
250
251 tracing::warn!("Primary node {:?} failed", node_id);
252
253 if self.config.auto_failover {
254 self.initiate_failover().await?;
255 }
256
257 Ok(())
258 }
259
260 pub async fn initiate_failover(&self) -> Result<()> {
262 let old_primary =
263 self.current_primary.read().await.ok_or_else(|| {
264 ProxyError::FailoverFailed("No primary to failover from".to_string())
265 })?;
266
267 let candidate = self.select_best_candidate().await?;
269 let new_primary = candidate.node_id;
270
271 *self.state.write().await = FailoverState::InProgress;
272
273 let _ = self
274 .event_tx
275 .send(FailoverEvent::FailoverStarted {
276 from: old_primary,
277 to: new_primary,
278 })
279 .await;
280
281 let start = chrono::Utc::now();
282
283 let history_entry = FailoverHistoryEntry {
285 id: uuid::Uuid::new_v4(),
286 started_at: start,
287 ended_at: None,
288 old_primary,
289 new_primary: Some(new_primary),
290 success: false,
291 error: None,
292 };
293 self.history.write().await.push(history_entry);
294
295 if candidate.lag_bytes > self.config.max_lag_bytes {
297 *self.state.write().await = FailoverState::WaitingForSync;
298
299 let _ = self
300 .event_tx
301 .send(FailoverEvent::WaitingForSync {
302 standby: new_primary,
303 lag_bytes: candidate.lag_bytes,
304 })
305 .await;
306
307 let sync_result = self.wait_for_sync(new_primary).await;
309 if let Err(e) = sync_result {
310 self.fail_failover(&e.to_string()).await;
311 return Err(e);
312 }
313 }
314
315 self.promote_standby(new_primary).await?;
317
318 *self.current_primary.write().await = Some(new_primary);
320 *self.state.write().await = FailoverState::Completed;
321 self.failover_count.fetch_add(1, Ordering::SeqCst);
322
323 let duration = chrono::Utc::now()
324 .signed_duration_since(start)
325 .num_milliseconds() as u64;
326
327 if let Some(entry) = self.history.write().await.last_mut() {
329 entry.ended_at = Some(chrono::Utc::now());
330 entry.success = true;
331 }
332
333 let _ = self
334 .event_tx
335 .send(FailoverEvent::StandbyPromoted { new_primary })
336 .await;
337
338 let _ = self
339 .event_tx
340 .send(FailoverEvent::FailoverCompleted {
341 duration_ms: duration,
342 })
343 .await;
344
345 tracing::info!(
346 "Failover completed: {:?} -> {:?} in {}ms",
347 old_primary,
348 new_primary,
349 duration
350 );
351
352 tokio::spawn({
354 let state = self.state.clone();
355 async move {
356 tokio::time::sleep(Duration::from_secs(1)).await;
357 *state.write().await = FailoverState::Normal;
358 }
359 });
360
361 Ok(())
362 }
363
364 async fn select_best_candidate(&self) -> Result<FailoverCandidate> {
366 let candidates = self.candidates.read().await;
367
368 if candidates.is_empty() {
369 return Err(ProxyError::FailoverFailed(
370 "No failover candidates available".to_string(),
371 ));
372 }
373
374 let mut sorted: Vec<_> = candidates.values().cloned().collect();
376 sorted.sort_by(|a, b| {
377 if self.config.prefer_sync_standby && a.is_sync != b.is_sync {
379 return b.is_sync.cmp(&a.is_sync);
380 }
381 if a.lag_bytes != b.lag_bytes {
383 return a.lag_bytes.cmp(&b.lag_bytes);
384 }
385 a.priority.cmp(&b.priority)
387 });
388
389 sorted
390 .first()
391 .cloned()
392 .ok_or_else(|| ProxyError::FailoverFailed("No eligible candidates".to_string()))
393 }
394
395 async fn wait_for_sync(&self, standby: NodeId) -> Result<()> {
407 let endpoint = self
408 .candidates
409 .read()
410 .await
411 .get(&standby)
412 .map(|c| c.endpoint.clone());
413 let cfg = match endpoint.as_ref().and_then(|e| self.backend_config_for(e)) {
414 Some(c) => c,
415 None => {
416 tokio::time::sleep(Duration::from_millis(50)).await;
419 return Ok(());
420 }
421 };
422
423 let overall = self.config.failover_timeout;
424 tokio::time::timeout(overall, Self::poll_until_caught_up(cfg))
425 .await
426 .map_err(|_| ProxyError::Timeout("standby sync timeout".to_string()))??;
427 Ok(())
428 }
429
430 async fn poll_until_caught_up(cfg: BackendConfig) -> Result<()> {
434 let mut client = BackendClient::connect(&cfg)
435 .await
436 .map_err(|e| ProxyError::Failover(format!("connect to candidate: {}", e)))?;
437
438 let mut last: Option<String> = None;
439 let mut stable_polls = 0u32;
440 loop {
441 let value = client
442 .query_scalar("SELECT pg_last_wal_replay_lsn()::text")
443 .await
444 .map_err(|e| ProxyError::Failover(format!("wal lsn probe: {}", e)))?;
445 let lsn = value
446 .into_string()
447 .ok_or_else(|| ProxyError::Failover("null WAL replay LSN".into()))?;
448
449 if last.as_ref() == Some(&lsn) {
450 stable_polls += 1;
451 if stable_polls >= 2 {
452 tracing::info!(lsn = %lsn, "standby caught up");
453 client.close().await;
454 return Ok(());
455 }
456 } else {
457 stable_polls = 0;
458 last = Some(lsn);
459 }
460 tokio::time::sleep(Duration::from_millis(200)).await;
461 }
462 }
463
464 async fn promote_standby(&self, standby: NodeId) -> Result<()> {
474 let endpoint = self
475 .candidates
476 .read()
477 .await
478 .get(&standby)
479 .map(|c| c.endpoint.clone());
480 let cfg = match endpoint.as_ref().and_then(|e| self.backend_config_for(e)) {
481 Some(c) => c,
482 None => {
483 tracing::info!(
484 node = ?standby,
485 "promote_standby: skeleton path (no backend template) — no-op"
486 );
487 return Ok(());
488 }
489 };
490
491 let wait_secs = self.config.failover_timeout.as_secs().clamp(10, 300);
492 let mut client = BackendClient::connect(&cfg)
493 .await
494 .map_err(|e| ProxyError::FailoverFailed(format!("connect to promote: {}", e)))?;
495
496 let sql = format!("SELECT pg_promote(true, {})", wait_secs);
497 let value = client
498 .query_scalar(&sql)
499 .await
500 .map_err(|e| ProxyError::FailoverFailed(format!("pg_promote: {}", e)))?;
501 let promoted = value
502 .as_bool("pg_promote")
503 .map_err(|e| ProxyError::FailoverFailed(format!("pg_promote result: {}", e)))?
504 .unwrap_or(false);
505 client.close().await;
506
507 if !promoted {
508 return Err(ProxyError::FailoverFailed(
509 "pg_promote returned false".to_string(),
510 ));
511 }
512
513 let mut verify = BackendClient::connect(&cfg)
515 .await
516 .map_err(|e| ProxyError::FailoverFailed(format!("connect to verify: {}", e)))?;
517 let in_recovery = verify
518 .query_scalar("SELECT pg_is_in_recovery()")
519 .await
520 .map_err(|e| ProxyError::FailoverFailed(format!("verify probe: {}", e)))?;
521 verify.close().await;
522 let still_standby = in_recovery
523 .as_bool("pg_is_in_recovery")
524 .map_err(|e| ProxyError::FailoverFailed(format!("verify bool: {}", e)))?
525 .unwrap_or(true);
526 if still_standby {
527 return Err(ProxyError::FailoverFailed(
528 "post-promote pg_is_in_recovery still true".to_string(),
529 ));
530 }
531
532 tracing::info!(node = ?standby, "standby promoted to primary");
533 Ok(())
534 }
535
536 async fn fail_failover(&self, reason: &str) {
538 *self.state.write().await = FailoverState::Failed;
539
540 if let Some(entry) = self.history.write().await.last_mut() {
541 entry.ended_at = Some(chrono::Utc::now());
542 entry.success = false;
543 entry.error = Some(reason.to_string());
544 }
545
546 let _ = self
547 .event_tx
548 .send(FailoverEvent::FailoverFailed {
549 reason: reason.to_string(),
550 })
551 .await;
552
553 tracing::error!("Failover failed: {}", reason);
554 }
555
556 pub async fn on_old_primary_recovered(&self, node_id: NodeId) {
572 let _ = self
573 .event_tx
574 .send(FailoverEvent::OldPrimaryRecovered { node_id })
575 .await;
576 tracing::warn!(
577 "old primary {:?} recovered — must be demoted out-of-band to prevent split-brain",
578 node_id
579 );
580
581 let endpoint = self
585 .candidates
586 .read()
587 .await
588 .get(&node_id)
589 .map(|c| c.endpoint.clone());
590 let cfg = match endpoint.as_ref().and_then(|e| self.backend_config_for(e)) {
591 Some(c) => c,
592 None => return, };
594
595 match BackendClient::connect(&cfg).await {
596 Ok(mut client) => {
597 let in_recovery_result = client.query_scalar("SELECT pg_is_in_recovery()").await;
598 client.close().await;
599 if let Ok(tv) = in_recovery_result {
600 if let Ok(Some(false)) = tv.as_bool("pg_is_in_recovery") {
601 tracing::error!(
602 "split-brain hazard: node {:?} recovered and still reports primary (pg_is_in_recovery=false). Shut it down or use pg_rewind before reintroducing.",
603 node_id
604 );
605 }
606 }
607 }
608 Err(e) => {
609 tracing::debug!(
610 error = %e,
611 "could not connect to recovered node for split-brain probe"
612 );
613 }
614 }
615 }
616
617 pub async fn manual_failover(&self, target: NodeId) -> Result<()> {
619 let candidates = self.candidates.read().await;
621 if !candidates.contains_key(&target) {
622 return Err(ProxyError::FailoverFailed(format!(
623 "Node {:?} is not a valid failover candidate",
624 target
625 )));
626 }
627 drop(candidates);
628
629 *self.state.write().await = FailoverState::InProgress;
631
632 let old_primary = self.current_primary.read().await.unwrap_or(NodeId::new());
633
634 let _ = self
635 .event_tx
636 .send(FailoverEvent::FailoverStarted {
637 from: old_primary,
638 to: target,
639 })
640 .await;
641
642 self.promote_standby(target).await?;
643
644 *self.current_primary.write().await = Some(target);
645 *self.state.write().await = FailoverState::Completed;
646 self.failover_count.fetch_add(1, Ordering::SeqCst);
647
648 Ok(())
649 }
650
651 pub fn failover_count(&self) -> u64 {
653 self.failover_count.load(Ordering::SeqCst)
654 }
655
656 pub async fn history(&self) -> Vec<FailoverHistoryEntry> {
658 self.history.read().await.clone()
659 }
660
661 pub fn take_event_receiver(&mut self) -> Option<mpsc::Receiver<FailoverEvent>> {
663 self.event_rx.take()
664 }
665
666 #[cfg(feature = "ha-tr")]
675 pub async fn coordinate_failover_replay(
676 &self,
677 journal: &TransactionJournal,
678 failed_node: NodeId,
679 new_primary_endpoint: &NodeEndpoint,
680 ) -> Result<CoordinatedReplayResult> {
681 let start = std::time::Instant::now();
682
683 tracing::info!(
684 "Starting coordinated replay: failed_node={:?}, new_primary={:?}",
685 failed_node,
686 new_primary_endpoint.id
687 );
688
689 let affected_txs = journal.get_transactions_for_node(failed_node).await;
691
692 if affected_txs.is_empty() {
693 tracing::info!("No active transactions to replay");
694 return Ok(CoordinatedReplayResult {
695 total_transactions: 0,
696 successful_replays: 0,
697 failed_replays: 0,
698 transaction_results: vec![],
699 duration_ms: start.elapsed().as_millis() as u64,
700 new_primary: new_primary_endpoint.id,
701 });
702 }
703
704 tracing::info!("Found {} active transactions to replay", affected_txs.len());
705
706 let max_lsn = affected_txs
708 .iter()
709 .map(|tx| tx.start_lsn)
710 .max()
711 .unwrap_or(0);
712
713 self.wait_for_lsn_catchup(new_primary_endpoint.id, max_lsn)
715 .await?;
716
717 let replay_manager = FailoverReplay::new(ReplayConfig {
719 verify_results: true,
720 statement_timeout_ms: 30000,
721 retry_on_error: true,
722 max_retries: 3,
723 skip_read_only: false,
724 wait_for_wal_sync: false, max_wal_lag_bytes: 0,
726 });
727
728 let mut transaction_results = Vec::new();
729 let mut successful_replays = 0;
730 let mut failed_replays = 0;
731
732 for tx_journal in affected_txs {
733 let tx_id = tx_journal.tx_id;
734
735 tracing::debug!(
736 "Replaying transaction {:?} with {} entries",
737 tx_id,
738 tx_journal.entries.len()
739 );
740
741 match replay_manager
743 .start_replay(tx_journal, new_primary_endpoint.id)
744 .await
745 {
746 Ok(_) => match replay_manager.execute_replay(tx_id).await {
747 Ok(result) => {
748 if result.success {
749 successful_replays += 1;
750 tracing::debug!("Transaction {:?} replayed successfully", tx_id);
751 } else {
752 failed_replays += 1;
753 tracing::warn!(
754 "Transaction {:?} replay failed: {:?}",
755 tx_id,
756 result.error
757 );
758 }
759 transaction_results.push(result);
760 }
761 Err(e) => {
762 failed_replays += 1;
763 tracing::error!("Failed to execute replay for {:?}: {}", tx_id, e);
764 transaction_results.push(ReplayResult {
765 tx_id,
766 success: false,
767 statements_replayed: 0,
768 statements_skipped: 0,
769 statements_failed: 0,
770 verification_failures: 0,
771 duration_ms: 0,
772 error: Some(e.to_string()),
773 statement_results: vec![],
774 });
775 }
776 },
777 Err(e) => {
778 failed_replays += 1;
779 tracing::error!("Failed to start replay for {:?}: {}", tx_id, e);
780 transaction_results.push(ReplayResult {
781 tx_id,
782 success: false,
783 statements_replayed: 0,
784 statements_skipped: 0,
785 statements_failed: 0,
786 verification_failures: 0,
787 duration_ms: 0,
788 error: Some(e.to_string()),
789 statement_results: vec![],
790 });
791 }
792 }
793 }
794
795 let duration_ms = start.elapsed().as_millis() as u64;
796
797 tracing::info!(
798 "Coordinated replay completed: {}/{} successful in {}ms",
799 successful_replays,
800 successful_replays + failed_replays,
801 duration_ms
802 );
803
804 Ok(CoordinatedReplayResult {
805 total_transactions: successful_replays + failed_replays,
806 successful_replays,
807 failed_replays,
808 transaction_results,
809 duration_ms,
810 new_primary: new_primary_endpoint.id,
811 })
812 }
813
814 #[cfg(feature = "ha-tr")]
816 async fn wait_for_lsn_catchup(&self, node: NodeId, target_lsn: u64) -> Result<()> {
817 if target_lsn == 0 {
818 return Ok(());
819 }
820
821 tracing::debug!(
822 "Waiting for node {:?} to catch up to LSN {}",
823 node,
824 target_lsn
825 );
826
827 let timeout = self.config.failover_timeout;
829 let start = std::time::Instant::now();
830
831 loop {
832 if start.elapsed() >= timeout {
833 return Err(ProxyError::Timeout(format!(
834 "Timeout waiting for node {:?} to catch up to LSN {}",
835 node, target_lsn
836 )));
837 }
838
839 let candidates = self.candidates.read().await;
841 if let Some(candidate) = candidates.get(&node) {
842 if candidate.lag_bytes == 0 {
845 tracing::debug!("Node {:?} has caught up", node);
846 return Ok(());
847 }
848 }
849 drop(candidates);
850
851 tokio::time::sleep(Duration::from_millis(100)).await;
852 }
853 }
854}
855
856#[cfg(feature = "ha-tr")]
858#[derive(Debug, Clone)]
859pub struct CoordinatedReplayResult {
860 pub total_transactions: usize,
862 pub successful_replays: usize,
864 pub failed_replays: usize,
866 pub transaction_results: Vec<ReplayResult>,
868 pub duration_ms: u64,
870 pub new_primary: NodeId,
872}
873
874#[cfg(feature = "ha-tr")]
875impl CoordinatedReplayResult {
876 pub fn all_successful(&self) -> bool {
878 self.failed_replays == 0
879 }
880
881 pub fn success_rate(&self) -> f64 {
883 if self.total_transactions == 0 {
884 100.0
885 } else {
886 (self.successful_replays as f64 / self.total_transactions as f64) * 100.0
887 }
888 }
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894
895 #[test]
896 fn test_config_default() {
897 let config = FailoverConfig::default();
898 assert!(config.auto_failover);
899 assert!(config.prefer_sync_standby);
900 assert_eq!(config.max_retries, 3);
901 }
902
903 #[tokio::test]
904 async fn test_set_get_primary() {
905 let controller = FailoverController::new(FailoverConfig::default());
906 let node_id = NodeId::new();
907
908 controller.set_primary(node_id).await;
909 assert_eq!(controller.get_primary().await, Some(node_id));
910 }
911
912 #[tokio::test]
913 async fn test_register_candidate() {
914 let controller = FailoverController::new(FailoverConfig::default());
915 let node_id = NodeId::new();
916
917 let candidate = FailoverCandidate {
918 node_id,
919 endpoint: NodeEndpoint::new("localhost", 5432).with_role(NodeRole::Standby),
920 is_sync: true,
921 lag_bytes: 0,
922 priority: 1,
923 last_heartbeat: None,
924 };
925
926 controller.register_candidate(candidate).await;
927
928 let candidates = controller.candidates.read().await;
929 assert!(candidates.contains_key(&node_id));
930 }
931
932 #[tokio::test]
933 async fn test_state_transitions() {
934 let controller = FailoverController::new(FailoverConfig::default());
935
936 assert_eq!(controller.state().await, FailoverState::Normal);
937
938 *controller.state.write().await = FailoverState::PrimaryFailed;
939 assert_eq!(controller.state().await, FailoverState::PrimaryFailed);
940 }
941
942 #[tokio::test]
943 async fn test_select_best_candidate() {
944 let controller = FailoverController::new(FailoverConfig::default());
945
946 let sync_node = NodeId::new();
947 let async_node = NodeId::new();
948
949 controller
950 .register_candidate(FailoverCandidate {
951 node_id: async_node,
952 endpoint: NodeEndpoint::new("async", 5432),
953 is_sync: false,
954 lag_bytes: 100,
955 priority: 1,
956 last_heartbeat: None,
957 })
958 .await;
959
960 controller
961 .register_candidate(FailoverCandidate {
962 node_id: sync_node,
963 endpoint: NodeEndpoint::new("sync", 5432),
964 is_sync: true,
965 lag_bytes: 50,
966 priority: 2,
967 last_heartbeat: None,
968 })
969 .await;
970
971 let best = controller.select_best_candidate().await.unwrap();
972 assert_eq!(best.node_id, sync_node);
974 }
975
976 #[cfg(feature = "ha-tr")]
977 #[tokio::test]
978 async fn test_coordinate_failover_replay_empty() {
979 use super::super::transaction_journal::TransactionJournal;
980
981 let controller = FailoverController::new(FailoverConfig::default());
982 let journal = TransactionJournal::new();
983 let failed_node = NodeId::new();
984 let new_primary = NodeEndpoint::new("new-primary", 5432).with_role(NodeRole::Primary);
985
986 let result = controller
988 .coordinate_failover_replay(&journal, failed_node, &new_primary)
989 .await
990 .unwrap();
991
992 assert_eq!(result.total_transactions, 0);
993 assert_eq!(result.successful_replays, 0);
994 assert_eq!(result.failed_replays, 0);
995 assert!(result.all_successful());
996 assert_eq!(result.success_rate(), 100.0);
997 }
998
999 #[cfg(feature = "ha-tr")]
1000 #[tokio::test]
1001 async fn test_coordinate_failover_replay_with_transactions() {
1002 use super::super::transaction_journal::{JournalValue, TransactionJournal};
1003 use uuid::Uuid;
1004
1005 let controller = FailoverController::new(FailoverConfig::default());
1006 let journal = TransactionJournal::new();
1007 let failed_node = NodeId::new();
1008 let new_primary = NodeEndpoint::new("new-primary", 5432).with_role(NodeRole::Primary);
1009
1010 controller
1012 .register_candidate(FailoverCandidate {
1013 node_id: new_primary.id,
1014 endpoint: new_primary.clone(),
1015 is_sync: true,
1016 lag_bytes: 0,
1017 priority: 1,
1018 last_heartbeat: None,
1019 })
1020 .await;
1021
1022 let tx_id = Uuid::new_v4();
1024 let session_id = Uuid::new_v4();
1025 journal
1026 .begin_transaction(tx_id, session_id, failed_node, 100)
1027 .await
1028 .unwrap();
1029 journal
1030 .log_statement(
1031 tx_id,
1032 "INSERT INTO users (name) VALUES ('test')".to_string(),
1033 vec![JournalValue::Text("test".to_string())],
1034 Some(12345),
1035 Some(1),
1036 10,
1037 )
1038 .await
1039 .unwrap();
1040
1041 let result = controller
1043 .coordinate_failover_replay(&journal, failed_node, &new_primary)
1044 .await
1045 .unwrap();
1046
1047 assert_eq!(result.total_transactions, 1);
1048 assert_eq!(result.successful_replays, 1);
1049 assert_eq!(result.failed_replays, 0);
1050 assert!(result.all_successful());
1051 }
1052
1053 #[cfg(feature = "ha-tr")]
1054 #[test]
1055 fn test_coordinated_replay_result_methods() {
1056 let result = CoordinatedReplayResult {
1057 total_transactions: 10,
1058 successful_replays: 8,
1059 failed_replays: 2,
1060 transaction_results: vec![],
1061 duration_ms: 1000,
1062 new_primary: NodeId::new(),
1063 };
1064
1065 assert!(!result.all_successful());
1066 assert_eq!(result.success_rate(), 80.0);
1067
1068 let perfect = CoordinatedReplayResult {
1069 total_transactions: 5,
1070 successful_replays: 5,
1071 failed_replays: 0,
1072 transaction_results: vec![],
1073 duration_ms: 500,
1074 new_primary: NodeId::new(),
1075 };
1076
1077 assert!(perfect.all_successful());
1078 assert_eq!(perfect.success_rate(), 100.0);
1079 }
1080}