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}