Files
inbuxa-server/crates/store/src/build/cluster.rs
T
jcoffey-dev 5245abd08d
ci / fork-checks (pull_request) Successful in 38s
ci / build (pull_request) Successful in 7m24s
Report a node unhealthy after three minutes of silence
Every node renews its lease once a minute instead of every 30 minutes,
so the lease works as a heartbeat. x:ClusterNode reports a node Stale
once it has gone three minutes without renewing (it used to take an
hour), and Inactive after a day, as before.

Taking over a lease still needs a full hour of silence. A node that is
slow rather than gone never loses its id to another host, so snowflake
ids stay unique.

The admin dashboard's Cluster Health card counts these statuses.
2026-09-25 01:05:15 -07:00

370 lines
12 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{
IterateParams, RegistryStore, RegistryStoreInner, Store, U16_LEN, U32_LEN, U64_LEN, ValueKey,
write::{
BatchBuilder, ValueClass,
assert::AssertValue,
key::{DeserializeBigEndian, KeySerializer},
now,
},
};
use registry::{
schema::{enums::ClusterNodeStatus, structs::ClusterNode},
types::datetime::UTCDateTime,
};
use std::time::Duration;
use trc::AddContext;
use utils::snowflake::MAX_NODE_ID;
const STALE_NODE_TIMEOUT: u64 = 60 * 60; // 1 hour
const DEAD_NODE_TIMEOUT: u64 = 60 * 60 * 24; // 24 hours
// INBUXA: every node renews its lease once a minute, so the lease doubles as
// a heartbeat. A node not heard from in three minutes is reported Stale, which
// is what Cluster Health on the dashboard counts. Taking over a lease still
// needs the full hour of silence, so a node that is slow rather than gone
// never loses its id to another host.
const HEARTBEAT_INTERVAL: u64 = 60; // 1 minute
const UNRESPONSIVE_NODE_TIMEOUT: u64 = 3 * HEARTBEAT_INTERVAL;
const MAX_LEASE_RETRIES: u32 = 5;
struct NodeSlot {
node_id: u16,
hostname: String,
last_renewal: u64,
elapsed: u64,
hash: u64,
}
struct NodeClaim {
node_id: u16,
assert: AssertValue,
}
impl RegistryStoreInner {
pub(super) async fn acquire_node_id(&mut self) -> Result<(), String> {
let mut retry_count = 0;
let slots = loop {
let now = now();
let slots = NodeSlot::list(&self.store, now)
.await
.map_err(|err| format!("Failed to iterate store: {err}"))?;
let claim = NodeSlot::claim(&slots, &self.env_hostname)?;
let mut batch = BatchBuilder::new();
batch
.assert_value(ValueClass::NodeId(claim.node_id), claim.assert)
.set(
ValueClass::NodeId(claim.node_id),
KeySerializer::new(self.env_hostname.len() + U64_LEN)
.write(now)
.write(&self.env_hostname)
.finalize(),
);
match self.store.write(batch.build_all()).await {
Ok(_) => {
self.node_id = claim.node_id;
break slots;
}
Err(err) => {
if err.is_assertion_failure() && retry_count < MAX_LEASE_RETRIES {
retry_count += 1;
continue;
} else {
return Err(format!("Failed to write node id to store: {err}"));
}
}
}
};
if let Err(err) = NodeSlot::release(
&self.store,
slots
.iter()
.filter(|slot| slot.node_id != self.node_id && slot.is_dead()),
)
.await
{
trc::error!(err.details("Failed to release expired node id leases"));
}
Ok(())
}
}
impl RegistryStore {
pub fn node_id(&self) -> u16 {
self.0.node_id
}
pub fn refresh_node_id_interval(&self) -> Duration {
Duration::from_secs(HEARTBEAT_INTERVAL)
}
pub async fn cluster_node_list(&self) -> trc::Result<Vec<ClusterNode>> {
NodeSlot::list(&self.0.store, now())
.await
.map(|slots| slots.into_iter().map(ClusterNode::from).collect())
}
pub async fn refresh_node_id_lease(&self) -> trc::Result<()> {
let node_id = self.0.node_id;
let assert = match NodeSlot::list(&self.0.store, now())
.await
.caused_by(trc::location!())?
.into_iter()
.find(|slot| slot.node_id == node_id)
{
Some(slot) if slot.is_owned_by(&self.0.env_hostname) => AssertValue::Hash(slot.hash),
Some(slot) => {
return Err(trc::StoreEvent::AssertValueFailed
.into_err()
.details("Node id lease is held by another host")
.ctx(trc::Key::Id, node_id)
.ctx(trc::Key::Hostname, slot.hostname));
}
None => AssertValue::None,
};
let mut batch = BatchBuilder::new();
batch.assert_value(ValueClass::NodeId(node_id), assert).set(
ValueClass::NodeId(node_id),
KeySerializer::new(self.0.env_hostname.len() + U64_LEN)
.write(now())
.write(&self.0.env_hostname)
.finalize(),
);
self.0
.store
.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
pub async fn purge_dead_nodes(&self) -> trc::Result<()> {
let node_id = self.0.node_id;
let slots = NodeSlot::list(&self.0.store, now())
.await
.caused_by(trc::location!())?;
if !slots.iter().any(|slot| {
slot.node_id == node_id && slot.is_owned_by(&self.0.env_hostname) && !slot.is_stale()
}) {
Ok(())
} else {
NodeSlot::release(
&self.0.store,
slots
.iter()
.filter(|slot| slot.node_id != node_id && slot.is_dead()),
)
.await
}
}
}
impl NodeSlot {
async fn list(store: &Store, now: u64) -> trc::Result<Vec<NodeSlot>> {
let mut slots = Vec::new();
store
.iterate(
IterateParams::new(
ValueKey::from(ValueClass::NodeId(0)),
ValueKey::from(ValueClass::NodeId(u16::MAX)),
)
.ascending(),
|key, value| {
if key.len() == U16_LEN * 3 {
let node_id = key.deserialize_be_u16(U32_LEN)?;
match (
value.deserialize_be_u64(0),
value
.get(U64_LEN..)
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.filter(|text| !text.is_empty()),
) {
(Ok(last_renewal), Some(hostname)) => {
slots.push(NodeSlot {
node_id,
hostname: hostname.to_string(),
last_renewal,
elapsed: now.saturating_sub(last_renewal),
hash: xxhash_rust::xxh3::xxh3_64(value),
});
}
_ => {
trc::error!(
trc::StoreEvent::DataCorruption
.into_err()
.details("Invalid node id lease")
.ctx(trc::Key::Id, node_id)
);
}
}
}
Ok(true)
},
)
.await
.map(|_| slots)
}
fn claim(slots: &[NodeSlot], hostname: &str) -> Result<NodeClaim, String> {
if let Some(slot) = slots
.iter()
.find(|slot| slot.is_owned_by(hostname) && slot.is_assignable())
.or_else(|| {
slots
.iter()
.find(|slot| slot.is_stale() && slot.is_assignable())
})
{
return Ok(NodeClaim {
node_id: slot.node_id,
assert: AssertValue::Hash(slot.hash),
});
}
let mut leased = slots
.iter()
.filter(|slot| !slot.is_stale())
.map(|slot| slot.node_id)
.collect::<Vec<_>>();
leased.sort_unstable();
let mut node_id = 0;
for leased_id in leased {
if leased_id > node_id {
break;
}
node_id = leased_id.saturating_add(1);
if node_id > MAX_NODE_ID {
return Err(format!(
"Failed to obtain a node id: all {} ids are leased by active nodes",
MAX_NODE_ID as u32 + 1
));
}
}
Ok(NodeClaim {
node_id,
assert: AssertValue::None,
})
}
async fn release<'x>(
store: &Store,
slots: impl Iterator<Item = &'x NodeSlot>,
) -> trc::Result<()> {
for slot in slots {
let mut batch = BatchBuilder::new();
batch
.assert_value(
ValueClass::NodeId(slot.node_id),
AssertValue::Hash(slot.hash),
)
.clear(ValueClass::NodeId(slot.node_id));
if let Err(err) = store.write(batch.build_all()).await
&& !err.is_assertion_failure()
{
return Err(err.caused_by(trc::location!()));
}
}
Ok(())
}
fn is_owned_by(&self, hostname: &str) -> bool {
self.hostname == hostname
}
fn is_stale(&self) -> bool {
self.elapsed > STALE_NODE_TIMEOUT
}
fn is_dead(&self) -> bool {
self.elapsed > DEAD_NODE_TIMEOUT
}
fn is_responsive(&self) -> bool {
self.elapsed <= UNRESPONSIVE_NODE_TIMEOUT
}
fn is_assignable(&self) -> bool {
self.node_id <= MAX_NODE_ID
}
fn status(&self) -> ClusterNodeStatus {
if self.is_dead() {
ClusterNodeStatus::Inactive
} else if self.is_responsive() {
ClusterNodeStatus::Active
} else {
ClusterNodeStatus::Stale
}
}
}
impl From<NodeSlot> for ClusterNode {
fn from(slot: NodeSlot) -> Self {
ClusterNode {
status: slot.status(),
last_renewal: UTCDateTime::from_timestamp(slot.last_renewal.cast_signed()),
node_id: slot.node_id as u64,
hostname: slot.hostname,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn slot(elapsed: u64) -> NodeSlot {
NodeSlot {
node_id: 1,
hostname: "mx2.example.org".into(),
last_renewal: 0,
elapsed,
hash: 0,
}
}
#[test]
fn status_follows_the_heartbeat() {
assert_eq!(slot(0).status(), ClusterNodeStatus::Active);
assert_eq!(slot(UNRESPONSIVE_NODE_TIMEOUT).status(), ClusterNodeStatus::Active);
assert_eq!(slot(UNRESPONSIVE_NODE_TIMEOUT + 1).status(), ClusterNodeStatus::Stale);
assert_eq!(slot(DEAD_NODE_TIMEOUT).status(), ClusterNodeStatus::Stale);
assert_eq!(slot(DEAD_NODE_TIMEOUT + 1).status(), ClusterNodeStatus::Inactive);
}
#[test]
fn a_silent_node_keeps_its_id_for_an_hour() {
// Reported Stale after three minutes, but not free to take over.
let quiet = slot(UNRESPONSIVE_NODE_TIMEOUT + 1);
assert_eq!(quiet.status(), ClusterNodeStatus::Stale);
assert!(!quiet.is_stale());
assert!(slot(STALE_NODE_TIMEOUT + 1).is_stale());
}
#[test]
fn several_renewals_fit_before_a_node_looks_unresponsive() {
assert!(UNRESPONSIVE_NODE_TIMEOUT >= 3 * HEARTBEAT_INTERVAL);
assert!(HEARTBEAT_INTERVAL * 2 < STALE_NODE_TIMEOUT);
}
}