Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Improve computation of canonical head
cloudhead committed 2 years ago
commit 883b548c61e1282499503be2516f5fa6a34ba754
parent 5acfffb352a47e052673f7ecc0c87653bc6c8ec4
3 files changed +313 -14
modified radicle/src/identity.rs
@@ -11,6 +11,7 @@ use crate::crypto;
use crate::crypto::{Signature, Verified};
use crate::git;
use crate::storage;
+
use crate::storage::git::QuorumError;
use crate::storage::{refs, ReadRepository, RemoteId};

pub use crypto::PublicKey;
@@ -41,6 +42,8 @@ pub enum IdentityError {
    MissingRoot,
    #[error("root commit is missing one or more delegate signatures")]
    MissingRootSignatures,
+
    #[error("quorum failed: {0}")]
+
    Quorum(#[from] QuorumError),
    #[error(transparent)]
    Payload(#[from] PayloadError),
    #[error("commit signature for {0} is invalid: {1}")]
modified radicle/src/storage/git.rs
@@ -1,7 +1,7 @@
pub mod cob;
pub mod transport;

-
use std::collections::{BTreeMap, HashMap};
+
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::{fs, io};

@@ -556,21 +556,16 @@ impl ReadRepository for Repository {
        let project = doc.project()?;
        let branch_ref = Qualified::from(lit::refs_heads(&project.default_branch()));
        let raw = self.raw();
-

        let mut heads = Vec::new();
+

        for delegate in doc.delegates.iter() {
-
            let r = self.reference_oid(delegate, &branch_ref)?.into();
+
            let r = self.reference_oid(delegate, &branch_ref)?;

-
            heads.push(r);
+
            heads.push(*r);
        }
+
        let quorum = self::quorum(&heads, doc.threshold, raw)?;

-
        let oid = match heads.as_slice() {
-
            [head] => Ok(*head),
-
            // FIXME: This branch is not tested.
-
            heads => raw.merge_base_many(heads),
-
        }?;
-

-
        Ok((branch_ref, oid.into()))
+
        Ok((branch_ref, quorum))
    }

    fn identity_head(&self) -> Result<Oid, IdentityError> {
@@ -684,6 +679,112 @@ impl SignRepository for Repository {
    }
}

+
#[derive(Debug, Error)]
+
pub enum QuorumError {
+
    #[error("no quorum was found")]
+
    NoQuorum,
+
    #[error(transparent)]
+
    Git(#[from] git2::Error),
+
}
+

+
/// Computes the quorum or "canonical" head based on the given heads and the
+
/// threshold. This can be described as the latest commit that is included in
+
/// at least `threshold` histories. In case there are multiple heads passing
+
/// the threshold, and they are divergent, their merge base is taken.
+
///
+
/// Returns an error if `heads` is empty or `threshold` cannot be satisified with
+
/// the number of heads given.
+
pub fn quorum(
+
    heads: &[git::raw::Oid],
+
    threshold: usize,
+
    repo: &git::raw::Repository,
+
) -> Result<Oid, QuorumError> {
+
    let mut direct: HashMap<git::raw::Oid, HashSet<usize>> = HashMap::new();
+
    let mut indirect: HashMap<git::raw::Oid, HashSet<usize>> = HashMap::new();
+

+
    let Some(init) = heads.first() else {
+
        return Err(QuorumError::NoQuorum);
+
    };
+
    // Nb. The merge base chosen for two merge commits is arbitrary.
+
    let base = heads
+
        .iter()
+
        .try_fold(*init, |base, h| repo.merge_base(base, *h))?;
+

+
    // Score every commit in the graph with the number of heads
+
    // pointing to it.
+
    // To make sure the votes are not counted twice, we use
+
    // the index in the `heads` slice as the vote identifier.
+
    // Note that it's perfectly legal to have multiple heads
+
    // with the same value.
+
    for (i, head) in heads.iter().enumerate() {
+
        direct.entry(*head).or_default().insert(i);
+

+
        let mut revwalk = repo.revwalk()?;
+
        revwalk.push(*head)?;
+

+
        for rev in revwalk {
+
            let rev = rev?;
+
            indirect.entry(rev).or_default().insert(i);
+

+
            if rev == base {
+
                break;
+
            }
+
        }
+
    }
+

+
    {
+
        let matches = direct
+
            .iter()
+
            .filter(|(_, tips)| tips.len() >= threshold)
+
            .map(|(h, _)| *h)
+
            .collect::<Vec<_>>();
+

+
        match matches.as_slice() {
+
            [] => {
+
                // Check indirect votes.
+
            }
+
            [head] => return Ok((*head).into()),
+
            [head, ref rest @ ..] => {
+
                let oid = rest
+
                    .iter()
+
                    .try_fold(*head, |base, h| repo.merge_base(base, *h))?;
+

+
                if !direct.contains_key(&oid) {
+
                    return Ok(oid.into());
+
                }
+
            }
+
        }
+
    }
+

+
    let mut combined: HashMap<git::raw::Oid, HashSet<usize>> = HashMap::new();
+
    for (k, v) in direct.into_iter().chain(indirect) {
+
        combined.entry(k).or_default().extend(v);
+
    }
+

+
    let minimum = combined
+
        .iter()
+
        .filter(|(_, tips)| tips.len() >= threshold)
+
        .map(|(_, tips)| tips.len())
+
        .min()
+
        .ok_or(QuorumError::NoQuorum)?;
+

+
    let candidates = combined
+
        .iter()
+
        .filter(|(_, v)| v.len() == minimum)
+
        .map(|(h, _)| *h)
+
        .collect::<Vec<_>>();
+

+
    let oid = match candidates.as_slice() {
+
        [] => return Err(QuorumError::NoQuorum),
+
        [head] => *head,
+
        [head, ref rest @ ..] => rest
+
            .iter()
+
            .try_fold(*head, |base, h| repo.merge_base(base, *h))?,
+
    };
+

+
    Ok(oid.into())
+
}
+

pub mod trailers {
    use std::str::FromStr;

@@ -742,6 +843,7 @@ mod tests {
    use crypto::test::signer::MockSigner;

    use super::*;
+
    use crate::assert_matches;
    use crate::git;
    use crate::storage::refs::SIGREFS_BRANCH;
    use crate::storage::{ReadRepository, ReadStorage};
@@ -749,6 +851,179 @@ mod tests {
    use crate::test::fixtures;

    #[test]
+
    fn test_quorum_properties() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (repo, c0) = fixtures::repository(tmp.path());
+
        let c0: git::Oid = c0.into();
+
        let a1 = fixtures::commit("A1", &[*c0], &repo);
+
        let a2 = fixtures::commit("A2", &[*a1], &repo);
+
        let d1 = fixtures::commit("D1", &[*c0], &repo);
+
        let c1 = fixtures::commit("C1", &[*c0], &repo);
+
        let c2 = fixtures::commit("C2", &[*c1], &repo);
+
        let b2 = fixtures::commit("B2", &[*c1], &repo);
+
        let a1 = fixtures::commit("A1", &[*c0], &repo);
+
        let m1 = fixtures::commit("M1", &[*c2, *b2], &repo);
+
        let m2 = fixtures::commit("M2", &[*a1, *b2], &repo);
+
        let mut rng = fastrand::Rng::new();
+
        let choices = vec![*c0, *c1, *c2, *b2, *a1, *a2, *d1, *m1, *m2];
+

+
        for _ in 0..100 {
+
            let count = rng.usize(1..=choices.len());
+
            let threshold = rng.usize(1..=count);
+
            let mut heads = Vec::new();
+

+
            for _ in 0..count {
+
                let ix = rng.usize(0..choices.len());
+
                heads.push(choices[ix]);
+
            }
+
            rng.shuffle(&mut heads);
+

+
            match quorum(&heads, threshold, &repo) {
+
                Ok(canonical) => {
+
                    let mut matches = 0;
+
                    for h in &heads {
+
                        if *canonical == *h || repo.graph_descendant_of(*h, *canonical).unwrap() {
+
                            matches += 1;
+
                        }
+
                    }
+
                    assert!(
+
                        matches >= threshold,
+
                        "test failed: heads={heads:?} threshold={threshold} canonical={canonical}"
+
                    );
+
                }
+
                Err(e) => panic!("{e} for heads={heads:?} threshold={threshold}"),
+
            }
+
        }
+
    }
+

+
    #[test]
+
    fn test_quorum() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (repo, c0) = fixtures::repository(tmp.path());
+
        let c0: git::Oid = c0.into();
+
        let c1 = fixtures::commit("C1", &[*c0], &repo);
+
        let c2 = fixtures::commit("C2", &[*c1], &repo);
+
        let b2 = fixtures::commit("B2", &[*c1], &repo);
+
        let a1 = fixtures::commit("A1", &[*c0], &repo);
+
        let m1 = fixtures::commit("M1", &[*c2, *b2], &repo);
+
        let m2 = fixtures::commit("M2", &[*a1, *b2], &repo);
+

+
        eprintln!("C0: {c0}");
+
        eprintln!("C1: {c1}");
+
        eprintln!("C2: {c2}");
+
        eprintln!("B2: {b2}");
+
        eprintln!("A1: {a1}");
+
        eprintln!("M1: {m1}");
+
        eprintln!("M2: {m2}");
+

+
        assert_eq!(quorum(&[*c0], 1, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*c0], 0, &repo).unwrap(), c0);
+
        assert_matches!(quorum(&[], 0, &repo), Err(QuorumError::NoQuorum));
+
        assert_matches!(quorum(&[*c0], 2, &repo), Err(QuorumError::NoQuorum));
+

+
        //  C1
+
        //  |
+
        // C0
+
        assert_eq!(quorum(&[*c1], 1, &repo).unwrap(), c1);
+

+
        //   C2
+
        //   |
+
        //  C1
+
        //  |
+
        // C0
+
        assert_eq!(quorum(&[*c1, *c2], 1, &repo).unwrap(), c2);
+
        assert_eq!(quorum(&[*c1, *c2], 2, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*c0, *c1, *c2], 3, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*c1, *c1, *c2], 2, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*c1, *c1, *c2], 1, &repo).unwrap(), c2);
+
        assert_eq!(quorum(&[*c2, *c2, *c1], 1, &repo).unwrap(), c2);
+

+
        // B2 C2
+
        //   \|
+
        //   C1
+
        //   |
+
        //  C0
+
        assert_eq!(quorum(&[*c1, *c2, *b2], 1, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*c2, *b2], 1, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*b2, *c2], 1, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*c2, *b2], 2, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*b2, *c2], 2, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*c1, *c2, *b2], 2, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*c1, *c2, *b2], 3, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*b2, *b2, *c2], 2, &repo).unwrap(), b2);
+
        assert_eq!(quorum(&[*b2, *c2, *c2], 2, &repo).unwrap(), c2);
+
        assert_eq!(quorum(&[*b2, *b2, *c2, *c2], 1, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*b2, *c2, *c2], 1, &repo).unwrap(), c1);
+

+
        //  B2 C2
+
        //    \|
+
        // A1 C1
+
        //   \|
+
        //   C0
+
        assert_eq!(quorum(&[*c2, *b2, *a1], 1, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*c2, *b2, *a1], 2, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*c2, *b2, *a1], 3, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*c1, *c2, *b2, *a1], 4, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 2, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 3, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*c0, *c2, *b2, *a1], 3, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 4, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*a1, *a1, *c2, *c2, *c1], 2, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*a1, *a1, *c2, *c2, *c1], 1, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*a1, *a1, *c2], 1, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*b2, *b2, *c2, *c2], 1, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*b2, *b2, *c2, *c2, *a1], 1, &repo).unwrap(), c0);
+

+
        //    M2  M1
+
        //    /\  /\
+
        //    \ B2 C2
+
        //     \  \|
+
        //     A1 C1
+
        //       \|
+
        //       C0
+
        assert_eq!(quorum(&[*m1], 1, &repo).unwrap(), m1);
+
        assert_eq!(quorum(&[*m1, *m2], 1, &repo).unwrap(), b2);
+
        assert_eq!(quorum(&[*m2, *m1], 1, &repo).unwrap(), b2);
+
        assert_eq!(quorum(&[*m1, *m2], 2, &repo).unwrap(), b2);
+
        assert_eq!(quorum(&[*m1, *m2, *c2], 1, &repo).unwrap(), c1);
+
        assert_eq!(quorum(&[*m1, *a1], 1, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*m1, *a1], 2, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*m1, *m1, *b2], 2, &repo).unwrap(), m1);
+
        assert_eq!(quorum(&[*c2, *m1, *m2], 3, &repo).unwrap(), c1);
+
    }
+

+
    #[test]
+
    #[ignore = "failing"]
+
    fn test_quorum_merges() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (repo, c0) = fixtures::repository(tmp.path());
+
        let c0: git::Oid = c0.into();
+
        let c1 = fixtures::commit("C1", &[*c0], &repo);
+
        let c2 = fixtures::commit("C2", &[*c0], &repo);
+
        let c3 = fixtures::commit("C3", &[*c0], &repo);
+

+
        let m1 = fixtures::commit("M1", &[*c1, *c2], &repo);
+
        let m2 = fixtures::commit("M2", &[*c2, *c3], &repo);
+

+
        eprintln!("C0: {c0}");
+
        eprintln!("C1: {c1}");
+
        eprintln!("C2: {c2}");
+
        eprintln!("C3: {c3}");
+
        eprintln!("M1: {m1}");
+
        eprintln!("M2: {m2}");
+

+
        assert_eq!(quorum(&[*m1, *m2], 1, &repo).unwrap(), c2);
+
        assert_eq!(quorum(&[*m1, *m2], 2, &repo).unwrap(), c2);
+

+
        let m3 = fixtures::commit("M3", &[*c2, *c1], &repo);
+

+
        assert_eq!(quorum(&[*m1, *m3], 1, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*m1, *m3], 2, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*m3, *m1], 1, &repo).unwrap(), c0);
+
        assert_eq!(quorum(&[*m3, *m1], 2, &repo).unwrap(), c0);
+
    }
+

+
    #[test]
    fn test_remote_refs() {
        let dir = tempfile::tempdir().unwrap();
        let signer = MockSigner::default();
modified radicle/src/test/fixtures.rs
@@ -48,9 +48,9 @@ pub fn project<P: AsRef<Path>, G: Signer>(
) -> Result<(Id, SignedRefs<Verified>, git2::Repository, git2::Oid), rad::InitError> {
    transport::local::register(storage.clone());

-
    let (repo, head) = repository(path);
+
    let (working, head) = repository(path);
    let (id, _, refs) = rad::init(
-
        &repo,
+
        &working,
        "acme",
        "Acme's repository",
        git::refname!("master"),
@@ -59,7 +59,7 @@ pub fn project<P: AsRef<Path>, G: Signer>(
        storage,
    )?;

-
    Ok((id, refs, repo, head))
+
    Ok((id, refs, working, head))
}

/// Creates a regular repository at the given path with a couple of commits.
@@ -95,6 +95,27 @@ pub fn repository<P: AsRef<Path>>(path: P) -> (git2::Repository, git2::Oid) {
    (repo, oid)
}

+
/// Create an empty commit on the current branch.
+
pub fn commit(msg: &str, parents: &[git2::Oid], repo: &git2::Repository) -> git::Oid {
+
    let head = repo.head().unwrap();
+
    let sig = git2::Signature::new(
+
        "anonymous",
+
        "anonymous@radicle.xyz",
+
        &git2::Time::new(RADICLE_EPOCH, 0),
+
    )
+
    .unwrap();
+
    let tree = head.peel_to_commit().unwrap().tree().unwrap();
+
    let parents = parents
+
        .iter()
+
        .map(|p| repo.find_commit(*p).unwrap())
+
        .collect::<Vec<_>>();
+
    let parents = parents.iter().collect::<Vec<_>>(); // Get references.
+

+
    repo.commit(None, &sig, &sig, msg, &tree, &parents)
+
        .unwrap()
+
        .into()
+
}
+

/// Populate a repository with commits, branches and blobs.
pub fn populate(repo: &git2::Repository, scale: usize) -> Vec<git::Qualified> {
    assert!(