#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
pub(crate) mod counter;
pub(crate) mod membership;
pub(crate) mod node;
pub(crate) mod transport;
pub(crate) mod wire;
#[cfg(test)]
mod tests;
use std::fmt;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use secrecy::ExposeSecret as _;
use tokio_util::sync::CancellationToken;
use crate::config::ClusterConfig;
use crate::state::AppState;
use crate::time::ClockSource;
use crate::{AutumnError, AutumnResult};
pub use counter::ClusterCounter;
pub(crate) use node::LEAVE_BUDGET;
pub(crate) type NodeId = String;
pub(crate) type Incarnation = u64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ClusterMemberStatus {
Alive,
Suspect,
}
impl fmt::Display for ClusterMemberStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::Alive => f.write_str("alive"),
Self::Suspect => f.write_str("suspect"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClusterMemberInfo {
pub id: String,
pub addr: String,
pub status: ClusterMemberStatus,
pub incarnation: u64,
}
pub(crate) const REJECT_REASONS: [wire::RejectReason; 9] = [
wire::RejectReason::Oversize,
wire::RejectReason::Malformed,
wire::RejectReason::Version,
wire::RejectReason::KeyId,
wire::RejectReason::Cluster,
wire::RejectReason::Mac,
wire::RejectReason::SelfOrigin,
wire::RejectReason::Replay,
wire::RejectReason::Payload,
];
#[derive(Debug, Default)]
pub(crate) struct ClusterMetrics {
rejected_by_reason: [AtomicU64; REJECT_REASONS.len()],
pub(crate) merges_applied: AtomicU64,
pub(crate) pushes_sent: AtomicU64,
pub(crate) pushes_unsendable: AtomicU64,
pub(crate) unsendable_warned_at_ms: AtomicU64,
pub(crate) pushes_received: AtomicU64,
pub(crate) frames_dropped: AtomicU64,
pub(crate) framing_rejected: AtomicU64,
}
impl ClusterMetrics {
pub(crate) fn record_rejection(&self, reason: wire::RejectReason) {
if let Some(counter) = REJECT_REASONS
.iter()
.position(|candidate| *candidate == reason)
.and_then(|index| self.rejected_by_reason.get(index))
{
counter.fetch_add(1, Ordering::Relaxed);
}
}
pub(crate) fn rejections_by_reason(&self) -> Vec<(&'static str, u64)> {
let framing = self.framing_rejected.load(Ordering::Relaxed);
REJECT_REASONS
.iter()
.enumerate()
.filter_map(|(index, reason)| {
self.rejected_by_reason.get(index).map(|counter| {
let mut count = counter.load(Ordering::Relaxed);
if *reason == wire::RejectReason::Oversize {
count = count.saturating_add(framing);
}
(reason.label(), count)
})
})
.collect()
}
#[cfg(test)]
pub(crate) fn rejected_total(&self) -> u64 {
self.rejections_by_reason()
.into_iter()
.map(|(_, count)| count)
.fold(0, u64::saturating_add)
}
}
pub(crate) struct ClusterInner {
pub(crate) node_id: NodeId,
pub(crate) cluster_name: String,
pub(crate) local_addr: SocketAddr,
pub(crate) advertise_addr: String,
pub(crate) secret: Vec<u8>,
pub(crate) seed_peers: Vec<String>,
pub(crate) push_interval: Duration,
pub(crate) incarnation: AtomicU64,
pub(crate) state: Mutex<membership::ClusterState>,
pub(crate) overlay: Mutex<membership::LivenessOverlay>,
pub(crate) pruned_senders: Mutex<std::collections::BTreeSet<NodeId>>,
pub(crate) clock: Arc<dyn ClockSource>,
pub(crate) entropy: Arc<dyn crate::entropy::Entropy>,
pub(crate) transport: Arc<dyn transport::PeerTransport>,
pub(crate) shutdown: CancellationToken,
pub(crate) notify: tokio::sync::Notify,
pub(crate) metrics: ClusterMetrics,
}
impl ClusterInner {
pub(crate) fn lock_state(&self) -> std::sync::MutexGuard<'_, membership::ClusterState> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
pub(crate) fn lock_overlay(&self) -> std::sync::MutexGuard<'_, membership::LivenessOverlay> {
self.overlay.lock().unwrap_or_else(PoisonError::into_inner)
}
pub(crate) fn note_pruned_senders(&self, pruned: impl IntoIterator<Item = NodeId>) {
let mut queued = self
.pruned_senders
.lock()
.unwrap_or_else(PoisonError::into_inner);
queued.extend(pruned);
}
pub(crate) fn take_pruned_senders(&self) -> std::collections::BTreeSet<NodeId> {
let mut queued = self
.pruned_senders
.lock()
.unwrap_or_else(PoisonError::into_inner);
std::mem::take(&mut queued)
}
}
#[derive(Clone)]
pub struct ClusterHandle {
inner: Arc<ClusterInner>,
}
impl fmt::Debug for ClusterHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ClusterHandle")
.field("node_id", &self.inner.node_id)
.field("cluster_name", &self.inner.cluster_name)
.field("local_addr", &self.inner.local_addr)
.finish_non_exhaustive()
}
}
impl ClusterHandle {
pub(crate) const fn from_inner(inner: Arc<ClusterInner>) -> Self {
Self { inner }
}
#[must_use]
pub fn node_id(&self) -> &str {
&self.inner.node_id
}
#[must_use]
pub fn cluster_name(&self) -> &str {
&self.inner.cluster_name
}
#[must_use]
pub fn local_addr(&self) -> SocketAddr {
self.inner.local_addr
}
#[must_use]
pub fn members(&self) -> Vec<ClusterMemberInfo> {
let now = self.inner.clock.monotonic();
let incarnation = self.inner.incarnation.load(Ordering::Relaxed);
let state = self.inner.lock_state();
let overlay = self.inner.lock_overlay();
let mut view = Vec::with_capacity(state.members.len().saturating_add(1));
view.push(ClusterMemberInfo {
id: self.inner.node_id.clone(),
addr: self.inner.advertise_addr.clone(),
status: ClusterMemberStatus::Alive,
incarnation,
});
for (id, record) in &state.members {
if id == &self.inner.node_id || record.status != membership::MemberStatus::Alive {
continue;
}
let liveness = overlay.liveness(id, now);
if !liveness.in_view() {
continue;
}
view.push(ClusterMemberInfo {
id: id.clone(),
addr: record.addr.clone(),
status: if liveness == membership::Liveness::Suspect {
ClusterMemberStatus::Suspect
} else {
ClusterMemberStatus::Alive
},
incarnation: record.incarnation,
});
}
drop(overlay);
drop(state);
view
}
#[must_use]
pub fn counter(&self, name: &str) -> ClusterCounter {
ClusterCounter::new(Arc::clone(&self.inner), name.to_owned())
}
#[cfg(test)]
pub(crate) fn incarnation(&self) -> u64 {
self.inner.incarnation.load(Ordering::Relaxed)
}
#[cfg(test)]
pub(crate) fn frames_rejected_total(&self) -> u64 {
self.inner.metrics.rejected_total()
}
}
const INBOUND_IDLE_SUSPICION_MULTIPLE: u32 = 4;
pub(crate) const MEMBERSHIP_COMPONENT: &str = "cluster:membership";
struct ClusterHealthIndicator {
handle: ClusterHandle,
}
impl ClusterHealthIndicator {
fn snapshot(&self) -> crate::actuator::HealthCheckOutput {
let members = self.handle.members();
let rows: Vec<serde_json::Value> = members
.iter()
.map(|member| {
serde_json::json!({
"id": member.id,
"addr": member.addr,
"status": member.status.to_string(),
"incarnation": member.incarnation,
})
})
.collect();
let details = std::collections::HashMap::from([
(
"node_id".to_owned(),
serde_json::json!(self.handle.node_id()),
),
(
"cluster".to_owned(),
serde_json::json!(self.handle.cluster_name()),
),
(
"local_addr".to_owned(),
serde_json::json!(self.handle.local_addr().to_string()),
),
("member_count".to_owned(), serde_json::json!(members.len())),
("members".to_owned(), serde_json::Value::Array(rows)),
]);
crate::actuator::HealthCheckOutput::up().with_details(details)
}
}
impl crate::actuator::HealthIndicator for ClusterHealthIndicator {
fn check(&self) -> futures::future::BoxFuture<'_, crate::actuator::HealthCheckOutput> {
Box::pin(std::future::ready(self.snapshot()))
}
fn group(&self) -> crate::actuator::IndicatorGroup {
crate::actuator::IndicatorGroup::HealthOnly
}
}
struct ClusterMetricsSource {
handle: ClusterHandle,
}
impl crate::actuator::MetricsSource for ClusterMetricsSource {
#[allow(
clippy::cast_precision_loss,
reason = "Prometheus values are f64 by definition; these counters would \
have to pass 2^53 frames to lose a unit"
)]
fn collect(&self) -> Vec<crate::actuator::MetricFamily> {
use crate::actuator::{MetricFamily, MetricKind, MetricSample};
let metrics = &self.handle.inner.metrics;
let unlabelled = |value: u64| {
vec![MetricSample {
labels: Vec::new(),
value: value as f64,
}]
};
vec![
MetricFamily {
name: "autumn_cluster_members".to_owned(),
help: "Members in this node's local cluster view.".to_owned(),
kind: MetricKind::Gauge,
samples: unlabelled(self.handle.members().len() as u64),
},
MetricFamily {
name: "autumn_cluster_pushes_sent_total".to_owned(),
help: "State pushes handed to the cluster transport.".to_owned(),
kind: MetricKind::Counter,
samples: unlabelled(metrics.pushes_sent.load(Ordering::Relaxed)),
},
MetricFamily {
name: "autumn_cluster_pushes_unsendable_total".to_owned(),
help: "Outbound cluster messages that could not be signed or framed \
(almost always a document past the 64 KiB frame cap)."
.to_owned(),
kind: MetricKind::Counter,
samples: unlabelled(metrics.pushes_unsendable.load(Ordering::Relaxed)),
},
MetricFamily {
name: "autumn_cluster_pushes_received_total".to_owned(),
help: "State pushes accepted from peers after verification.".to_owned(),
kind: MetricKind::Counter,
samples: unlabelled(metrics.pushes_received.load(Ordering::Relaxed)),
},
MetricFamily {
name: "autumn_cluster_merges_applied_total".to_owned(),
help: "Merges that changed this node's replicated document.".to_owned(),
kind: MetricKind::Counter,
samples: unlabelled(metrics.merges_applied.load(Ordering::Relaxed)),
},
MetricFamily {
name: "autumn_cluster_frames_dropped_total".to_owned(),
help: "Frames the transport could not queue for a peer.".to_owned(),
kind: MetricKind::Counter,
samples: unlabelled(metrics.frames_dropped.load(Ordering::Relaxed)),
},
MetricFamily {
name: "autumn_cluster_frames_rejected_total".to_owned(),
help: "Inbound frames refused by the verifier, by reason.".to_owned(),
kind: MetricKind::Counter,
samples: metrics
.rejections_by_reason()
.into_iter()
.map(|(reason, count)| MetricSample {
labels: vec![("reason".to_owned(), reason.to_owned())],
value: count as f64,
})
.collect(),
},
]
}
}
pub fn install_from_config(
state: &AppState,
config: &ClusterConfig,
shutdown: &CancellationToken,
) -> AutumnResult<()> {
if !config.enabled {
return Ok(());
}
if state.extension::<ClusterHandle>().is_some() {
return Err(AutumnError::internal_server_error_msg(
"cluster: a ClusterHandle is already installed on this AppState — \
install_from_config must be called once per app, and a second node \
needs its own AppState",
));
}
config.validate().map_err(|error| {
AutumnError::internal_server_error_msg(format!("cluster: invalid configuration: {error}"))
})?;
let Some(secret) = config.secret.as_ref() else {
return Err(AutumnError::internal_server_error_msg(
"cluster.secret is required when cluster.enabled = true: the cluster transport is \
authenticated (HMAC-SHA256) and has no unauthenticated mode — set it with \
AUTUMN_CLUSTER__SECRET",
));
};
let secret = secret.expose_secret().as_bytes().to_vec();
let health_registry = state.health_indicator_registry();
let metrics_registry = state.metrics_source_registry();
if health_registry.contains(MEMBERSHIP_COMPONENT)
|| metrics_registry.contains(MEMBERSHIP_COMPONENT)
{
return Err(AutumnError::internal_server_error_msg(format!(
"cluster: the {MEMBERSHIP_COMPONENT} name is reserved for the cluster's own health \
component and metrics source, and this app has already registered it; rename the \
app's registration, because a cluster whose membership and rejection counters are \
invisible cannot be operated"
)));
}
let suspicion_timeout = Duration::from_millis(config.suspicion_timeout_ms);
let transport = transport::TcpPeerTransport::bind(&config.bind_addr)?
.with_inbound_idle_timeout(
suspicion_timeout
.saturating_mul(INBOUND_IDLE_SUSPICION_MULTIPLE)
.max(transport::DEFAULT_INBOUND_IDLE_TIMEOUT),
);
let runtime = node::ClusterRuntimeConfig {
cluster_name: config.cluster_name.clone(),
secret,
node_id: config.node_id.clone(),
advertise_addr: config.advertise_addr.clone(),
seed_peers: config.seed_peers.clone(),
push_interval: Duration::from_millis(config.push_interval_ms),
suspicion_timeout,
};
let node_shutdown = shutdown.child_token();
let handle = node::ClusterNode::start(
runtime,
state.entropy_arc(),
state.clock_arc(),
node_shutdown.clone(),
Arc::new(transport),
)?;
let registered = health_registry
.register(
MEMBERSHIP_COMPONENT,
crate::actuator::IndicatorGroup::HealthOnly,
Arc::new(ClusterHealthIndicator {
handle: handle.clone(),
}),
)
.and_then(|()| {
metrics_registry.register(
MEMBERSHIP_COMPONENT,
Arc::new(ClusterMetricsSource {
handle: handle.clone(),
}),
)
});
if let Err(error) = registered {
node_shutdown.cancel();
return Err(AutumnError::internal_server_error_msg(format!(
"cluster: {error} — the {MEMBERSHIP_COMPONENT} name is reserved for the cluster's \
own health component and metrics source; rename the app's registration, because a \
cluster whose membership and rejection counters are invisible cannot be operated"
)));
}
state.insert_extension(handle);
Ok(())
}
const JITTER_FLOOR_PERCENT: u64 = 80;
const JITTER_SPAN_PERCENT: u64 = 41;
pub(crate) fn jittered(base: Duration, entropy: &dyn crate::entropy::Entropy) -> Duration {
let base_ms = u64::try_from(base.as_millis()).unwrap_or(u64::MAX);
let draw = entropy
.next_u64()
.checked_rem(JITTER_SPAN_PERCENT)
.unwrap_or(0);
let percent = JITTER_FLOOR_PERCENT.saturating_add(draw);
let millis = base_ms
.saturating_mul(percent)
.checked_div(100)
.unwrap_or(base_ms);
Duration::from_millis(millis.max(1))
}
pub(crate) fn bind_error(addr: &str, err: &std::io::Error) -> AutumnError {
AutumnError::internal_server_error_msg(format!(
"cluster: failed to bind the cluster listener on {addr}: {err}"
))
}