Skip to main content

choir_node/
followers.rs

1//! Followers (D21): after a ref lands, push it to the remotes the
2//! operator added to the bare repository.
3//!
4//! A follower is any remote a bare repository under the root carries. A
5//! bare repository on a node has no `origin`, it *is* the origin, so
6//! every remote on one was added on purpose, and enumerating them is
7//! what keeps this list-free: adding a mirror is `git remote add` on the
8//! node host (`choir repo follower add`), and nothing here to edit.
9//!
10//! # The node is canonical, and this only ever pushes
11//!
12//! D21's single-canonical invariant: upstream is the node, a follower
13//! copies it, and nothing flows back. So this pushes `refs/heads/*` and
14//! `refs/tags/*`, never fetches, and **never forces**. A follower that
15//! diverged from the node is a fault to surface, not a state to
16//! overwrite, and a push that refuses is the whole of that surfacing. A
17//! deleted ref is not deleted on the follower either: a follower is a
18//! copy that may hold more, and pruning is a policy nobody has asked for.
19//!
20//! # Invariant 5, the same way webhooks keep it
21//!
22//! A push goes to a host somebody else runs, so its latency is unbounded.
23//! The writer thread therefore does one thing with a landed ref:
24//! [`Followers::offer`], a non-blocking `try_send` of the repository's
25//! name into a bounded queue. The pushes run on the thread this module
26//! spawns, and a full queue drops the offer and counts it. Dropping is
27//! safe here in a way it is not for a webhook: the next landing on the
28//! same repository pushes everything, so a dropped offer is delay, not
29//! loss.
30//!
31//! # The log leads, so the push waits for git
32//!
33//! The offer is made when the sequencer accepts the ref, and git does
34//! not hold it yet: a push is still inside `pre-receive`, and the queue
35//! writes its ref after the round. A push made at the offer reads the
36//! repository as it was, sends nothing new, and nothing sends it again,
37//! which leaves the follower one landing behind until the next one. So
38//! an offer carries the value the view now holds, and the push thread
39//! waits until git reads the same before it pushes.
40//!
41//! The wait is bounded by `SETTLE`, because git may never get there: a
42//! push refused on a later ref is retracted, and its objects go out with
43//! the quarantine. Offers that arrive during the wait replace what is
44//! waited for, so a retraction ends it early instead of running it out.
45//!
46//! # Consent is the flag
47//!
48//! The daemon pushes only when started with `--followers`. Without it a
49//! remote on a bare repository is inert, which matters for a mirror
50//! `choir-bridge` keeps: that repository carries an `origin` pointing
51//! *upstream*, and a node that pushed to every remote it found would
52//! push a mirror back at the thing it mirrors.
53//!
54//! # Examples
55//!
56//! ```
57//! use choir_node::followers::repository_of;
58//!
59//! // A landed ref names its repository ahead of the colon.
60//! assert_eq!(repository_of("owner/repo.git:refs/heads/main"), Some("owner/repo.git"));
61//! assert_eq!(repository_of("refs/heads/main"), None);
62//! ```
63
64use std::collections::{BTreeMap, BTreeSet};
65use std::path::{Path, PathBuf};
66use std::sync::atomic::{AtomicU64, Ordering};
67use std::sync::mpsc::{Receiver, SyncSender, TrySendError};
68use std::sync::Arc;
69use std::time::{Duration, Instant};
70
71/// Offers waiting to be pushed. Small on purpose: every offer for a
72/// repository is the same work, so a deep queue would hold duplicates.
73const QUEUE_CAPACITY: usize = 64;
74
75/// How long the push thread waits for git to hold a landed ref before it
76/// pushes what git has. Far above a healthy apply, which is the tail of
77/// one hook, and short enough that a ref git will never hold delays the
78/// other repositories' pushes by seconds rather than stalling them.
79const SETTLE: Duration = Duration::from_secs(10);
80
81/// How often the wait looks again.
82const SETTLE_POLL: Duration = Duration::from_millis(25);
83
84/// A ref the view just moved, and the value it now holds there; `None`
85/// when the landing deleted it.
86struct Landing {
87    repo: String,
88    refname: String,
89    new: Option<String>,
90}
91
92/// The repository half of a view ref key, `<repo>:<refname>`.
93#[must_use]
94pub fn repository_of(key: &str) -> Option<&str> {
95    let (repo, rest) = key.split_once(':')?;
96    (!repo.is_empty() && rest.starts_with("refs/")).then_some(repo)
97}
98
99/// The push handle the writer thread holds.
100pub struct Followers {
101    tx: SyncSender<Landing>,
102    dropped: Arc<AtomicU64>,
103}
104
105impl Followers {
106    /// Starts the push thread over the bare repositories under `root`,
107    /// appending one record per push to `log`.
108    ///
109    /// # Errors
110    ///
111    /// Returns a message when the thread cannot be started.
112    pub fn start(root: PathBuf, log: PathBuf) -> Result<Self, String> {
113        let (tx, rx) = std::sync::mpsc::sync_channel(QUEUE_CAPACITY);
114        let dropped = Arc::new(AtomicU64::new(0));
115        let worker = Worker {
116            rx,
117            root,
118            log,
119            dropped: Arc::clone(&dropped),
120        };
121        std::thread::Builder::new()
122            .name("choir-followers".to_string())
123            .spawn(move || worker.run())
124            .map_err(|e| format!("could not start the follower push thread: {e}"))?;
125        eprintln!("followers enabled: every landing is pushed to each remote of its repository");
126        Ok(Self { tx, dropped })
127    }
128
129    /// Offers a landed ref to the push thread, with the value the view
130    /// now holds for it (`None` for a deletion). **Never blocks**; a full
131    /// queue drops the offer and counts it, and the next landing on the
132    /// same repository pushes everything anyway.
133    pub fn offer(&self, key: &str, new: Option<&str>) {
134        let Some(repo) = repository_of(key) else {
135            return;
136        };
137        let landing = Landing {
138            repo: repo.to_string(),
139            refname: key[repo.len() + 1..].to_string(),
140            new: new.map(str::to_string),
141        };
142        match self.tx.try_send(landing) {
143            Ok(()) => {}
144            Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
145                self.dropped.fetch_add(1, Ordering::Relaxed);
146            }
147        }
148    }
149
150    /// Offers dropped because the queue was full.
151    #[must_use]
152    pub fn dropped(&self) -> u64 {
153        self.dropped.load(Ordering::Relaxed)
154    }
155}
156
157struct Worker {
158    rx: Receiver<Landing>,
159    root: PathBuf,
160    log: PathBuf,
161    dropped: Arc<AtomicU64>,
162}
163
164impl Worker {
165    fn run(self) {
166        let mut reported = 0;
167        while let Ok(first) = self.rx.recv() {
168            // Everything queued behind it, once each: twenty landings on
169            // one repository while a slow push ran are one push after it.
170            // Keyed by ref with the last value winning, because that is
171            // the one git ends up holding.
172            let mut pending = BTreeMap::from([((first.repo, first.refname), first.new)]);
173            let deadline = Instant::now() + SETTLE;
174            loop {
175                while let Ok(landing) = self.rx.try_recv() {
176                    pending.insert((landing.repo, landing.refname), landing.new);
177                }
178                let settled = pending
179                    .iter()
180                    .all(|((repo, refname), new)| held(&self.root.join(repo), refname) == *new);
181                if settled || Instant::now() >= deadline {
182                    break;
183                }
184                std::thread::sleep(SETTLE_POLL);
185            }
186            let repos: BTreeSet<&String> = pending.keys().map(|(repo, _)| repo).collect();
187            for repo in repos {
188                for outcome in push_all(&self.root, repo) {
189                    record(&self.log, &outcome);
190                }
191            }
192            let dropped = self.dropped.load(Ordering::Relaxed);
193            if dropped > reported {
194                record(
195                    &self.log,
196                    &Outcome {
197                        repo: String::new(),
198                        remote: String::new(),
199                        event: "dropped",
200                        detail: format!("{} offers dropped, queue full", dropped - reported),
201                    },
202                );
203                reported = dropped;
204            }
205        }
206    }
207}
208
209/// One push, as it went.
210#[derive(Debug, Clone, Eq, PartialEq)]
211pub struct Outcome {
212    /// The repository, as `repos.list` spells it.
213    pub repo: String,
214    /// The remote's name; empty when the repository has none.
215    pub remote: String,
216    /// `pushed`, `failed`, `no_remote`, `no_repository` or `dropped`.
217    pub event: &'static str,
218    /// git's own words, or what was missing.
219    pub detail: String,
220}
221
222impl Outcome {
223    /// Whether this is a push that succeeded or a repository with
224    /// nothing to push to; the latter is named, never counted as failure,
225    /// because "no second copy yet" should stay visible without being an
226    /// error.
227    #[must_use]
228    pub fn is_ok(&self) -> bool {
229        self.event != "failed" && self.event != "no_repository"
230    }
231}
232
233/// The remotes a bare repository carries, `(name, url)` each.
234#[must_use]
235pub fn remotes(bare: &Path) -> Vec<(String, String)> {
236    let Some(names) = git(bare, &["remote"]) else {
237        return Vec::new();
238    };
239    names
240        .lines()
241        .map(str::trim)
242        .filter(|n| !n.is_empty())
243        .map(|name| {
244            let url = git(bare, &["remote", "get-url", name])
245                .map(|u| u.trim().to_string())
246                .unwrap_or_default();
247            (name.to_string(), url)
248        })
249        .collect()
250}
251
252/// Pushes every branch and tag of one bare repository to one remote.
253/// No force, no prune, no fetch.
254///
255/// # Errors
256///
257/// Returns git's stderr when the push was refused or could not run.
258pub fn push(bare: &Path, remote: &str) -> Result<(), String> {
259    let out = std::process::Command::new("git")
260        .arg("--git-dir")
261        .arg(bare)
262        .args([
263            "push",
264            "-q",
265            remote,
266            "refs/heads/*:refs/heads/*",
267            "refs/tags/*:refs/tags/*",
268        ])
269        .env("GIT_TERMINAL_PROMPT", "0")
270        .output()
271        .map_err(|e| format!("could not run git: {e}"))?;
272    if out.status.success() {
273        Ok(())
274    } else {
275        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
276    }
277}
278
279/// Pushes one repository to each of its remotes, one outcome per remote,
280/// or one `no_remote` outcome when it has none.
281#[must_use]
282pub fn push_all(root: &Path, repo: &str) -> Vec<Outcome> {
283    let bare = root.join(repo);
284    if !bare.is_dir() {
285        return vec![Outcome {
286            repo: repo.to_string(),
287            remote: String::new(),
288            event: "no_repository",
289            detail: format!("{} is not a repository", bare.display()),
290        }];
291    }
292    let remotes = remotes(&bare);
293    if remotes.is_empty() {
294        return vec![Outcome {
295            repo: repo.to_string(),
296            remote: String::new(),
297            event: "no_remote",
298            detail: "no remote: its objects have no second copy yet".to_string(),
299        }];
300    }
301    remotes
302        .into_iter()
303        .map(|(name, url)| match push(&bare, &name) {
304            Ok(()) => Outcome {
305                repo: repo.to_string(),
306                remote: name,
307                event: "pushed",
308                detail: url,
309            },
310            Err(why) => Outcome {
311                repo: repo.to_string(),
312                remote: name,
313                event: "failed",
314                detail: why,
315            },
316        })
317        .collect()
318}
319
320/// Appends one outcome to the push log as a JSON line.
321fn record(log: &Path, outcome: &Outcome) {
322    let line = serde_json::json!({
323        "at": std::time::SystemTime::now()
324            .duration_since(std::time::UNIX_EPOCH)
325            .map(|d| d.as_secs())
326            .unwrap_or(0),
327        "repo": outcome.repo,
328        "remote": outcome.remote,
329        "event": outcome.event,
330        "detail": outcome.detail,
331    });
332    if let Ok(mut file) = std::fs::OpenOptions::new()
333        .create(true)
334        .append(true)
335        .open(log)
336    {
337        use std::io::Write;
338        let _ = writeln!(file, "{line}");
339    }
340}
341
342/// What a bare repository holds at `refname`, unpeeled, the way a view
343/// ref spells it; `None` when it holds nothing there.
344fn held(bare: &Path, refname: &str) -> Option<String> {
345    git(bare, &["rev-parse", "--verify", "-q", refname]).map(|oid| oid.trim().to_string())
346}
347
348fn git(bare: &Path, args: &[&str]) -> Option<String> {
349    let out = std::process::Command::new("git")
350        .arg("--git-dir")
351        .arg(bare)
352        .args(args)
353        .output()
354        .ok()?;
355    out.status
356        .success()
357        .then(|| String::from_utf8_lossy(&out.stdout).to_string())
358}