diff options
| author | tv <tv@krebsco.de> | 2026-10-24 13:05:54 +0200 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-24 13:05:54 +0200 |
| commit | c852ea8478963f5298e5b186ca5e1d7284504fd1 (patch) | |
| tree | 6b9405ab6735fe46bad45432e8c1b034b2503237 /src/swarm/members.rs | |
| parent | 871c6ac9bc5244875eb1ad09a37359bbf751297b (diff) | |
add electorate
For an upcoming change in remake coordination, nodes now maintain
their self-assessed liveness in the document.
Diffstat (limited to 'src/swarm/members.rs')
| -rw-r--r-- | src/swarm/members.rs | 109 |
1 files changed, 101 insertions, 8 deletions
diff --git a/src/swarm/members.rs b/src/swarm/members.rs index dcb47ce..221437e 100644 --- a/src/swarm/members.rs +++ b/src/swarm/members.rs @@ -9,14 +9,16 @@ //! cancels the record and registers again. //! //! Besides its tier, an entry holds the alias a node gives itself (`--alias`), a name -//! for people reading /peers and metrics. Not unique and never an identity: the -//! public key stays that. +//! for people reading /peers and metrics (not unique and never an identity: the public +//! key stays that), and an `alive` stamp the node renews now and then: the members +//! renewed within `--electorate-window-days` are the electorate of a remake. use std::collections::HashMap; +use std::time::Duration; use automerge::AutoCommit; use serde_json::{Value as JsonValue, json}; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; use crate::document::store::{self, StoreError}; use crate::state::{AppState, unix_millis}; @@ -107,26 +109,69 @@ fn set_alias(doc: &mut AutoCommit, pk: &str, alias: Option<&str>) -> Result<(), } } -/// Registers this node if it is ready and its entry is missing, and keeps its alias -/// as configured (set, changed or removed after a restart with another `--alias`). +fn alive_path(pk: &str) -> Vec<String> { + let mut path = member_path(pk); + path.push("alive".to_owned()); + path +} + +fn millis_at(doc: &AutoCommit, path: &[String]) -> Option<u64> { + match store::read_path(doc, path) { + Ok(Some(JsonValue::Number(number))) => number.as_u64(), + _ => None, + } +} + +/// When `pk` last said it is alive (Unix ms): its last renewal, or its registration. +pub(crate) fn alive_at(doc: &AutoCommit, pk: &str) -> Option<u64> { + let mut since = member_path(pk); + since.push("since".to_owned()); + millis_at(doc, &alive_path(pk)).max(millis_at(doc, &since)) +} + +/// The members that said they are alive within `window` before `now_ms` (sorted): the +/// electorate of a remake (`remake::coordinator`). +pub(crate) fn electorate(doc: &AutoCommit, now_ms: u64, window: Duration) -> Vec<String> { + let oldest = now_ms.saturating_sub(window.as_millis() as u64); + members(doc) + .into_iter() + .filter(|pk| alive_at(doc, pk).is_some_and(|at| at >= oldest)) + .collect() +} + +/// How often a node renews its `alive` stamp: often enough that a live node never +/// drops out of the electorate (a seventh of the window), but at most hourly and at +/// least daily, so idle swarms stay quiet. +fn alive_renewal(window: Duration) -> Duration { + (window / 7).clamp(Duration::from_secs(3600), Duration::from_secs(24 * 3600)) +} + +/// Registers this node if it is ready and its entry is missing, keeps its alias as +/// configured (set, changed or removed after a restart with another `--alias`), and +/// renews its `alive` stamp now and then (see `electorate`). pub(crate) async fn ensure_registered(state: &AppState) { if !state.is_ready().await { return; } let local = state.local_id().to_owned(); let own = member_path(&local); - let (registered, recorded_alias) = { + let (registered, recorded_alias, alive) = { let replica = state.doc.read().await; let registered = store::read_path(&replica.doc, &own) .ok() .flatten() .is_some(); - (registered, alias(&replica.doc, &local)) + ( + registered, + alias(&replica.doc, &local), + millis_at(&replica.doc, &alive_path(&local)), + ) }; let wanted = state.alias.clone(); + let now = unix_millis(); if !registered { - let mut entry = json!({ "since": unix_millis() }); + let mut entry = json!({ "since": now, "alive": now }); if let Some(alias) = &wanted { entry["alias"] = json!(alias); } @@ -143,6 +188,18 @@ pub(crate) async fn ensure_registered(state: &AppState) { return; } + let renewal = alive_renewal(state.electorate_window).as_millis() as u64; + if alive.is_none_or(|at| now.saturating_sub(at) >= renewal) { + let path = alive_path(&local); + match state + .mutate_doc(move |doc| store::write_path(doc, &path, &json!(now))) + .await + { + Ok(()) => debug!("Renewed our alive stamp in the member list"), + Err(error) => warn!(error = %error, "Failed to renew our alive stamp"), + } + } + if recorded_alias == wanted { return; } @@ -167,3 +224,39 @@ pub(crate) fn unregister(doc: &mut AutoCommit, pk: &str) -> Result<(), StoreErro result => result, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn electorate_by_alive_stamp() { + let day = 24 * 3600 * 1000; + let now = 100 * day; + let mut doc = AutoCommit::new(); + let entries = [ + ("fresh", json!({ "since": 1, "alive": now - day })), + ("stale", json!({ "since": 1, "alive": now - 8 * day })), + ("new", json!({ "since": now - day })), + ("old", json!({ "since": 1 })), + ]; + for (pk, entry) in entries { + store::write_path(&mut doc, &member_path(pk), &entry).unwrap(); + } + assert_eq!( + electorate(&doc, now, Duration::from_secs(7 * 24 * 3600)), + ["fresh", "new"] + ); + } + + #[test] + fn renewal_interval() { + let day = Duration::from_secs(24 * 3600); + assert_eq!(alive_renewal(7 * day), day); + assert_eq!(alive_renewal(70 * day), day); + assert_eq!(alive_renewal(day), day / 7); + // The hourly floor only matters for windows shorter than the option allows. + let hour = Duration::from_secs(3600); + assert_eq!(alive_renewal(2 * hour), hour); + } +} |
