Skip to main content

heliosdb_proxy/
failover_controller.rs

1//! Failover Controller - HeliosProxy
2//!
3//! Orchestrates failover operations including primary detection,
4//! automatic rerouting, and transaction replay coordination.
5
6#[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// TR (Transaction Replay) imports
17#[cfg(feature = "ha-tr")]
18use super::failover_replay::{FailoverReplay, ReplayConfig, ReplayResult};
19#[cfg(feature = "ha-tr")]
20use super::transaction_journal::TransactionJournal;
21
22/// Failover configuration
23#[derive(Debug, Clone)]
24pub struct FailoverConfig {
25    /// Time to wait before initiating failover
26    pub detection_time: Duration,
27    /// Maximum time to wait for failover completion
28    pub failover_timeout: Duration,
29    /// Automatic failover (vs manual confirmation)
30    pub auto_failover: bool,
31    /// Prefer synchronous standbys for failover
32    pub prefer_sync_standby: bool,
33    /// Maximum LSN lag allowed for standby promotion (bytes)
34    pub max_lag_bytes: u64,
35    /// Retry failed failovers
36    pub retry_failed: bool,
37    /// Max retry attempts
38    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, // 16MB
49            retry_failed: true,
50            max_retries: 3,
51        }
52    }
53}
54
55/// Failover mode
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum FailoverMode {
58    /// Automatic failover on primary failure
59    Automatic,
60    /// Manual failover (require confirmation)
61    Manual,
62    /// Disabled (no failover)
63    Disabled,
64}
65
66/// Failover state
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum FailoverState {
69    /// Normal operation
70    Normal,
71    /// Primary failure detected
72    PrimaryFailed,
73    /// Failover in progress
74    InProgress,
75    /// Waiting for standby to catch up
76    WaitingForSync,
77    /// Failover completed
78    Completed,
79    /// Failover failed
80    Failed,
81}
82
83/// Failover event
84#[derive(Debug, Clone)]
85pub enum FailoverEvent {
86    /// Primary failure detected
87    PrimaryFailed { node_id: NodeId },
88    /// Failover started
89    FailoverStarted { from: NodeId, to: NodeId },
90    /// Waiting for standby sync
91    WaitingForSync { standby: NodeId, lag_bytes: u64 },
92    /// Standby promoted
93    StandbyPromoted { new_primary: NodeId },
94    /// Failover completed
95    FailoverCompleted { duration_ms: u64 },
96    /// Failover failed
97    FailoverFailed { reason: String },
98    /// Old primary recovered (split-brain prevention)
99    OldPrimaryRecovered { node_id: NodeId },
100}
101
102/// Failover candidate information
103#[derive(Debug, Clone)]
104pub struct FailoverCandidate {
105    /// Node ID
106    pub node_id: NodeId,
107    /// Node endpoint
108    pub endpoint: NodeEndpoint,
109    /// Is synchronous standby
110    pub is_sync: bool,
111    /// Replication lag (bytes)
112    pub lag_bytes: u64,
113    /// Priority (lower = better)
114    pub priority: u32,
115    /// Last heartbeat
116    pub last_heartbeat: Option<chrono::DateTime<chrono::Utc>>,
117}
118
119/// Failover history entry
120#[derive(Debug, Clone)]
121pub struct FailoverHistoryEntry {
122    /// Failover ID
123    pub id: uuid::Uuid,
124    /// Start time
125    pub started_at: chrono::DateTime<chrono::Utc>,
126    /// End time
127    pub ended_at: Option<chrono::DateTime<chrono::Utc>>,
128    /// Old primary
129    pub old_primary: NodeId,
130    /// New primary
131    pub new_primary: Option<NodeId>,
132    /// Result
133    pub success: bool,
134    /// Error message (if failed)
135    pub error: Option<String>,
136}
137
138/// Failover Controller
139pub struct FailoverController {
140    /// Configuration
141    config: FailoverConfig,
142    /// Current state
143    state: Arc<RwLock<FailoverState>>,
144    /// Current primary node
145    current_primary: Arc<RwLock<Option<NodeId>>>,
146    /// Failover candidates (standbys)
147    candidates: Arc<RwLock<HashMap<NodeId, FailoverCandidate>>>,
148    /// Event channel sender
149    event_tx: mpsc::Sender<FailoverEvent>,
150    /// Event channel receiver
151    event_rx: Option<mpsc::Receiver<FailoverEvent>>,
152    /// Failover count
153    failover_count: AtomicU64,
154    /// Failover history
155    history: Arc<RwLock<Vec<FailoverHistoryEntry>>>,
156    /// Optional backend-connection template. Host/port are swapped to
157    /// a candidate's endpoint when running `pg_promote()` or polling
158    /// `pg_last_wal_replay_lsn()`. When `None`, all backend-talking
159    /// paths become no-ops that log and succeed — preserving the
160    /// pre-T0-TR4 behaviour for unit tests.
161    backend_template: Option<BackendConfig>,
162}
163
164impl FailoverController {
165    /// Create a new failover controller
166    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    /// Attach a backend-connection template so sync-wait and promotion
183    /// can actually run SQL against the candidate.
184    pub fn with_backend_template(mut self, template: BackendConfig) -> Self {
185        self.backend_template = Some(template);
186        self
187    }
188
189    /// Build a BackendConfig for a specific node's endpoint. Returns
190    /// `None` when no template is configured (the no-op / test path).
191    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    /// Set the current primary
201    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    /// Get the current primary
207    pub async fn get_primary(&self) -> Option<NodeId> {
208        *self.current_primary.read().await
209    }
210
211    /// Register a failover candidate (standby)
212    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    /// Remove a failover candidate
219    pub async fn remove_candidate(&self, node_id: &NodeId) {
220        self.candidates.write().await.remove(node_id);
221    }
222
223    /// Update candidate lag
224    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    /// Get current state
232    pub async fn state(&self) -> FailoverState {
233        *self.state.read().await
234    }
235
236    /// Handle primary failure
237    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(()); // Not the current primary
241        }
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    /// Initiate failover to best candidate
261    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        // Select best candidate
268        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        // Record history entry
284        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        // Check lag
296        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            // Wait for sync (with timeout)
308            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        // Promote standby
316        self.promote_standby(new_primary).await?;
317
318        // Complete failover
319        *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        // Update history
328        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        // Reset state after a moment
353        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    /// Select the best failover candidate
365    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        // Sort by: sync status, lag, priority
375        let mut sorted: Vec<_> = candidates.values().cloned().collect();
376        sorted.sort_by(|a, b| {
377            // Prefer sync standbys
378            if self.config.prefer_sync_standby && a.is_sync != b.is_sync {
379                return b.is_sync.cmp(&a.is_sync);
380            }
381            // Then by lag
382            if a.lag_bytes != b.lag_bytes {
383                return a.lag_bytes.cmp(&b.lag_bytes);
384            }
385            // Then by priority
386            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    /// Wait for a standby to catch up before promotion.
396    ///
397    /// Polls `pg_last_wal_replay_lsn()` on the candidate at 200 ms
398    /// cadence. The same LSN observed across three consecutive polls
399    /// (`stable_polls >= 2`, i.e. two repeat observations) is treated
400    /// as "caught up as far as it can go" (the primary is presumed
401    /// dead, so no new WAL is arriving). Bounded by
402    /// `config.failover_timeout`.
403    ///
404    /// When no backend template is attached, returns `Ok(())` after
405    /// a short delay — the pre-T0-TR4 skeleton behaviour for tests.
406    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                // Skeleton path: simulate a brief wait so the state
417                // machine test harness still sees WaitingForSync.
418                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    /// Connect to the candidate and poll `pg_last_wal_replay_lsn()`
431    /// until it stabilises across three consecutive 200 ms polls
432    /// (two repeat observations of the same LSN).
433    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    /// Promote a standby to primary via `pg_promote()`.
465    ///
466    /// Uses `pg_promote(wait => true, wait_seconds => N)` so the server
467    /// waits for promotion to complete before returning. Verifies
468    /// post-promotion by re-running `pg_is_in_recovery()` (must now be
469    /// `false`) on a fresh connection.
470    ///
471    /// When no backend template is attached, logs and returns `Ok(())`
472    /// — skeleton / test path.
473    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        // Verify on a fresh connection that the node is no longer in recovery.
514        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    /// Fail the failover
537    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    /// Handle old primary recovery (split-brain prevention).
557    ///
558    /// PostgreSQL has no built-in "demote the current primary" command —
559    /// re-joining as a standby requires stopping the process and
560    /// re-initialising (`pg_rewind` or `pg_basebackup`). This method
561    /// therefore cannot fully automate demotion. What it CAN do:
562    ///
563    /// 1. Connect to the recovered node and verify whether it still
564    ///    believes it is the primary (`pg_is_in_recovery() = false`).
565    /// 2. Emit `OldPrimaryRecovered` so operators (or an external
566    ///    orchestrator like Patroni / pg_auto_failover) can react.
567    ///
568    /// This is deliberately read-only. Rewriting WAL on a live cluster
569    /// without operator oversight is the canonical way to lose data;
570    /// the proxy refuses to do it.
571    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        // Best-effort: if we have an endpoint and template, probe to
582        // confirm recovery state and shout extra-loud if it still
583        // thinks it's primary.
584        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, // no backend template → nothing more to do
593        };
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    /// Manual failover to specific node
618    pub async fn manual_failover(&self, target: NodeId) -> Result<()> {
619        // Verify target is a valid candidate
620        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        // Force failover to specific node
630        *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    /// Get failover count
652    pub fn failover_count(&self) -> u64 {
653        self.failover_count.load(Ordering::SeqCst)
654    }
655
656    /// Get failover history
657    pub async fn history(&self) -> Vec<FailoverHistoryEntry> {
658        self.history.read().await.clone()
659    }
660
661    /// Take the event receiver
662    pub fn take_event_receiver(&mut self) -> Option<mpsc::Receiver<FailoverEvent>> {
663        self.event_rx.take()
664    }
665
666    /// Coordinate transaction replay after failover (TR integration)
667    ///
668    /// This method orchestrates the replay of in-flight transactions on a new primary
669    /// after a failover event. It ensures transaction atomicity by:
670    /// 1. Getting all active transactions from the journal that were on the failed node
671    /// 2. Waiting for the new primary to catch up to the required LSN
672    /// 3. Replaying each transaction's statements on the new primary
673    /// 4. Verifying results match the original execution (via checksums)
674    #[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        // 1. Get all active transactions that were on the failed node
690        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        // 2. Get the maximum LSN we need to wait for
707        let max_lsn = affected_txs
708            .iter()
709            .map(|tx| tx.start_lsn)
710            .max()
711            .unwrap_or(0);
712
713        // 3. Wait for the new primary to catch up to this LSN
714        self.wait_for_lsn_catchup(new_primary_endpoint.id, max_lsn)
715            .await?;
716
717        // 4. Create replay manager and replay each transaction
718        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, // Already waited above
725            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            // Start and execute replay
742            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    /// Wait for a node to catch up to a specific LSN
815    #[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        // Use configured timeout
828        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            // Check if candidate has caught up
840            let candidates = self.candidates.read().await;
841            if let Some(candidate) = candidates.get(&node) {
842                // In a real implementation, we'd query the node's current LSN
843                // For now, we check if lag is acceptable
844                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/// Result of coordinated transaction replay after failover
857#[cfg(feature = "ha-tr")]
858#[derive(Debug, Clone)]
859pub struct CoordinatedReplayResult {
860    /// Total number of transactions replayed
861    pub total_transactions: usize,
862    /// Number of successful replays
863    pub successful_replays: usize,
864    /// Number of failed replays
865    pub failed_replays: usize,
866    /// Per-transaction replay results
867    pub transaction_results: Vec<ReplayResult>,
868    /// Total duration (ms)
869    pub duration_ms: u64,
870    /// New primary node ID
871    pub new_primary: NodeId,
872}
873
874#[cfg(feature = "ha-tr")]
875impl CoordinatedReplayResult {
876    /// Check if all transactions were replayed successfully
877    pub fn all_successful(&self) -> bool {
878        self.failed_replays == 0
879    }
880
881    /// Get the success rate as a percentage
882    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        // Sync standby should be preferred
973        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        // With no transactions, should succeed immediately
987        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        // Register the new primary as a candidate with zero lag
1011        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        // Create a transaction on the failed node
1023        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        // Coordinate replay
1042        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}