choir_node/queue.rs
1//! The node's own merge queue (D68).
2//!
3//! The bridge queues pull requests from somebody else's forge. A node
4//! has no pull requests; it has proposals, which are pushes to
5//! `refs/for/<branch>/<user>/<topic>` admitted by the `propose` grant
6//! (D53, D60). This is the reading that turns those refs into a round,
7//! and the landing that writes the round's result back where the log
8//! decides.
9//!
10//! Two things differ from the bridge's composition, and both come from
11//! the same fact: here the log *is* the source of truth.
12//!
13//! - A landing is real. It moves `refs/heads/<branch>`, so it is an
14//! [`OpKind::SetRef`] carrying the value the round started from as
15//! its compare-and-swap `prev`. A push that beat the round makes the
16//! landing fail rather than clobber.
17//! - The node moves no git ref itself.
18//! [`crate::Platform::reconcile_git_refs`] already treats a commit
19//! the log names and git does not as git being behind, and writes it.
20//! The log leads and git follows, which is the direction D21 fixed.
21
22use std::sync::Arc;
23
24use choir_hash::ContentHash;
25use choir_identity::ActorKey;
26use choir_queue::landing::Landing;
27use choir_queue::Change;
28use choir_sequencer::SequencerHandle;
29use choir_view::{OpKind, View, ViewOp};
30
31/// The channel the node's queue authors under.
32///
33/// Its own, not the proposer's: the ordering of a round is the node's
34/// statement, and attributing it to whoever happened to be first in the
35/// train would put a landing decision in somebody else's name.
36pub const QUEUE_CHANNEL: &str = "node/queue";
37
38/// One proposal in a round.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Proposal {
41 /// Round-local address, the position of [`Proposal::refname`] in the
42 /// round's sorted order. Not an identity: the queue's
43 /// cross-round identity is the patch identity
44 /// [`choir_queue::speculate::Speculator::identity`] computes, which
45 /// survives a rebase and a rename and this does not.
46 pub id: u64,
47 /// The proposal's full name in the view, `<repo>:refs/for/...`.
48 pub refname: String,
49 /// The commit the proposal points at.
50 pub head: String,
51}
52
53/// The proposals aimed at one branch of one repository, plus the base
54/// they are aimed at.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct ProposalRound {
57 /// The repository, as the view names it (`<owner>/<name>.git`).
58 pub repo: String,
59 /// The branch proposals are aimed at, short form (`main`).
60 pub branch: String,
61 /// The branch's commit when the round was read, and the `prev` every
62 /// landing in it is CAS'd against.
63 pub base: String,
64 /// The round's proposals, in refname order.
65 pub proposals: Vec<Proposal>,
66}
67
68impl ProposalRound {
69 /// Reads the round for `repo` and `branch` out of `view`.
70 ///
71 /// `None` when the branch does not exist: there is nothing to
72 /// propose to, and a queue that invented a base would be deciding
73 /// what a repository's history starts from.
74 ///
75 /// Ordering is by refname, and deliberately not by when the ref was
76 /// pushed. The view records no push time, and taking the order from
77 /// the op log's sequence numbers would make a round's behaviour
78 /// depend on how far the log had been compacted.
79 #[must_use]
80 pub fn from_view(view: &View, repo: &str, branch: &str) -> Option<Self> {
81 let base = view.refs.get(&format!("{repo}:refs/heads/{branch}"))?;
82 let base = base.git_oid()?;
83 let prefix = format!("{repo}:refs/for/{branch}/");
84 let proposals = view
85 .refs
86 .range(prefix.clone()..)
87 .take_while(|(name, _)| name.starts_with(&prefix))
88 .filter_map(|(name, head)| Some((name.clone(), head.git_oid()?)))
89 .enumerate()
90 .map(|(i, (refname, head))| Proposal {
91 // One-based, so a report never addresses a change as 0
92 // and leaves a reader wondering whether it means "none".
93 id: i as u64 + 1,
94 refname,
95 head,
96 })
97 .collect();
98 Some(Self {
99 repo: repo.to_string(),
100 branch: branch.to_string(),
101 base,
102 proposals,
103 })
104 }
105
106 /// The round as queue input.
107 ///
108 /// The workspace is the proposal's own refname, so a landing
109 /// recorded against a workspace names the thing that was proposed
110 /// rather than a number only this round knows.
111 #[must_use]
112 pub fn changes(&self) -> Vec<Change> {
113 self.proposals
114 .iter()
115 .map(|p| Change {
116 id: p.id,
117 workspace: p.refname.clone(),
118 base: self.base.clone(),
119 proposed: p.head.clone(),
120 depends: Vec::new(),
121 })
122 .collect()
123 }
124
125 /// The view key of the branch this round lands on.
126 #[must_use]
127 pub fn target_ref(&self) -> String {
128 format!("{}:refs/heads/{}", self.repo, self.branch)
129 }
130}
131
132/// Where a landing reads the scope it signs: this node's id and a head
133/// its window still holds.
134///
135/// Read per landing rather than once per round, because a round appends
136/// as it goes and a node running with `--require-scope` admits only a
137/// *recent* head. A scope captured at the start would go stale in
138/// exactly the rounds that land the most.
139pub type ScopeSource = Box<dyn Fn() -> (ContentHash, Option<ContentHash>) + Send>;
140
141/// Lands by moving the branch the proposals were aimed at (D68).
142pub struct RefLanding {
143 key: Arc<ActorKey>,
144 refname: String,
145 scope: ScopeSource,
146 at: Option<ContentHash>,
147}
148
149impl RefLanding {
150 /// Lands on `refname`, CAS'd from `base` and moving forward from
151 /// each landing to the next.
152 ///
153 /// `base` is the value the round was read at. It is the first
154 /// landing's `prev`, which is what makes a push that beat the round
155 /// win: the op is refused, and the queue stops rather than
156 /// overwriting somebody's work with a merge computed against a
157 /// branch that has moved.
158 #[must_use]
159 pub fn new(key: Arc<ActorKey>, refname: String, base: &str, scope: ScopeSource) -> Self {
160 Self {
161 key,
162 refname,
163 scope,
164 at: ContentHash::from_git_oid(base),
165 }
166 }
167}
168
169impl Landing for RefLanding {
170 fn name(&self) -> &'static str {
171 "ref"
172 }
173
174 fn record(
175 &mut self,
176 handle: &SequencerHandle,
177 _change: &Change,
178 commit: ContentHash,
179 ) -> Result<(), String> {
180 let (node, head) = (self.scope)();
181 let payload = ViewOp::new(OpKind::SetRef {
182 name: self.refname.clone(),
183 commit: commit.clone(),
184 prev: self.at.clone(),
185 })
186 .in_scope(node, head)
187 .to_payload();
188 let sig = self.key.sign_submission(QUEUE_CHANNEL, &payload);
189 handle.try_submit(QUEUE_CHANNEL, payload, Some(sig))?;
190 // Only after the sequencer took it. Advancing on a refusal would
191 // make the next landing CAS against a value the log never held,
192 // turning one lost race into a round that can never land.
193 self.at = Some(commit);
194 Ok(())
195 }
196}