Skip to main content

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}