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#[derive(Debug, Clone, Copy)]
38pub struct Options {
39    pub interval: Duration,
40    pub poll: Duration,
41    pub backoff_max: Duration,
42    /// Longest one dial may take before it counts as a failure.
43    pub timeout: Duration,
44    pub grace: Duration,
45}
46
47impl Default for Options {
48    fn default() -> Self {
49        Options {
50            interval: Duration::from_secs(60),
51            poll: Duration::from_secs(2),
52            backoff_max: Duration::from_secs(30 * 60),
53            timeout: Duration::from_secs(30),
54            grace: Duration::from_secs(5),
55        }
56    }
57}
58
59/// What the loop tells its caller (the CLI prints it, tests count it).
60#[derive(Debug, Clone)]
61pub enum Event {
62    Served(Result<Report, String>),
63    Synced {
64        peer: String,
65        result: Result<Report, String>,
66    },
67    /// The gate closed (or opened again): no round runs while closed.
68    Gate(Result<(), String>),
69}
70
71#[derive(Default)]
72struct Backoff {
73    failures: u32,
74    next: Option<Instant>,
75}
76
77pub type State = BTreeMap<PathBuf, (u64, Option<SystemTime>)>;
78
79/// A cheap fingerprint of what a round would carry: size and mtime of the synced files.
80fn local_state(home: &Path) -> State {
81    let mut files = crate::lines::logs(home);
82    files.push(membership::path(home));
83    state(files)
84}
85
86/// One stat per file; a missing one reads (0, None).
87fn state(files: Vec<PathBuf>) -> State {
88    files
89        .into_iter()
90        .map(|p| {
91            let at = std::fs::metadata(&p).map_or((0, None), |m| (m.len(), m.modified().ok()));
92            (p, at)
93        })
94        .collect()
95}
96
97/// How long a peer waits after its `failures`th failure in a row: `poll` after the first (a session
98/// that failed once, under load or on a network hiccup, is retried at once), then `interval ·
99/// 2^(failures - 1)`, at most `backoff_max`.
100fn backoff_wait(opts: &Options, failures: u32) -> Duration {
101    match failures {
102        0 | 1 => opts.poll,
103        n => opts.interval.saturating_mul(1u32 << (n - 1).min(16)).min(opts.backoff_max),
104    }
105}
106
107pub async fn serve(node: Arc<Node>, opts: Options, shutdown: impl Future<Output = ()>, on: impl Fn(Event)) {
108    let mut sessions: JoinSet<Result<Report, String>> = JoinSet::new();
109    // One dial per peer, side by side: a peer that does not answer holds up no other.
110    let mut dials: JoinSet<Result<Report, String>> = JoinSet::new();
111    let mut dialling: HashMap<tokio::task::Id, String> = HashMap::new();
112    // The members a change has yet to reach: each dialled once not dialling already and not backing
113    // off, so a change during a dial, or before a failure, reaches its peer after it.
114    let mut owed: BTreeSet<String> = BTreeSet::new();
115    let mut backoff: HashMap<String, Backoff> = HashMap::new();
116    let mut seen: Option<State> = None;
117    let mut last_round: Option<Instant> = None;
118    let mut dirty = true;
119    let mut gate_open: Option<bool> = None;
120    // The gate and the store are looked at every `poll`.
121    let mut tick = tokio::time::interval(opts.poll);
122    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
123    tokio::pin!(shutdown);
124    loop {
125        tokio::select! {
126            _ = &mut shutdown => break,
127            incoming = node.incoming() => match incoming {
128                Some(i) => {
129                    let n = node.clone();
130                    sessions.spawn(async move { n.handle(i).await });
131                }
132                None => break,
133            },
134            Some(done) = sessions.join_next() => {
135                let result = done.unwrap_or_else(|e| Err(format!("session task: {e}")));
136                if result.as_ref().is_ok_and(|r| r.received > 0 || r.quarantined > 0 || r.steered > 0 || r.joined > 0) {
137                    dirty = true; // pass it on to the others
138                }
139                on(Event::Served(result));
140            }
141            Some(done) = dials.join_next_with_id() => {
142                let (id, result) = match done {
143                    Ok((id, r)) => (id, r),
144                    Err(e) => (e.id(), Err(format!("dial task: {e}"))),
145                };
146                let Some(peer) = dialling.remove(&id) else { continue };
147                let b = backoff.entry(peer.clone()).or_default();
148                match &result {
149                    Ok(r) => {
150                        *b = Backoff::default();
151                        dirty |= r.received > 0 || r.quarantined > 0 || r.steered > 0;
152                    }
153                    Err(_) => {
154                        b.failures = b.failures.saturating_add(1);
155                        b.next = Some(Instant::now() + backoff_wait(&opts, b.failures));
156                        owed.insert(peer.clone()); // what it did not get, once the wait is over
157                        node.unreachable(&peer);
158                    }
159                }
160                on(Event::Synced { peer, result });
161            }
162            _ = tick.tick() => {
163                let gate = node.gate().map(|_| ());
164                if gate_open != Some(gate.is_ok()) {
165                    gate_open = Some(gate.is_ok());
166                    on(Event::Gate(gate.clone()));
167                }
168                // A closing gate also refuses every session it would start (sync.rs).
169                if gate_open != Some(true) {
170                    continue;
171                }
172                let mut changed = false;
173                let mut now_state = local_state(node.home());
174                if seen.as_ref() != Some(&now_state) {
175                    // A `join` or `revoke` run beside the daemon changed the membership: quarantine
176                    // what it no longer admits before anything is offered (no-op when already done).
177                    let _ = node.enforce();
178                    let _ = node.stamp(); // a hook's new lines are stamped as this node's when first seen
179                    // What those two wrote (fresh outcomes, a quarantine) goes in this round, not the next.
180                    now_state = local_state(node.home());
181                    changed = true;
182                }
183                seen = Some(now_state);
184                let round = dirty || changed || last_round.is_none_or(|t: Instant| t.elapsed() >= opts.interval);
185                let now = Instant::now();
186                let ready = |k: &String| !dialling.values().any(|d| d == k) && backoff.get(k).and_then(|b| b.next).is_none_or(|at| at <= now);
187                // Peers.json and the membership are read only when a dial may start: not every tick a
188                // peer that backs off is owed a change.
189                if !round && !owed.iter().any(ready) {
190                    continue;
191                }
192                let all = targets(&node);
193                if round {
194                    (dirty, last_round) = (false, Some(now));
195                    owed.extend(all.iter().map(|t| t.key.clone()));
196                }
197                // A member no longer listed or admitted is owed nothing.
198                owed.retain(|k| all.iter().any(|t| t.key == *k));
199                let due: Vec<Target> = all.into_iter().filter(|t| owed.contains(&t.key) && ready(&t.key)).collect();
200                for t in due {
201                    owed.remove(&t.key);
202                    let (n, timeout, key) = (node.clone(), opts.timeout, t.key.clone());
203                    let handle = dials.spawn(async move {
204                        match tokio::time::timeout(timeout, n.sync(&t.to_string())).await {
205                            Ok(r) => r,
206                            Err(_) => Err(format!("no answer within {timeout:?}")),
207                        }
208                    });
209                    dialling.insert(handle.id(), key);
210                }
211            }
212        }
213    }
214    // Clean shutdown: no new sessions; running ones get the grace period, then the endpoint closes.
215    let _ = tokio::time::timeout(opts.grace, async {
216        while sessions.join_next().await.is_some() {}
217        while dials.join_next().await.is_some() {}
218    })
219    .await;
220    sessions.abort_all();
221    dials.abort_all();
222    node.close().await;
223}
224
225/// The members a round dials: listed in peers.json, still members, not this node.
226fn targets(node: &Node) -> Vec<Target> {
227    let Ok(Some(m)) = Membership::load(node.home()) else { return Vec::new() };
228    let me = node.public();
229    peers::targets(node.home()).into_iter().filter(|t| t.key != me && m.role_of(&t.key).is_some()).collect()
230}