1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
|
//! Whether this node is a stable member of the swarm, judged by itself.
//!
//! Long-running, well-connected nodes make a good backbone; laptops that sleep and
//! phones that lose signal don't. Each node measures what fraction of the last 24 hours
//! it had at least one live peer, and publishes only a coarse tier in its registry entry
//! (`_continuum/members/<pk>/tier`): `stable` or `transient`, written only when it
//! changes, with hysteresis. So the swarm learns about stability from the replicated
//! document alone, without anyone measuring anyone else, and the tier changes rarely.
//!
//! * transient → stable: at least 24 h observed, connected at least 95% of it;
//! * stable → transient: connected less than 80% (over at least an hour observed).
//!
//! After a restart a node has no history. If its entry says `stable` and a peer reports
//! (in its HELLO_REPLY) that it last heard from us less than an hour ago, the node was
//! only briefly away and keeps its tier, starting with a full day's credit. Otherwise it
//! starts over as transient. Time the process didn't run (a laptop asleep) counts as
//! disconnected.
use std::collections::VecDeque;
use std::time::Duration;
use tokio::time::Instant;
use tracing::{info, warn};
use crate::state::{AppState, HelloState};
use crate::swarm::members::{self, Tier};
const WINDOW: Duration = Duration::from_secs(24 * 3600);
const BUCKET: Duration = Duration::from_secs(5 * 60);
const BECOME_STABLE: f64 = 0.95;
const BECOME_TRANSIENT: f64 = 0.80;
/// A day observed (the oldest bucket may be partly outside the window).
const FULL_DAY: Duration = WINDOW.saturating_sub(BUCKET);
/// Less than this observed is too little to demote a stable node.
const MIN_TO_DEMOTE: Duration = Duration::from_secs(3600);
/// An absence shorter than this keeps a stable node stable across a restart.
const BRIEF_ABSENCE: Duration = Duration::from_secs(3600);
/// How long after becoming ready we wait for a peer to tell us how long we were away.
const AWAY_REPORT_GRACE: Duration = Duration::from_secs(30);
/// A gap between two measurements longer than this means we didn't run (asleep).
const MAX_TICK_GAP: Duration = Duration::from_secs(30);
#[derive(Debug, Default)]
pub(crate) struct Stability {
buckets: VecDeque<Bucket>,
last_tick: Option<Instant>,
/// The shortest absence any peer reported to us in this run.
away: Option<Duration>,
ready_since: Option<Instant>,
/// The decision about the tier we found after a restart is made.
settled: bool,
}
#[derive(Debug, Clone, Copy)]
struct Bucket {
start: Instant,
observed: Duration,
connected: Duration,
}
impl Stability {
fn record(&mut self, now: Instant, connected: bool) {
let Some(last) = self.last_tick.replace(now) else {
return;
};
let elapsed = now.saturating_duration_since(last).min(WINDOW);
let connected = connected && elapsed <= MAX_TICK_GAP;
if self
.buckets
.back()
.is_none_or(|bucket| now.saturating_duration_since(bucket.start) >= BUCKET)
{
self.buckets.push_back(Bucket {
start: now,
observed: Duration::ZERO,
connected: Duration::ZERO,
});
}
let bucket = self.buckets.back_mut().expect("just ensured");
bucket.observed += elapsed;
if connected {
bucket.connected += elapsed;
}
while self
.buckets
.front()
.is_some_and(|bucket| now.saturating_duration_since(bucket.start) > WINDOW)
{
self.buckets.pop_front();
}
}
/// A full day of connectivity, for a stable node that was only briefly away.
fn credit(&mut self, now: Instant) {
self.buckets.push_front(Bucket {
start: now,
observed: WINDOW,
connected: WINDOW,
});
}
fn observed(&self) -> Duration {
self.buckets.iter().map(|bucket| bucket.observed).sum()
}
fn fraction(&self) -> f64 {
let observed = self.observed().as_secs_f64();
if observed == 0.0 {
return 0.0;
}
let connected: f64 = self.buckets.iter().map(|b| b.connected.as_secs_f64()).sum();
connected / observed
}
/// The tier we should have, given the one we have now.
fn assess(&self, current: Tier) -> Tier {
match current {
Tier::Transient if self.observed() >= FULL_DAY && self.fraction() >= BECOME_STABLE => {
Tier::Stable
}
Tier::Stable
if self.observed() >= MIN_TO_DEMOTE && self.fraction() < BECOME_TRANSIENT =>
{
Tier::Transient
}
tier => tier,
}
}
}
fn lock(state: &AppState) -> std::sync::MutexGuard<'_, Stability> {
state
.stability
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// The fraction of the observed time (up to a day) we had a live peer, and how much
/// time was observed.
pub(crate) fn measured(state: &AppState) -> (f64, Duration) {
let stability = lock(state);
(stability.fraction(), stability.observed())
}
/// A HELLO_REPLY told us how long ago that peer last heard from us before this run.
pub(crate) fn note_away(state: &AppState, away: Option<Duration>) {
if let Some(away) = away {
let mut stability = lock(state);
stability.away = Some(stability.away.map_or(away, |known| known.min(away)));
}
}
/// Measures, and updates our tier in the registry when it should change. Called every
/// few seconds by the peer worker.
pub(crate) async fn tick(state: &AppState) {
let now = Instant::now();
let mut connected = false;
let peers: Vec<_> = state.peer_states.read().await.values().cloned().collect();
for peer_state in peers {
let peer_state = peer_state.lock().await;
if peer_state.hello_state == HelloState::Established
&& peer_state.is_alive(&state.timing, now)
{
connected = true;
break;
}
}
lock(state).record(now, connected);
if !state.is_ready().await {
return;
}
let local = state.local_id().to_owned();
let current = members::tier(&state.doc.read().await.doc, &local);
let desired = {
let mut stability = lock(state);
let ready_since = *stability.ready_since.get_or_insert(now);
if stability.settled {
stability.assess(current)
} else {
let brief = stability.away.is_some_and(|away| away < BRIEF_ABSENCE);
if current == Tier::Stable && !brief && now < ready_since + AWAY_REPORT_GRACE {
return; // a peer may still tell us we were only briefly away
}
stability.settled = true;
match current {
Tier::Stable if brief => {
stability.credit(now);
info!("Back after a brief absence; staying a stable member");
Tier::Stable
}
// Restarted without a report that we were only briefly away.
Tier::Stable => Tier::Transient,
Tier::Transient => Tier::Transient,
}
}
};
if desired == current {
return;
}
let fraction = lock(state).fraction();
match state
.mutate_doc(move |doc| members::set_tier(doc, &local, desired))
.await
{
Ok(()) => info!(
tier = desired.as_str(),
connected_percent = (fraction * 100.0).round(),
"Stability tier changed"
),
Err(error) => warn!(error = %error, "Failed to record our stability tier"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run(stability: &mut Stability, start: Instant, hours: u64, connected: impl Fn(u64) -> bool) {
let step = Duration::from_secs(5);
let steps = hours * 3600 / 5;
for i in 0..=steps {
stability.record(start + step * i as u32, connected(i));
}
}
#[test]
fn becomes_stable_after_a_well_connected_day() {
let mut stability = Stability::default();
let start = Instant::now();
run(&mut stability, start, 12, |_| true);
assert_eq!(stability.assess(Tier::Transient), Tier::Transient);
run(
&mut stability,
start + Duration::from_secs(12 * 3600),
13,
|_| true,
);
assert_eq!(stability.assess(Tier::Transient), Tier::Stable);
}
#[test]
fn hysteresis_between_the_thresholds() {
let mut stability = Stability::default();
// Connected 9 of every 10 measurements: 90%.
run(&mut stability, Instant::now(), 25, |i| i % 10 != 0);
assert_eq!(stability.assess(Tier::Transient), Tier::Transient);
assert_eq!(stability.assess(Tier::Stable), Tier::Stable);
}
#[test]
fn falls_back_to_transient_when_often_disconnected() {
let mut stability = Stability::default();
// Connected half the time.
run(&mut stability, Instant::now(), 2, |i| i % 2 == 0);
assert_eq!(stability.assess(Tier::Stable), Tier::Transient);
}
#[test]
fn time_asleep_counts_as_disconnected() {
let mut stability = Stability::default();
let start = Instant::now();
run(&mut stability, start, 1, |_| true);
// The process didn't run for two hours, then one more measurement.
stability.record(start + Duration::from_secs(3 * 3600), true);
assert!(stability.fraction() < 0.5);
}
}
|