use std::sync::Arc;
use std::sync::atomic::AtomicI64;
use crate::config::ClusterConfig;
use crate::connector::cache_backend::CacheBackend;
use crate::errors::OrionError;
use crate::storage::DbPool;
use crate::storage::repositories::cluster::{ClusterRepository, SqlClusterRepository};
pub mod epoch_watcher;
pub mod job_lease;
pub use epoch_watcher::start_cluster_tasks;
pub use job_lease::JobLeaseGate;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum EpochScope {
Definitions,
Connectors,
#[default]
All,
}
impl EpochScope {
pub fn as_str(self) -> &'static str {
match self {
Self::Definitions => "definitions",
Self::Connectors => "connectors",
Self::All => "all",
}
}
pub fn parse(raw: &str) -> Self {
match raw {
"definitions" => Self::Definitions,
"connectors" => Self::Connectors,
_ => Self::All,
}
}
pub fn for_advance(from: i64, to: i64, scope_at: i64, raw: &str) -> Self {
if scope_at == to && to == from + 1 {
Self::parse(raw)
} else {
Self::All
}
}
pub fn touches_connectors(self) -> bool {
matches!(self, Self::Connectors | Self::All)
}
}
pub struct ClusterRuntime {
pub enabled: bool,
pub instance_id: String,
pub redis: Option<redis::aio::ConnectionManager>,
pub default_cache: Option<Arc<dyn CacheBackend>>,
pub repo: Arc<dyn ClusterRepository>,
pub last_seen_epoch: AtomicI64,
pub last_seen_breaker_epoch: AtomicI64,
propagation_degraded: std::sync::atomic::AtomicBool,
}
impl ClusterRuntime {
pub async fn bump_config_epoch(&self, scope: EpochScope) {
use std::sync::atomic::Ordering;
match self.repo.bump_epoch(scope.as_str()).await {
Ok(epoch) => {
let _ = self.last_seen_epoch.compare_exchange(
epoch - 1,
epoch,
Ordering::AcqRel,
Ordering::Relaxed,
);
self.propagation_degraded.store(false, Ordering::Release);
}
Err(e) if self.enabled => {
self.propagation_degraded.store(true, Ordering::Release);
crate::metrics::record_error("config_epoch_bump");
tracing::error!(
error = %e,
scope = scope.as_str(),
"Failed to advance the config epoch: this node's change is live \
but peers will not see it until a later bump succeeds"
);
}
Err(e) => {
tracing::warn!(error = %e, "Failed to bump config epoch (cluster disabled — ignored)");
}
}
}
pub fn propagation_degraded(&self) -> bool {
self.propagation_degraded
.load(std::sync::atomic::Ordering::Acquire)
}
}
impl From<&ClusterRuntime> for crate::channel::registry::ClusterBackends {
fn from(runtime: &ClusterRuntime) -> Self {
Self {
default_cache: runtime.default_cache.clone(),
redis: runtime.redis.clone(),
}
}
}
pub async fn init_cluster_runtime(
config: &ClusterConfig,
pool: &DbPool,
) -> Result<Arc<ClusterRuntime>, OrionError> {
let instance_id = config.effective_instance_id();
let (redis, default_cache) = if config.enabled {
let client =
redis::Client::open(config.redis_url.as_str()).map_err(|e| OrionError::Config {
message: format!("cluster.redis_url is invalid: {e}"),
})?;
let conn = client
.get_connection_manager()
.await
.map_err(|e| OrionError::Internal {
context: "Failed to connect to cluster Redis (cluster.redis_url)".to_string(),
source: Some(Box::new(e)),
})?;
let cache: Arc<dyn CacheBackend> = Arc::new(
crate::connector::cache_backend::RedisCacheBackend::new(conn.clone()),
);
(Some(conn), Some(cache))
} else {
(None, None)
};
let repo: Arc<dyn ClusterRepository> = Arc::new(SqlClusterRepository::new(pool.clone()));
let (epoch, breaker_epoch) = if config.enabled {
let row = repo.get_epoch().await?;
(row.epoch, row.breaker_epoch)
} else {
(0, 0)
};
Ok(Arc::new(ClusterRuntime {
enabled: config.enabled,
instance_id,
redis,
default_cache,
repo,
last_seen_epoch: AtomicI64::new(epoch),
last_seen_breaker_epoch: AtomicI64::new(breaker_epoch),
propagation_degraded: std::sync::atomic::AtomicBool::new(false),
}))
}
#[cfg(test)]
mod tests {
use super::EpochScope;
#[test]
fn a_scope_survives_the_column() {
for scope in [
EpochScope::Definitions,
EpochScope::Connectors,
EpochScope::All,
] {
assert_eq!(EpochScope::parse(scope.as_str()), scope);
}
}
#[test]
fn an_absent_or_unknown_scope_resyncs_everything() {
assert_eq!(EpochScope::parse(""), EpochScope::All);
assert_eq!(EpochScope::parse("something-newer"), EpochScope::All);
assert_eq!(EpochScope::default(), EpochScope::All);
assert!(EpochScope::All.touches_connectors());
}
#[test]
fn a_scope_left_over_from_an_earlier_epoch_is_not_trusted() {
assert_eq!(
EpochScope::for_advance(6, 7, 7, "definitions"),
EpochScope::Definitions
);
assert_eq!(
EpochScope::for_advance(7, 8, 7, "definitions"),
EpochScope::All
);
assert_eq!(
EpochScope::for_advance(98, 99, 7, "connectors"),
EpochScope::All
);
}
#[test]
fn an_advance_over_several_bumps_resyncs_everything() {
assert_eq!(
EpochScope::for_advance(0, 3, 3, "definitions"),
EpochScope::All
);
assert_eq!(
EpochScope::for_advance(0, 3, 3, "connectors"),
EpochScope::All
);
assert_eq!(
EpochScope::for_advance(2, 3, 3, "connectors"),
EpochScope::Connectors
);
}
#[test]
fn a_row_predating_the_binding_column_resyncs_everything() {
assert_eq!(EpochScope::for_advance(0, 1, 0, ""), EpochScope::All);
assert_eq!(
EpochScope::for_advance(41, 42, 0, "connectors"),
EpochScope::All
);
}
#[test]
fn an_unknown_scope_is_wide_even_when_it_matches_the_epoch() {
assert_eq!(
EpochScope::for_advance(2, 3, 3, "something-newer"),
EpochScope::All
);
assert_eq!(EpochScope::for_advance(2, 3, 3, ""), EpochScope::All);
assert_eq!(
EpochScope::for_advance(2, 3, 3, "connectors"),
EpochScope::Connectors
);
}
#[test]
fn a_definitions_change_leaves_the_connector_pools_alone() {
assert!(!EpochScope::Definitions.touches_connectors());
assert!(EpochScope::Connectors.touches_connectors());
}
use super::*;
async fn sqlite_pool() -> DbPool {
crate::storage::test_sqlite_pool().await
}
#[tokio::test]
async fn test_disabled_runtime_is_inert() {
let runtime = init_cluster_runtime(&ClusterConfig::default(), &sqlite_pool().await)
.await
.expect("disabled runtime never fails");
assert!(!runtime.enabled);
assert!(runtime.redis.is_none());
assert!(runtime.default_cache.is_none());
assert_eq!(runtime.instance_id.len(), 36); }
#[tokio::test]
async fn test_configured_instance_id_wins() {
let config = ClusterConfig {
instance_id: "node-7".to_string(),
..Default::default()
};
let runtime = init_cluster_runtime(&config, &sqlite_pool().await)
.await
.expect("runtime");
assert_eq!(runtime.instance_id, "node-7");
}
}