borg_mesh/daemon.rs
1//! `borg mesh serve`: one node, running until it is told to stop.
2//!
3//! - **Accepts** members dialling in, each session in its own task (sync.rs gates every one).
4//! - **Dials** every member listed in peers.json: every `interval`, and at once when the store
5//! changed locally (a hook wrote a lesson or an excerpt, a session brought new lines, `revoke`
6//! re-signed the membership), checked every `poll`. Each peer is dialled on its own, side by side:
7//! one that does not answer holds up no other for its `timeout`. A change made while a peer's dial
8//! runs, or that a failed dial did not carry, is owed to it and dialled once that dial ends or its
9//! backoff is over. A changed membership is applied to the logs (quarantine) at once, before the
10//! round. New lines therefore travel on: what B learns from A reaches C in B's next round, at the
11//! next `poll` once the session ends.
12//! - **Backs off** per peer on failure: `poll` after the first, then `interval · 2^(failures - 1)`,
13//! at most `backoff_max`; a success resets it. A failed dial also drops the peer from
14//! `mesh/synced.json` (borg presence.rs).
15//! - **Re-checks the gate every round** on the config as it is then: once the collective turns
16//! local or off (or the repo moves to another store) no round dials anyone, and incoming
17//! sessions are refused by sync.rs. Turning it back on resumes without a restart.
18//! - **Stops cleanly** when `shutdown` resolves: no new sessions, running ones get `grace`, then
19//! the endpoint closes (peers see a closed connection, not a timeout).
20//!
21//! Not a coordinator: every node runs the same loop, and the hooks never depend on it.
22
23use std::collections::{BTreeMap, BTreeSet, HashMap};
24use std::future::Future;
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27use std::time::{Duration, SystemTime};
28
29use tokio::task::JoinSet;
30use tokio::time::Instant;
31
32use crate::addr::Target;
33use crate::membership::{self, Membership};
34use crate::peers;
35use crate::sync::{Node, Report};
36
37/// The loop's timing. `Default` is the one place its values are written: `borg mesh serve`'s
38/// `--interval` default and the bounds of the one-shot commands (main.rs) read them from it.
39#[derive(Debug, Clone, Copy)]
40pub struct Options {
41 /// How often every peer is dialled when nothing changed locally (60 s). Guard: bounds how stale
42 /// a node can be when a peer's change never triggered a dial (a member's own round failed, a
43 /// change that arrived over a third node); a local change is carried at the next `poll` anyway.
44 pub interval: Duration,
45 /// How often the local files are checked for a change to carry (2 s). Method: the polling
46 /// period of a `stat` per synced file; a change made here is dialled within this much.
47 pub poll: Duration,
48 /// The longest backoff after failed dials (30 min). Guard: bounds the doubling, so a peer back
49 /// after a long outage is dialled again within this much.
50 pub backoff_max: Duration,
51 /// Longest one dial may take before it counts as a failure (30 s). Guard: a peer that accepts
52 /// and never answers holds up only its own dial, and only this long.
53 pub timeout: Duration,
54 /// How long running sessions get to finish at a stop (5 s). Guard: Ctrl-C ends the node even
55 /// when a peer's session hangs.
56 pub grace: Duration,
57}
58
59impl Default for Options {
60 fn default() -> Self {
61 Options {
62 interval: Duration::from_secs(60),
63 poll: Duration::from_secs(2),
64 backoff_max: Duration::from_secs(30 * 60),
65 timeout: Duration::from_secs(30),
66 grace: Duration::from_secs(5),
67 }
68 }
69}
70
71/// What the loop tells its caller (the CLI prints it, tests count it).
72#[derive(Debug, Clone)]
73pub enum Event {
74 Served(Result<Report, String>),
75 Synced {
76 peer: String,
77 result: Result<Report, String>,
78 },
79 /// The gate closed (or opened again): no round runs while closed.
80 Gate(Result<(), String>),
81}
82
83#[derive(Default)]
84struct Backoff {
85 failures: u32,
86 next: Option<Instant>,
87}
88
89pub type State = BTreeMap<PathBuf, (u64, Option<SystemTime>)>;
90
91/// A cheap fingerprint of what a round would carry: size and mtime of the synced files.
92fn local_state(home: &Path) -> State {
93 let mut files = crate::lines::logs(home);
94 files.push(membership::path(home));
95 state(files)
96}
97
98/// One stat per file; a missing one reads (0, None).
99fn state(files: Vec<PathBuf>) -> State {
100 files
101 .into_iter()
102 .map(|p| {
103 let at = std::fs::metadata(&p).map_or((0, None), |m| (m.len(), m.modified().ok()));
104 (p, at)
105 })
106 .collect()
107}
108
109/// How long a peer waits after its `failures`th failure in a row: `poll` after the first (a session
110/// that failed once, under load or on a network hiccup, is retried at once), then `interval ·
111/// 2^(failures - 1)`, at most `backoff_max`.
112fn backoff_wait(opts: &Options, failures: u32) -> Duration {
113 match failures {
114 0 | 1 => opts.poll,
115 n => opts.interval.saturating_mul(1u32 << (n - 1).min(16)).min(opts.backoff_max),
116 }
117}
118
119pub async fn serve(node: Arc<Node>, opts: Options, shutdown: impl Future<Output = ()>, on: impl Fn(Event)) {
120 let mut sessions: JoinSet<Result<Report, String>> = JoinSet::new();
121 // One dial per peer, side by side: a peer that does not answer holds up no other.
122 let mut dials: JoinSet<Result<Report, String>> = JoinSet::new();
123 let mut dialling: HashMap<tokio::task::Id, String> = HashMap::new();
124 // The members a change has yet to reach: each dialled once not dialling already and not backing
125 // off, so a change during a dial, or before a failure, reaches its peer after it.
126 let mut owed: BTreeSet<String> = BTreeSet::new();
127 let mut backoff: HashMap<String, Backoff> = HashMap::new();
128 let mut seen: Option<State> = None;
129 let mut last_round: Option<Instant> = None;
130 let mut dirty = true;
131 let mut gate_open: Option<bool> = None;
132 // The gate and the store are looked at every `poll`.
133 let mut tick = tokio::time::interval(opts.poll);
134 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
135 tokio::pin!(shutdown);
136 loop {
137 tokio::select! {
138 _ = &mut shutdown => break,
139 incoming = node.incoming() => match incoming {
140 Some(i) => {
141 let n = node.clone();
142 sessions.spawn(async move { n.handle(i).await });
143 }
144 None => break,
145 },
146 Some(done) = sessions.join_next() => {
147 let result = done.unwrap_or_else(|e| Err(format!("session task: {e}")));
148 if result.as_ref().is_ok_and(|r| r.received > 0 || r.quarantined > 0 || r.steered > 0 || r.joined > 0) {
149 dirty = true; // pass it on to the others
150 }
151 on(Event::Served(result));
152 }
153 Some(done) = dials.join_next_with_id() => {
154 let (id, result) = match done {
155 Ok((id, r)) => (id, r),
156 Err(e) => (e.id(), Err(format!("dial task: {e}"))),
157 };
158 let Some(peer) = dialling.remove(&id) else { continue };
159 let b = backoff.entry(peer.clone()).or_default();
160 match &result {
161 Ok(r) => {
162 *b = Backoff::default();
163 dirty |= r.received > 0 || r.quarantined > 0 || r.steered > 0;
164 }
165 Err(_) => {
166 b.failures = b.failures.saturating_add(1);
167 b.next = Some(Instant::now() + backoff_wait(&opts, b.failures));
168 owed.insert(peer.clone()); // what it did not get, once the wait is over
169 node.unreachable(&peer);
170 }
171 }
172 on(Event::Synced { peer, result });
173 }
174 _ = tick.tick() => {
175 let gate = node.gate().map(|_| ());
176 if gate_open != Some(gate.is_ok()) {
177 gate_open = Some(gate.is_ok());
178 on(Event::Gate(gate.clone()));
179 }
180 // A closing gate also refuses every session it would start (sync.rs).
181 if gate_open != Some(true) {
182 continue;
183 }
184 let mut changed = false;
185 let mut now_state = local_state(node.home());
186 if seen.as_ref() != Some(&now_state) {
187 // A `join` or `revoke` run beside the daemon changed the membership: quarantine
188 // what it no longer admits before anything is offered (no-op when already done).
189 let _ = node.enforce();
190 let _ = node.stamp(); // a hook's new lines are stamped as this node's when first seen
191 // What those two wrote (fresh outcomes, a quarantine) goes in this round, not the next.
192 now_state = local_state(node.home());
193 changed = true;
194 }
195 seen = Some(now_state);
196 let round = dirty || changed || last_round.is_none_or(|t: Instant| t.elapsed() >= opts.interval);
197 let now = Instant::now();
198 let ready = |k: &String| !dialling.values().any(|d| d == k) && backoff.get(k).and_then(|b| b.next).is_none_or(|at| at <= now);
199 // Peers.json and the membership are read only when a dial may start: not every tick a
200 // peer that backs off is owed a change.
201 if !round && !owed.iter().any(ready) {
202 continue;
203 }
204 let all = targets(&node);
205 if round {
206 (dirty, last_round) = (false, Some(now));
207 owed.extend(all.iter().map(|t| t.key.clone()));
208 }
209 // A member no longer listed or admitted is owed nothing.
210 owed.retain(|k| all.iter().any(|t| t.key == *k));
211 let due: Vec<Target> = all.into_iter().filter(|t| owed.contains(&t.key) && ready(&t.key)).collect();
212 for t in due {
213 owed.remove(&t.key);
214 let (n, timeout, key) = (node.clone(), opts.timeout, t.key.clone());
215 let handle = dials.spawn(async move {
216 match tokio::time::timeout(timeout, n.sync(&t.to_string())).await {
217 Ok(r) => r,
218 Err(_) => Err(format!("no answer within {timeout:?}")),
219 }
220 });
221 dialling.insert(handle.id(), key);
222 }
223 }
224 }
225 }
226 // Clean shutdown: no new sessions; running ones get the grace period, then the endpoint closes.
227 let _ = tokio::time::timeout(opts.grace, async {
228 while sessions.join_next().await.is_some() {}
229 while dials.join_next().await.is_some() {}
230 })
231 .await;
232 sessions.abort_all();
233 dials.abort_all();
234 node.close().await;
235}
236
237/// The members a round dials: listed in peers.json, still members, not this node.
238fn targets(node: &Node) -> Vec<Target> {
239 let Ok(Some(m)) = Membership::load(node.home()) else { return Vec::new() };
240 let me = node.public();
241 peers::targets(node.home()).into_iter().filter(|t| t.key != me && m.role_of(&t.key).is_some()).collect()
242}