Skip to main content

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}