use std::sync::Arc;
use std::sync::Mutex;
use async_trait::async_trait;
use sea_orm::{ConnectionTrait, Statement};
use crate::database::DbPool;
use crate::foundation::{DbError, DbResult};
#[derive(Debug, Clone)]
pub struct ReplicationLag {
pub lag_bytes: Option<u64>,
pub lag_seconds: Option<f64>,
pub is_caught_up: bool,
}
#[async_trait]
pub trait ReplicationLagDetector: Send + Sync {
async fn detect_lag(&self, pool: &DbPool) -> DbResult<ReplicationLag>;
}
pub struct PostgresLagDetector {
pub max_lag_bytes: u64,
}
impl Default for PostgresLagDetector {
fn default() -> Self {
Self {
max_lag_bytes: 10 * 1024 * 1024, }
}
}
#[async_trait]
impl ReplicationLagDetector for PostgresLagDetector {
async fn detect_lag(&self, pool: &DbPool) -> DbResult<ReplicationLag> {
let session = pool.get_session("admin").await?;
let conn = session.connection()?;
let sql = "SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), \
COALESCE(pg_last_wal_replay_lsn(), pg_current_wal_lsn()))::text AS lag_bytes";
let row = conn
.query_one_raw(Statement::from_string(
conn.get_database_backend(),
sql.to_owned(),
))
.await
.map_err(DbError::Connection)?
.ok_or_else(|| DbError::Query("pg_wal_lsn_diff query returned no rows".to_string()))?;
let raw: String = row.try_get_by("lag_bytes").map_err(DbError::Connection)?;
let lag_bytes = parse_pg_wal_lag(&raw);
let is_caught_up = lag_bytes.is_some_and(|lag| lag <= self.max_lag_bytes);
Ok(ReplicationLag {
lag_bytes,
lag_seconds: None,
is_caught_up,
})
}
}
pub struct MySqlLagDetector {
pub max_lag_seconds: f64,
}
impl Default for MySqlLagDetector {
fn default() -> Self {
Self {
max_lag_seconds: 5.0,
}
}
}
#[async_trait]
impl ReplicationLagDetector for MySqlLagDetector {
async fn detect_lag(&self, pool: &DbPool) -> DbResult<ReplicationLag> {
let session = pool.get_session("admin").await?;
let conn = session.connection()?;
let sql = "SHOW SLAVE STATUS";
let row = conn
.query_one_raw(Statement::from_string(
conn.get_database_backend(),
sql.to_owned(),
))
.await
.map_err(DbError::Connection)?
.ok_or_else(|| {
DbError::Query(
"SHOW SLAVE STATUS returned no rows: server is not configured as a replica"
.to_string(),
)
})?;
let raw = match row.try_get_by::<Option<i64>, _>("Seconds_Behind_Master") {
Ok(Some(v)) => SecondsBehindRaw::Value(v),
Ok(None) => SecondsBehindRaw::Null,
Err(_) => SecondsBehindRaw::MissingColumn,
};
let lag_seconds = parse_mysql_seconds_behind(raw);
let is_caught_up = lag_seconds.is_some_and(|s| s <= self.max_lag_seconds);
Ok(ReplicationLag {
lag_bytes: None,
lag_seconds,
is_caught_up,
})
}
}
fn parse_pg_wal_lag(row_value: &str) -> Option<u64> {
let s = row_value.trim();
if let Ok(v) = s.parse::<u64>() {
return Some(v);
}
let f = s.parse::<f64>().ok()?;
if f.is_finite() && f >= 0.0 {
Some(f as u64)
} else {
None
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SecondsBehindRaw {
Value(i64),
Null,
MissingColumn,
}
fn parse_mysql_seconds_behind(raw: SecondsBehindRaw) -> Option<f64> {
match raw {
SecondsBehindRaw::Value(v) if v >= 0 => Some(v as f64),
_ => None,
}
}
pub struct SqliteLagDetector;
#[async_trait]
impl ReplicationLagDetector for SqliteLagDetector {
async fn detect_lag(&self, _pool: &DbPool) -> DbResult<ReplicationLag> {
Ok(ReplicationLag {
lag_bytes: None,
lag_seconds: None,
is_caught_up: true,
})
}
}
pub struct ReplicaPool {
pool: Arc<DbPool>,
lag_detector: Arc<dyn ReplicationLagDetector>,
#[allow(dead_code)]
max_lag_seconds: f64,
}
impl ReplicaPool {
pub fn new(
pool: Arc<DbPool>,
lag_detector: Arc<dyn ReplicationLagDetector>,
max_lag_seconds: f64,
) -> Self {
Self {
pool,
lag_detector,
max_lag_seconds,
}
}
pub async fn get_read_session(&self, role: &str) -> Option<crate::Session> {
match self.lag_detector.detect_lag(&self.pool).await {
Ok(lag) if lag.is_caught_up => self.pool.get_session(role).await.ok(),
_ => None,
}
}
pub fn pool(&self) -> &Arc<DbPool> {
&self.pool
}
}
pub struct ReplicaNode {
pub name: String,
pub pool: Arc<DbPool>,
pub weight: u32,
pub lag_detector: Arc<dyn ReplicationLagDetector>,
}
struct NodeState {
node: ReplicaNode,
consecutive_failures: u32,
last_latency_ms: Option<u64>,
last_healthy: bool,
}
impl NodeState {
fn score(&self) -> f64 {
let latency = self.last_latency_ms.unwrap_or(0) as f64;
self.node.weight as f64 / (1.0 + latency)
}
}
pub struct ReplicaLoadBalancer {
primary: Arc<DbPool>,
nodes: Mutex<Vec<NodeState>>,
failure_threshold: u32,
last_selected: Mutex<Option<String>>,
}
impl ReplicaLoadBalancer {
pub fn new(
primary: Arc<DbPool>,
nodes: Vec<ReplicaNode>,
_config: crate::foundation::ReplicaConfig,
) -> Self {
let states = nodes
.into_iter()
.map(|node| NodeState {
node,
consecutive_failures: 0,
last_latency_ms: None,
last_healthy: true,
})
.collect();
Self {
primary,
nodes: Mutex::new(states),
failure_threshold: 3,
last_selected: Mutex::new(None),
}
}
pub fn failure_threshold(&self) -> u32 {
self.failure_threshold
}
pub fn last_selected_replica(&self) -> Option<String> {
self.last_selected.lock().expect("balancer lock").clone()
}
pub fn is_replica_evicted(&self, name: &str) -> bool {
self.nodes
.lock()
.expect("balancer lock")
.iter()
.any(|s| s.node.name == name && s.consecutive_failures >= self.failure_threshold)
}
pub fn revive_all(&self) {
let mut nodes = self.nodes.lock().expect("balancer lock");
for s in nodes.iter_mut() {
s.consecutive_failures = 0;
}
}
pub fn snapshot(&self) -> Vec<serde_json::Value> {
self.nodes
.lock()
.expect("balancer lock")
.iter()
.map(|s| {
serde_json::json!({
"name": s.node.name,
"weight": s.node.weight,
"healthy": s.last_healthy,
"evicted": s.consecutive_failures >= self.failure_threshold,
"consecutive_failures": s.consecutive_failures,
"last_probe_latency_ms": s.last_latency_ms,
})
})
.collect()
}
pub async fn get_write_session(
&self,
role: &str,
) -> crate::foundation::DbResult<crate::Session> {
self.primary.get_session(role).await
}
pub async fn get_read_session(
&self,
role: &str,
) -> crate::foundation::DbResult<crate::Session> {
let candidates: Vec<usize> = {
let nodes = self.nodes.lock().expect("balancer lock");
nodes
.iter()
.enumerate()
.filter(|(_, s)| s.consecutive_failures < self.failure_threshold)
.map(|(i, _)| i)
.collect()
};
let mut probed: Vec<(usize, bool, Option<u64>)> = Vec::with_capacity(candidates.len());
for idx in candidates {
let (pool, detector) = {
let nodes = self.nodes.lock().expect("balancer lock");
(
Arc::clone(&nodes[idx].node.pool),
Arc::clone(&nodes[idx].node.lag_detector),
)
};
let start = std::time::Instant::now();
let probe = detector.detect_lag(&pool).await;
let latency_ms = start.elapsed().as_millis() as u64;
let healthy = matches!(&probe, Ok(lag) if lag.is_caught_up);
probed.push((idx, healthy, Some(latency_ms)));
}
let ranked: Vec<usize> = {
let mut nodes = self.nodes.lock().expect("balancer lock");
for (idx, healthy, latency) in &probed {
let state = &mut nodes[*idx];
state.last_healthy = *healthy;
state.last_latency_ms = *latency;
if *healthy {
state.consecutive_failures = 0;
} else {
state.consecutive_failures = state.consecutive_failures.saturating_add(1);
}
}
let mut order: Vec<usize> = probed
.iter()
.filter(|(_, healthy, _)| *healthy)
.map(|(idx, _, _)| *idx)
.collect();
order.sort_by(|a, b| {
nodes[*b]
.score()
.partial_cmp(&nodes[*a].score())
.unwrap_or(std::cmp::Ordering::Equal)
});
order
};
for idx in ranked {
let pool = {
let nodes = self.nodes.lock().expect("balancer lock");
Arc::clone(&nodes[idx].node.pool)
};
let result = pool.get_session(role).await;
match result {
Ok(session) => {
let name = {
let nodes = self.nodes.lock().expect("balancer lock");
nodes[idx].node.name.clone()
};
*self.last_selected.lock().expect("balancer lock") = Some(name);
return Ok(session);
}
Err(_) => {
let mut nodes = self.nodes.lock().expect("balancer lock");
nodes[idx].consecutive_failures =
nodes[idx].consecutive_failures.saturating_add(1);
}
}
}
*self.last_selected.lock().expect("balancer lock") = None;
self.primary.get_session(role).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pg_wal_lag_zero() {
assert_eq!(parse_pg_wal_lag("0"), Some(0));
}
#[test]
fn test_parse_pg_wal_lag_positive_integer() {
assert_eq!(parse_pg_wal_lag("1048576"), Some(1048576));
}
#[test]
fn test_parse_pg_wal_lag_trims_whitespace() {
assert_eq!(parse_pg_wal_lag(" 42\n"), Some(42));
}
#[test]
fn test_parse_pg_wal_lag_fractional_truncates() {
assert_eq!(parse_pg_wal_lag("123.7"), Some(123));
}
#[test]
fn test_parse_pg_wal_lag_negative_invalid() {
assert_eq!(parse_pg_wal_lag("-1"), None);
}
#[test]
fn test_parse_pg_wal_lag_garbage_invalid() {
assert_eq!(parse_pg_wal_lag("abc"), None);
}
#[test]
fn test_parse_pg_wal_lag_empty_invalid() {
assert_eq!(parse_pg_wal_lag(""), None);
}
#[test]
fn test_parse_pg_wal_lag_nan_invalid() {
assert_eq!(parse_pg_wal_lag("NaN"), None);
}
#[test]
fn test_parse_mysql_seconds_behind_numeric() {
assert_eq!(
parse_mysql_seconds_behind(SecondsBehindRaw::Value(5)),
Some(5.0)
);
}
#[test]
fn test_parse_mysql_seconds_behind_zero() {
assert_eq!(
parse_mysql_seconds_behind(SecondsBehindRaw::Value(0)),
Some(0.0)
);
}
#[test]
fn test_parse_mysql_seconds_behind_null_broken_replication() {
assert_eq!(parse_mysql_seconds_behind(SecondsBehindRaw::Null), None);
}
#[test]
fn test_parse_mysql_seconds_behind_missing_column() {
assert_eq!(
parse_mysql_seconds_behind(SecondsBehindRaw::MissingColumn),
None
);
}
#[test]
fn test_parse_mysql_seconds_behind_negative_invalid() {
assert_eq!(
parse_mysql_seconds_behind(SecondsBehindRaw::Value(-3)),
None
);
}
#[test]
fn test_pg_caught_up_respects_max_lag_bytes() {
let detector = PostgresLagDetector::default();
let lag = parse_pg_wal_lag("1048576").unwrap(); assert!(lag <= detector.max_lag_bytes);
let huge = parse_pg_wal_lag("20971520").unwrap(); assert!(huge > detector.max_lag_bytes);
assert!(!parse_pg_wal_lag("bad").is_some_and(|lag| lag <= detector.max_lag_bytes));
}
#[test]
fn test_mysql_caught_up_respects_max_lag_seconds() {
let detector = MySqlLagDetector::default();
let ok = parse_mysql_seconds_behind(SecondsBehindRaw::Value(3)).unwrap();
assert!(ok <= detector.max_lag_seconds);
let late = parse_mysql_seconds_behind(SecondsBehindRaw::Value(30)).unwrap();
assert!(late > detector.max_lag_seconds);
assert!(
!parse_mysql_seconds_behind(SecondsBehindRaw::Null)
.is_some_and(|s| s <= detector.max_lag_seconds)
);
}
}