choir_node/queue_api.rs
1//! Driving the node's merge queue from outside (D5, D68).
2//!
3//! [`crate::Platform::run_proposal_queue`] is the round; this is how
4//! anybody asks for one. A single endpoint, `POST /api/queue/run`, and
5//! deliberately no timer: a node that spends CI on its own schedule
6//! surprises whoever pays for it, and a round that only a clock can
7//! start is a round no test can reach without waiting on one. An
8//! operator's cron, a hook, or a person decides the cadence.
9//!
10//! The queue's trees are made here rather than by the operator, because
11//! the one contract that matters cannot be checked once it is wrong:
12//! the tree a round speculates in must share the served repository's
13//! object store, or the log names merge commits git cannot see and
14//! [`crate::Platform::reconcile_git_refs`] compensates the whole round
15//! back out. A `git worktree` of the bare repo satisfies it by
16//! construction; a clone that looks identical does not.
17
18use std::path::{Path, PathBuf};
19
20use choir_queue::differential_ledger::CommandSpec;
21
22/// What a node needs before it can run a round.
23///
24/// Set by `--queue-tree` and `--ci-command`. Absent, `/api/queue/run`
25/// answers 501: a node with no CI command has no way to decide whether
26/// a candidate is good, and a queue that landed everything unchecked
27/// would be a worse `git push`.
28#[derive(Debug, Clone)]
29pub struct QueueConfig {
30 /// Scratch root the queue owns. Worktrees are made under it, one
31 /// per `repo:branch`, and reused between rounds.
32 pub tree: PathBuf,
33 /// What to run against each candidate state.
34 pub command: CommandSpec,
35}
36
37/// Where one target's two trees live.
38///
39/// Two, not one. The speculator forces its tree to each candidate state
40/// in turn, and the executor gives every job in a batch its own tree
41/// (D18); sharing one directory between them would have the executor
42/// checking out over the merge in progress.
43struct Trees {
44 speculation: PathBuf,
45 jobs: PathBuf,
46}
47
48impl QueueConfig {
49 fn trees(&self, repo: &str, branch: &str) -> Trees {
50 // The repo name carries a slash and a `.git`; neither is a
51 // legal path component here, so it is flattened rather than
52 // joined, which also keeps a repo called `a/b` from colliding
53 // with one called `a-b` in the same root.
54 let slug = format!(
55 "{}@{}",
56 repo.replace(['/', '\\'], "_"),
57 branch.replace(['/', '\\'], "_")
58 );
59 let base = self.tree.join(slug);
60 Trees {
61 speculation: base.join("speculation"),
62 jobs: base.join("jobs"),
63 }
64 }
65}
66
67/// Ensures `at` is a worktree of the bare repository at `repo`.
68///
69/// Idempotent: an existing worktree is reused, because the alternative
70/// is making one per round and paying a full checkout each time. A
71/// directory that exists but is not a worktree of this repository is an
72/// error rather than something to repair, since removing whatever a
73/// caller put there is not this function's decision to make.
74fn ensure_worktree(repo: &Path, at: &Path, branch: &str) -> Result<(), String> {
75 if at.join(".git").exists() {
76 return Ok(());
77 }
78 if at.exists() {
79 return Err(format!(
80 "{} exists and is not a worktree of this repository",
81 at.display()
82 ));
83 }
84 if let Some(parent) = at.parent() {
85 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
86 }
87 let out = std::process::Command::new("git")
88 .args(["worktree", "add", "--detach"])
89 .arg(at)
90 .arg(branch)
91 .current_dir(repo)
92 .output()
93 .map_err(|e| format!("spawn git: {e}"))?;
94 if out.status.success() {
95 Ok(())
96 } else {
97 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
98 }
99}
100
101/// The rounds currently running, by `<repo>:<branch>`.
102///
103/// A second request for a target already in flight is refused rather
104/// than queued behind it. Two overlapping rounds would each read the
105/// branch, speculate from it, and race to land: the loser's whole round
106/// is void by construction, so the CI it spent was wasted before it
107/// started. Refusing says so immediately.
108#[derive(Debug, Default)]
109pub struct InFlight(std::sync::Mutex<std::collections::BTreeSet<String>>);
110
111impl InFlight {
112 /// Claims `target`, or reports that somebody else holds it.
113 fn claim(&self, target: &str) -> Option<Claim<'_>> {
114 let mut held = self.0.lock().expect("in-flight lock");
115 held.insert(target.to_string()).then(|| Claim {
116 owner: self,
117 target: target.to_string(),
118 })
119 }
120}
121
122/// Releases its target when the round ends, however it ends.
123struct Claim<'a> {
124 owner: &'a InFlight,
125 target: String,
126}
127
128impl Drop for Claim<'_> {
129 fn drop(&mut self) {
130 self.owner
131 .0
132 .lock()
133 .expect("in-flight lock")
134 .remove(&self.target);
135 }
136}
137
138/// Runs one round for the target named in `body`, as JSON.
139///
140/// # Errors
141///
142/// Answered as a status and a JSON body rather than returned: a
143/// malformed request, a repository or branch that does not exist, a
144/// round already in flight for that target, or a tree that cannot be
145/// made.
146pub fn run(
147 root: &Path,
148 platform: &crate::Platform,
149 config: &QueueConfig,
150 in_flight: &InFlight,
151 body: &[u8],
152) -> (u16, String) {
153 let request: serde_json::Value = match serde_json::from_slice(body) {
154 Ok(value) => value,
155 Err(error) => return bad(400, &format!("body is not json: {error}")),
156 };
157 let (Some(repo), Some(branch)) = (request["repo"].as_str(), request["branch"].as_str()) else {
158 return bad(400, "both `repo` and `branch` are required");
159 };
160 // A branch name is a path component of a ref and of a directory
161 // here, so the two characters that would make it neither are
162 // refused before either is built from it.
163 if branch.contains("..") || branch.starts_with('/') {
164 return bad(400, "that is not a branch name");
165 }
166
167 let target = format!("{repo}:refs/heads/{branch}");
168 let Some(_claim) = in_flight.claim(&target) else {
169 return bad(409, "a round for that target is already running");
170 };
171
172 let bare = root.join(repo);
173 if !bare.exists() {
174 return bad(404, "no such repository");
175 }
176 let Some(round) = platform.proposal_round(repo, branch) else {
177 return bad(404, "no such branch on that repository");
178 };
179
180 let trees = config.trees(repo, branch);
181 if let Err(why) = ensure_worktree(&bare, &trees.speculation, branch) {
182 return bad(500, &format!("the queue has no tree to work in: {why}"));
183 }
184
185 let mut ci = choir_queue::worktree::WorktreeRunner::new(bare.clone(), trees.jobs);
186 let template = choir_queue::JobTemplate {
187 command: {
188 let mut argv = Vec::with_capacity(config.command.args.len() + 1);
189 argv.push(config.command.program.clone());
190 argv.extend(config.command.args.iter().cloned());
191 argv
192 },
193 environment: choir_queue::differential_ledger::effective_environment(&config.command.env),
194 deadline: config
195 .command
196 .timeout_seconds
197 .map(std::time::Duration::from_secs),
198 // Speculative by definition: a shared cache written from a
199 // state nobody approved is the CREEP shape, and a queue open to
200 // proposals from outside is exactly where it is reached.
201 may_write_cache: false,
202 };
203
204 // Named in the answer so a caller can tell "nothing to do" from
205 // "nothing proposed": these were in the round and already on the
206 // branch, so the queue never saw them.
207 let already_integrated = round.already_integrated(&trees.speculation);
208 let Some(report) =
209 platform.run_proposal_queue(repo, branch, &trees.speculation, &mut ci, template)
210 else {
211 return bad(404, "no such branch on that repository");
212 };
213 // The log led; git follows now rather than at the next startup.
214 // Without this every landing left git's ref behind until a restart,
215 // and every push to the branch in between lost its CAS (the pusher's
216 // `old` is git's value, the view holds the landing). CAS'd on the
217 // round's base at the git level too: a push that beat the round
218 // would already have made the landing itself lose, so whichever
219 // side wins here, nothing is clobbered.
220 let git_lag = if report.merged.is_empty() {
221 None
222 } else {
223 crate::platform::write_git_ref(
224 &bare,
225 &format!("refs/heads/{branch}"),
226 &report.final_state,
227 Some(&round.base),
228 )
229 .err()
230 };
231 let body = serde_json::json!({
232 "format_version": 1,
233 "repo": repo,
234 "branch": branch,
235 "merged": report.merged,
236 "rejected": report.rejected.iter()
237 .map(|(id, why)| serde_json::json!({ "id": id, "why": format!("{why:?}") }))
238 .collect::<Vec<_>>(),
239 "tip": report.final_state,
240 "stalled": report.provider_error,
241 "unreported_checks": report.unreported_checks,
242 // How many CI jobs the round ran: the train's own cost multiplier,
243 // derivable per D23 without any borrowed rate.
244 "ci_runs": report.ci_runs,
245 // Null when git holds what the log landed; otherwise why it does
246 // not yet, and startup reconciliation will bring it forward.
247 "git_lag": git_lag,
248 "already_integrated": already_integrated,
249 });
250 (200, body.to_string())
251}
252
253fn bad(status: u16, reason: &str) -> (u16, String) {
254 (status, serde_json::json!({ "error": reason }).to_string())
255}