Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
radicle: introduce announcer
Fintan Halpenny committed 11 months ago
commit 5b4cbc2cd87919020e7c4600f3a1c0f0c8fccb5a
parent fa9c6cd142b4763df5cd5e422074a44c5fc84f3e
24 files changed +704 -232
modified radicle-cli/examples/git/git-push-amend.md
@@ -22,7 +22,7 @@ $ git commit --amend -m "Neue Änderungen" --allow-empty -q
``` ~alice (stderr)
$ git push rad master -f
✓ Canonical head updated to 9170c8795d3a78f0381a0ffafb20ea69fb0f5b6b
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + fb25886...9170c87 master -> master (forced update)
```
modified radicle-cli/examples/git/git-push-converge.md
@@ -94,7 +94,7 @@ $ git push rad -f
warn: could not determine canonical tip for `refs/heads/master`
warn: no commit found with at least 3 vote(s) (threshold not met)
warn: it is recommended to find a commit to agree upon
-
✓ Synced with 2 node(s)
+
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   d09e634..0f9bd80  master -> master
```
@@ -118,7 +118,7 @@ become the canonical `master`.
``` ~bob (stderr)
$ git push rad
✓ Canonical head updated to 3a75f66dd0020c9a0355cc6ec21f15de989e2001
-
✓ Synced with 2 node(s)
+
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
   2a37862..0f9bd80  master -> master
```
@@ -139,7 +139,7 @@ HEAD is now at 0f9bd80 Merge remote-tracking branch 'eve/master'
``` ~eve (stderr)
$ git push rad
✓ Canonical head updated to 0f9bd8035c04b3f73f5408e73e8454879b20800b
-
✓ Synced with 2 node(s)
+
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z
   3a75f66..0f9bd80  master -> master
```
modified radicle-cli/examples/git/git-push-rollback.md
@@ -36,7 +36,7 @@ Fast-forward
``` ~alice (stderr)
$ git push rad
✓ Canonical head updated to 319a7dc3b195368ded4b099f8c90bbb80addccd3
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..319a7dc  master -> master
```
@@ -55,7 +55,7 @@ push and the new canonical head becomes the previous commit again:
``` ~alice (stderr)
$ git push rad -f
✓ Canonical head updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + 319a7dc...f2de534 master -> master (forced update)
```
modified radicle-cli/examples/git/git-push.md
@@ -59,7 +59,7 @@ f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/master

```
$ rad sync --announce
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
```

Note that it is forbidden to delete the default/canonical branch:
modified radicle-cli/examples/git/git-tag.md
@@ -12,7 +12,7 @@ $ git tag v1.0 -a -m "Release v1.0"

``` ~alice (stderr)
$ git push rad v1.0
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new tag]         v1.0 -> v1.0
```
@@ -64,7 +64,7 @@ Updated tag 'v1.0' (was be18ed6)

``` ~alice (stderr)
$ git push rad v1.0 -f
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + be18ed6...9dbdebc v1.0 -> v1.0 (forced update)
```
modified radicle-cli/examples/rad-id-threshold-soft-fork.md
@@ -13,7 +13,7 @@ $ rad issue open --title "Add Bob as a delegate" --description "We agreed to add
│ We agreed to add me as a delegate, so I am creating an issue │
│ to track that work                                           │
╰──────────────────────────────────────────────────────────────╯
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
```

and if we inspect Alice's references, then we will see the following:
modified radicle-cli/examples/rad-id-threshold.md
@@ -71,7 +71,7 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE

``` ~alice
$ rad sync -a
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
```

``` ~alice
modified radicle-cli/examples/rad-inbox.md
@@ -7,7 +7,7 @@ Your inbox is empty.
``` ~bob
$ cd heartwood
$ rad issue open --title "No license file" --description "..." -q
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
$ git commit -m "Change copyright" --allow-empty -q
$ git push rad HEAD:bob/copy
$ cd ..
@@ -113,7 +113,7 @@ Now let's do an identity update.
$ rad id update --title "Modify description" --description "Use website" --payload xyz.radicle.project description '"https://radicle.xyz"' -q
[..]
$ rad sync -a
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
```

``` ~bob
modified radicle-cli/examples/rad-init-private-clone.md
@@ -14,14 +14,14 @@ Fetching rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu from the network, found 1 potential s
✗ Error: no seeds found for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu
```

-
She allows Bob to view the repository. And when she syncs, one node (Bob) gets
+
She allows Bob to view the repository. And when she syncs, one seed (Bob) gets
the refs.

``` ~alice
$ rad id update --title "Allow Bob" --description "" --allow did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk -q
...
$ rad sync --announce --timeout 3
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
```

Bob can now fetch the private repo without specifying a seed, because he knows
modified radicle-cli/examples/rad-patch-checkout-force.md
@@ -14,7 +14,7 @@ $ git commit -v -m "Define power requirements"
``` ~alice (stderr)
$ git push rad -o patch.message="Define power requirements" -o patch.message="See details." HEAD:refs/patches
✓ Patch aa45913e757cacd46972733bddee5472c78fa32a opened
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -51,7 +51,7 @@ To compare against your previous revision aa45913, run:

   git range-diff f2de534[..] 3e674d1[..] 27857ec[..]

-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   3e674d1..27857ec  flux-capacitor-power -> patches/aa45913e757cacd46972733bddee5472c78fa32a
```
modified radicle-cli/examples/rad-patch-delete.md
@@ -11,7 +11,7 @@ $ git commit -m "Introduce license"
``` ~alice (stderr)
$ git push rad -o patch.draft -o patch.message="Define LICENSE for project" HEAD:refs/patches
✓ Patch 6c61ef1716ad8a5c11e04dd7a3fec51e01fba70b drafted
-
✓ Synced with 2 node(s)
+
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -28,7 +28,7 @@ $ rad patch comment 6c61ef1 -m "I think we should use MIT"
│ bob (you) now 833db19     │
│ I think we should use MIT │
╰───────────────────────────╯
-
✓ Synced with 2 node(s)
+
✓ Synced with 2 seed(s)
```

``` ~alice
@@ -55,7 +55,7 @@ $ rad patch comment 6c61ef1 --reply-to 833db19 -m "Thanks, I'll add it!"
│ alice (you) now 1803a38 │
│ Thanks, I'll add it!    │
╰─────────────────────────╯
-
✓ Synced with 2 node(s)
+
✓ Synced with 2 seed(s)
```

``` ~alice
@@ -75,7 +75,7 @@ To compare against your previous revision 6c61ef1, run:

   git range-diff f2de534[..] 717c900[..] 1cc8cd9[..]

-
✓ Synced with 2 node(s)
+
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   717c900..1cc8cd9  prepare-license -> patches/6c61ef1716ad8a5c11e04dd7a3fec51e01fba70b
```
@@ -83,7 +83,7 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE
``` ~bob
$ rad patch review 6c61ef1 --accept -m "LGTM!"
✓ Patch 6c61ef1 accepted
-
✓ Synced with 2 node(s)
+
✓ Synced with 2 seed(s)
$ rad patch show 6c61ef1 -v
╭─────────────────────────────────────────────────────────────────────╮
│ Title    Define LICENSE for project                                 │
@@ -105,7 +105,7 @@ $ rad patch show 6c61ef1 -v

``` ~bob
$ rad patch delete 6c61ef1
-
✓ Synced with 2 node(s)
+
✓ Synced with 2 seed(s)
```

``` ~alice
@@ -133,7 +133,7 @@ Alice should no longer have the patch:

``` ~alice
$ rad patch delete 6c61ef1
-
✓ Synced with 2 node(s)
+
✓ Synced with 2 seed(s)
```

``` ~seed (fails)
modified radicle-cli/examples/rad-patch-open-explore.md
@@ -5,7 +5,7 @@ $ git checkout -b changes -q
$ git commit --allow-empty -q -m "Changes"
$ git push rad HEAD:refs/patches
✓ Patch acab0ec777a97d013f30be5d5d1aec32562ecb02 opened
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)

  https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/patches/acab0ec777a97d013f30be5d5d1aec32562ecb02

@@ -23,7 +23,7 @@ To compare against your previous revision acab0ec, run:

   git range-diff f2de534[..] e12525d[..] b2b6432[..]

-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)

  https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/patches/acab0ec777a97d013f30be5d5d1aec32562ecb02

@@ -39,7 +39,7 @@ $ git merge changes -q
$ git push rad master
✓ Patch acab0ec777a97d013f30be5d5d1aec32562ecb02 merged
✓ Canonical head updated to b2b6432af93f8fe188e32d400263021b602cfec8
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)

  https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/tree/b2b6432af93f8fe188e32d400263021b602cfec8

modified radicle-cli/examples/rad-patch-pull-update.md
@@ -43,7 +43,7 @@ our fork:
``` ~bob (stderr)
$ cd heartwood
$ git push rad master
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
To rad://zhbMU4DUXrzB8xT6qAJh6yZ7bFMK/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
 * [new branch]      master -> master
```
@@ -55,7 +55,7 @@ $ git checkout -b bob/feature -q
$ git commit --allow-empty -m "Bob's commit #1" -q
$ git push rad -o sync -o patch.message="Bob's patch" HEAD:refs/patches
✓ Patch 55b9721ed7f6bfec38f43729e9b6631c5dc812fb opened
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
To rad://zhbMU4DUXrzB8xT6qAJh6yZ7bFMK/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
 * [new reference]   HEAD -> refs/patches
```
@@ -88,7 +88,7 @@ To compare against your previous revision 55b9721, run:

   git range-diff f2de534[..] bdcdb30[..] cad2666[..]

-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
To rad://zhbMU4DUXrzB8xT6qAJh6yZ7bFMK/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
   bdcdb30..cad2666  bob/feature -> patches/55b9721ed7f6bfec38f43729e9b6631c5dc812fb
```
modified radicle-cli/examples/rad-push-and-pull-patches.md
@@ -38,7 +38,7 @@ To compare against your previous revision d004b67, run:

   git range-diff f2de534[..] 8d5f1ba[..] c2aaf1c[..]

-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new branch]      patch/d004b67 -> patches/d004b67355456c46de10c0d287e4a791ad1a6945
```
@@ -61,7 +61,7 @@ To compare against your previous revision d004b67, run:

   git range-diff f2de534[..] 8d5f1ba[..] d9f8caf[..]

-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   c2aaf1c..d9f8caf  patch/d004b67 -> patches/d004b67355456c46de10c0d287e4a791ad1a6945
```
modified radicle-cli/examples/rad-sync.md
@@ -26,7 +26,7 @@ wait for nodes to announce that they have fetched those refs.

```
$ rad sync --announce
-
✓ Synced with 2 node(s)
+
✓ Synced with 2 seed(s)
```

Now, when we run `rad sync status` again, we can see that `bob` and
@@ -48,7 +48,7 @@ be up to date.

```
$ rad sync --announce
-
✓ Nothing to announce, already in sync with 2 node(s) (see `rad sync status`)
+
✓ Nothing to announce, already in sync with 2 seed(s) (see `rad sync status`)
```

We can also use the `--fetch` option to only fetch objects:
@@ -69,7 +69,7 @@ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 2 potential s
✓ Target met: 2 seed(s)
🌱 Fetched from z6Mkux1…nVhib7Z
🌱 Fetched from z6Mkt67…v4N1tRk
-
✓ Nothing to announce, already in sync with 2 node(s) (see `rad sync status`)
+
✓ Nothing to announce, already in sync with 2 seed(s) (see `rad sync status`)
```

It's also possible to use the `--seed` flag to only sync with a specific node:
@@ -93,7 +93,7 @@ $ rad sync --replicas 1
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 2 potential seed(s).
✓ Target met: 1 seed(s)
🌱 Fetched from z6Mkux1…nVhib7Z
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
```

Note that we see `✓ Fetched repository from 1 seed(s)` and `✓ Synced
modified radicle-cli/examples/workflow/4-patching-contributor.md
@@ -91,5 +91,5 @@ And let's leave a quick comment for our team:
```
$ rad patch comment e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46 --message 'I cannot wait to get back to the 90s!' -q
8c66f87afadc7c7c857f8bb92973c25f64e75776
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
```
modified radicle-cli/examples/workflow/5-patching-maintainer.md
@@ -77,7 +77,7 @@ Great, all fixed up, lets accept and merge the code.
```
$ rad patch review e4934b6 --revision 9d62420 --accept
✓ Patch e4934b6 accepted
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
$ git checkout master
Your branch is up to date with 'rad/master'.
$ git merge patch/e4934b6
@@ -93,7 +93,7 @@ Fast-forward
$ git push rad master
✓ Patch e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46 merged at revision 9d62420
✓ Canonical head updated to f567f695d25b4e8fb63b5f5ad2a584529826e908
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..f567f69  master -> master
```
@@ -135,5 +135,5 @@ patch, marking it as solved:
```
$ rad issue state 9037b7a --solved
✓ Issue 9037b7a is now solved
-
✓ Synced with 1 node(s)
+
✓ Synced with 1 seed(s)
```
modified radicle-cli/src/commands/sync.rs
@@ -1,4 +1,5 @@
use std::cmp::Ordering;
+
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashSet;
use std::ffi::OsString;
@@ -289,7 +290,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        );
    }

-
    match options.op {
+
    match &options.op {
        Operation::Status => {
            let rid = match options.rid {
                Some(rid) => rid,
@@ -315,15 +316,15 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            };
            let settings = settings.clone().with_profile(&profile);

-
            if [SyncDirection::Fetch, SyncDirection::Both].contains(&direction) {
+
            if [SyncDirection::Fetch, SyncDirection::Both].contains(direction) {
                if !profile.policies()?.is_seeding(&rid)? {
                    anyhow::bail!("repository {rid} is not seeded");
                }
                let result = fetch(rid, settings.clone(), &mut node, &profile)?;
                display_fetch_result(&result, options.verbose)
            }
-
            if [SyncDirection::Announce, SyncDirection::Both].contains(&direction) {
-
                announce_refs(rid, settings, options.debug, &mut node, &profile)?;
+
            if [SyncDirection::Announce, SyncDirection::Both].contains(direction) {
+
                announce_refs(rid, settings, &mut node, &profile, &options)?;
            }
        }
        Operation::Synchronize(SyncMode::Inventory) => {
@@ -417,9 +418,9 @@ fn sync_status(
fn announce_refs(
    rid: RepoId,
    settings: SyncSettings,
-
    debug: bool,
    node: &mut Node,
    profile: &Profile,
+
    options: &Options,
) -> anyhow::Result<()> {
    let Ok(repo) = profile.storage.repository(rid) else {
        return Err(anyhow!(
@@ -437,16 +438,19 @@ fn announce_refs(
        }
    }

-
    crate::node::announce(
+
    let result = crate::node::announce(
        &repo,
        settings,
        SyncReporting {
-
            debug,
+
            debug: options.debug,
            ..SyncReporting::default()
        },
        node,
        profile,
    )?;
+
    if let Some(result) = result {
+
        print_announcer_result(&result, options.verbose)
+
    }

    Ok(())
}
@@ -816,3 +820,72 @@ fn display_success<'a>(
        }
    }
}
+

+
fn print_announcer_result(result: &sync::AnnouncerResult, verbose: bool) {
+
    match result {
+
        sync::AnnouncerResult::Success(success) if verbose => {
+
            // N.b. Printing how many seeds were synced with is printed
+
            // elsewhere
+
            match success.outcome() {
+
                sync::announce::SuccessfulOutcome::MinReplicationFactor { preferred, synced }
+
                | sync::announce::SuccessfulOutcome::MaxReplicationFactor { preferred, synced } => {
+
                    if preferred == 0 {
+
                        term::success!("Synced {} seed(s)", term::format::positive(synced));
+
                    } else {
+
                        term::success!(
+
                            "Synced {} preferred seed(s) and {} total seed(s)",
+
                            term::format::positive(preferred),
+
                            term::format::positive(synced)
+
                        );
+
                    }
+
                }
+
            }
+
            print_synced(success.synced());
+
        }
+
        sync::AnnouncerResult::Success(_) => {
+
            // Successes are ignored when `!verbose`.
+
        }
+
        sync::AnnouncerResult::TimedOut(result) => {
+
            if result.synced().is_empty() {
+
                term::error("All seeds timed out, use `rad sync -v` to see the list of seeds");
+
                return;
+
            }
+
            let timed_out = result.timed_out();
+
            term::warning(format!(
+
                "{} seed(s) timed out, use `rad sync -v` to see the list of seeds",
+
                timed_out.len(),
+
            ));
+
            if verbose {
+
                print_synced(result.synced());
+
                for node in timed_out {
+
                    term::warning(format!("{} timed out", term::format::node(node)));
+
                }
+
            }
+
        }
+
        sync::AnnouncerResult::NoNodes(result) => {
+
            term::info!("Announcement could not sync with anymore seeds.");
+
            if verbose {
+
                print_synced(result.synced())
+
            }
+
        }
+
    }
+
}
+

+
fn print_synced(synced: &BTreeMap<NodeId, sync::announce::SyncStatus>) {
+
    for (node, status) in synced.iter() {
+
        let mut message = format!("🌱 Synced with {}", term::format::node(node));
+

+
        match status {
+
            sync::announce::SyncStatus::AlreadySynced => {
+
                message.push_str(&format!("{}", term::format::dim(" (already in sync)")));
+
            }
+
            sync::announce::SyncStatus::Synced { duration } => {
+
                message.push_str(&format!(
+
                    "{}",
+
                    term::format::dim(format!("in {}s", duration.as_secs()))
+
                ));
+
            }
+
        }
+
        term::info!("{}", message);
+
    }
+
}
modified radicle-cli/src/node.rs
@@ -2,13 +2,11 @@ use core::time;
use std::collections::BTreeSet;
use std::io;
use std::io::Write;
-
use std::ops::ControlFlow;

-
use radicle::node::{self, sync, AnnounceResult};
+
use radicle::node::sync;
use radicle::node::{Handle as _, NodeId};
use radicle::storage::{ReadRepository, RepositoryError};
use radicle::{Node, Profile};
-
use radicle_term::format;

use crate::terminal as term;

@@ -82,6 +80,8 @@ pub enum SyncError {
    Node(#[from] radicle::node::Error),
    #[error("all seeds timed out")]
    AllSeedsTimedOut,
+
    #[error(transparent)]
+
    Target(#[from] sync::announce::TargetError),
}

impl SyncError {
@@ -159,137 +159,107 @@ pub fn announce<R: ReadRepository>(
    reporting: SyncReporting,
    node: &mut Node,
    profile: &Profile,
-
) -> Result<AnnounceResult, SyncError> {
+
) -> Result<Option<sync::AnnouncerResult>, SyncError> {
    match announce_(repo, settings, reporting, node, profile) {
        Ok(result) => Ok(result),
        Err(e) if e.is_connection_err() => {
            term::hint("Node is stopped. To announce changes to the network, start it with `rad node start`.");
-
            Ok(AnnounceResult::default())
+
            Ok(None)
        }
        Err(e) => Err(e),
    }
}

-
fn announce_<R: ReadRepository>(
+
fn announce_<R>(
    repo: &R,
    settings: SyncSettings,
    mut reporting: SyncReporting,
    node: &mut Node,
    profile: &Profile,
-
) -> Result<AnnounceResult, SyncError> {
+
) -> Result<Option<sync::AnnouncerResult>, SyncError>
+
where
+
    R: ReadRepository,
+
{
+
    let me = profile.id();
    let rid = repo.id();
    let doc = repo.identity_doc()?;
-
    let mut settings = settings.with_profile(profile);
-
    let unsynced: Vec<_> = if doc.is_public() {
-
        // All seeds.
-
        let all = node.seeds(rid)?;
-
        if all.is_empty() {
-
            term::info!(&mut reporting.completion; "No seeds found for {rid}.");
-
            return Ok(AnnounceResult::default());
-
        }
-
        // Seeds in sync with us.
-
        let synced = all
-
            .iter()
-
            .filter(|s| s.is_synced())
-
            .map(|s| s.nid)
-
            .collect::<BTreeSet<_>>();
-
        // Replicas not counting our local replica.
-
        let replicas = synced.iter().filter(|nid| *nid != profile.id()).count();
-
        // Maximum replication factor we can achieve.
-
        let max_replicas = all.iter().filter(|s| &s.nid != profile.id()).count();
-
        // If the seeds we specified in the sync settings are all synced.
-
        let is_seeds_synced = settings.seeds.iter().all(|s| synced.contains(s));
-
        // If we met our desired replica count. Note that this can never exceed the maximum count.
-
        let is_replicas_synced = replicas >= settings.replicas.lower_bound().min(max_replicas);

-
        // Nothing to do if we've met our sync state.
-
        if is_seeds_synced && is_replicas_synced {
-
            term::success!(
-
                &mut reporting.completion;
-
                "Nothing to announce, already in sync with {replicas} node(s) (see `rad sync status`)"
+
    let settings = settings.with_profile(profile);
+
    let n_preferred_seeds = settings.seeds.len();
+

+
    let config = match sync::PrivateNetwork::private_repo(&doc) {
+
        None => {
+
            let (synced, unsynced) = node.seeds(rid)?.iter().fold(
+
                (BTreeSet::new(), BTreeSet::new()),
+
                |(mut synced, mut unsynced), seed| {
+
                    if seed.is_synced() {
+
                        synced.insert(seed.nid);
+
                    } else {
+
                        unsynced.insert(seed.nid);
+
                    }
+
                    (synced, unsynced)
+
                },
            );
-
            return Ok(AnnounceResult::default());
+
            sync::AnnouncerConfig::public(*me, settings.replicas, settings.seeds, synced, unsynced)
+
        }
+
        Some(network) => {
+
            let sessions = node.sessions()?;
+
            let network =
+
                network.restrict(|nid| sessions.iter().any(|s| s.nid == *nid && s.is_connected()));
+
            sync::AnnouncerConfig::private(*me, settings.replicas, network)
        }
-
        // Return nodes we can announce to. They don't have to be connected directly.
-
        all.iter()
-
            .filter(|s| !s.is_synced() && &s.nid != profile.id())
-
            .map(|s| s.nid)
-
            .collect()
-
    } else {
-
        node.sessions()?
-
            .into_iter()
-
            .filter(|s| s.state.is_connected() && doc.is_visible_to(&s.nid.into()))
-
            .map(|s| s.nid)
-
            .collect()
    };
-

-
    if unsynced.is_empty() {
-
        term::info!(&mut reporting.completion; "No seeds to announce to for {rid}. (see `rad sync status`)");
-
        return Ok(AnnounceResult::default());
-
    }
-
    // Cap the replicas to the maximum achievable.
-
    // Nb. It's impossible to know if a replica follows our node. This means that if we announce
-
    // only our refs, and the replica doesn't follow us, it won't fetch from us.
-
    settings.replicas = settings.replicas.min(unsynced.len());
-

+
    let announcer = match sync::Announcer::new(config) {
+
        Ok(announcer) => announcer,
+
        Err(err) => match err {
+
            sync::AnnouncerError::AlreadySynced(result) => {
+
                term::success!(
+
                    &mut reporting.completion;
+
                    "Nothing to announce, already in sync with {} seed(s) (see `rad sync status`)",
+
                    term::format::positive(result.synced()),
+
                );
+
                return Ok(None);
+
            }
+
            sync::AnnouncerError::NoSeeds => {
+
                term::info!(
+
                    &mut reporting.completion;
+
                    "{}",
+
                    term::format::yellow("No seeds found for {rid}.")
+
                );
+
                return Ok(None);
+
            }
+
            sync::AnnouncerError::Target(err) => return Err(err.into()),
+
        },
+
    };
+
    let target = announcer.target();
+
    let min_replicas = target.replicas().lower_bound();
    let mut spinner = term::spinner_to(
-
        format!("Found {} seed(s)..", unsynced.len()),
+
        format!("Found {} seed(s)..", announcer.progress().unsynced()),
        reporting.completion.clone(),
        reporting.progress.clone(),
    );
-
    let result = node.announce(
-
        rid,
-
        unsynced,
-
        settings.timeout,
-
        |event, replicas| match event {
-
            node::AnnounceEvent::Announced => ControlFlow::Continue(()),
-
            node::AnnounceEvent::RefsSynced { remote, time } => {
-
                spinner.message(format!(
-
                    "Synced with {} in {}..",
-
                    format::dim(remote),
-
                    format::dim(format!("{time:?}"))
-
                ));
-

-
                // We're done syncing when both of these conditions are met:
-
                //
-
                // 1. We've matched or exceeded our target replica count.
-
                // 2. We've synced with one of the seeds specified manually.
-
                if replicas.len() >= settings.replicas.lower_bound()
-
                    && (settings.seeds.is_empty()
-
                        || settings.seeds.iter().any(|s| replicas.contains_key(s)))
-
                {
-
                    ControlFlow::Break(())
-
                } else {
-
                    ControlFlow::Continue(())
-
                }
-
            }
-
        },
-
    )?;

-
    if result.synced.is_empty() {
-
        spinner.failed();
-
    } else {
-
        spinner.message(format!("Synced with {} node(s)", result.synced.len()));
-
        spinner.finish();
-

-
        if reporting.debug {
-
            for (seed, time) in &result.synced {
-
                writeln!(
-
                    &mut reporting.completion,
-
                    "  {}",
-
                    term::format::dim(format!("Synced with {seed} in {time:?}")),
-
                )
-
                .ok();
-
            }
+
    match node.announce(rid, settings.timeout, announcer, |node, progress| {
+
        spinner.message(format!(
+
            "Synced with {}, {} of {} preferred seeds, and {} of at least {} replica(s).",
+
            term::format::node(node),
+
            term::format::secondary(progress.preferred()),
+
            term::format::secondary(n_preferred_seeds),
+
            term::format::secondary(progress.synced()),
+
            term::format::secondary(min_replicas),
+
        ));
+
    }) {
+
        Ok(result) => {
+
            spinner.message(format!(
+
                "Synced with {} seed(s)",
+
                term::format::positive(result.synced().len())
+
            ));
+
            spinner.finish();
+
            Ok(Some(result))
        }
-
    }
-
    for seed in &result.timed_out {
-
        if settings.seeds.contains(seed) {
-
            term::notice!(&mut reporting.completion; "Seed {seed} timed out..");
+
        Err(err) => {
+
            spinner.error("Sync failed: {err}");
+
            Err(err.into())
        }
    }
-
    if result.synced.is_empty() {
-
        return Err(SyncError::AllSeedsTimedOut);
-
    }
-
    Ok(result)
}
modified radicle-cli/tests/commands.rs
@@ -553,8 +553,8 @@ fn rad_id_threshold() {
        .follow(seed.id, Some(Alias::new("seed")))
        .unwrap();

-
    alice.connect(&seed);
-
    bob.connect(&seed).connect(&alice);
+
    alice.connect(&seed).connect(&bob);
+
    bob.connect(&seed);
    alice.routes_to(&[(acme, seed.id)]);
    seed.handle.fetch(acme, alice.id, DEFAULT_TIMEOUT).unwrap();

modified radicle-remote-helper/src/push.rs
@@ -878,18 +878,20 @@ fn sync(

    let mut urls = Vec::new();

-
    for seed in profile.config.preferred_seeds.iter() {
-
        if result.synced(&seed.id).is_some() {
-
            for resource in updated {
-
                let url = profile
-
                    .config
-
                    .public_explorer
-
                    .url(seed.addr.host.clone(), repo.id)
-
                    .resource(resource);
-

-
                urls.push(url);
+
    if let Some(result) = result {
+
        for seed in profile.config.preferred_seeds.iter() {
+
            if result.is_synced(&seed.id) {
+
                for resource in updated {
+
                    let url = profile
+
                        .config
+
                        .public_explorer
+
                        .url(seed.addr.host.clone(), repo.id)
+
                        .resource(resource);
+

+
                    urls.push(url);
+
                }
+
                break;
            }
-
            break;
        }
    }

modified radicle/src/node.rs
@@ -817,37 +817,6 @@ impl From<Vec<Seed>> for Seeds {
    }
}

-
/// Announcement result returned by [`Node::announce`].
-
#[derive(Debug, Default)]
-
pub struct AnnounceResult {
-
    /// Nodes that timed out.
-
    pub timed_out: Vec<NodeId>,
-
    /// Nodes that synced.
-
    pub synced: Vec<(NodeId, time::Duration)>,
-
}
-

-
impl AnnounceResult {
-
    /// Check if a node synced successfully.
-
    pub fn synced(&self, nid: &NodeId) -> Option<time::Duration> {
-
        self.synced
-
            .iter()
-
            .find(|(id, _)| id == nid)
-
            .map(|(_, time)| *time)
-
    }
-
}
-

-
/// A sync event, emitted by [`Node::announce`].
-
#[derive(Debug)]
-
pub enum AnnounceEvent {
-
    /// Refs were synced with the given node.
-
    RefsSynced {
-
        remote: NodeId,
-
        time: time::Duration,
-
    },
-
    /// Refs were announced to all given nodes.
-
    Announced,
-
}
-

#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum FetchResult {
@@ -1161,25 +1130,24 @@ impl Node {
    pub fn announce(
        &mut self,
        rid: RepoId,
-
        seeds: impl IntoIterator<Item = NodeId>,
        timeout: time::Duration,
-
        mut callback: impl FnMut(AnnounceEvent, &HashMap<PublicKey, time::Duration>) -> ControlFlow<()>,
-
    ) -> Result<AnnounceResult, Error> {
-
        let events = self.subscribe(timeout)?;
+
        mut announcer: sync::Announcer,
+
        mut report: impl FnMut(&NodeId, sync::announce::Progress),
+
    ) -> Result<sync::AnnouncerResult, Error> {
+
        let mut events = self.subscribe(timeout)?;
        let refs = self.announce_refs(rid)?;

-
        let mut unsynced = seeds.into_iter().collect::<BTreeSet<_>>();
-
        let mut synced = HashMap::new();
-
        let mut timed_out: Vec<NodeId> = Vec::new();
        let started = time::Instant::now();

-
        callback(AnnounceEvent::Announced, &synced);
-

-
        for e in events {
+
        loop {
+
            let Some(e) = events.next() else {
+
                // Consider the announcement as timed out if there are no more
+
                // events
+
                return Ok(announcer.timed_out());
+
            };
            let elapsed = started.elapsed();
            if elapsed >= timeout {
-
                timed_out.extend(unsynced.iter());
-
                break;
+
                return Ok(announcer.timed_out());
            }
            match e {
                Ok(Event::RefsSynced {
@@ -1188,37 +1156,29 @@ impl Node {
                    at,
                }) if rid == rid_ && refs.at == at => {
                    log::debug!(target: "radicle", "Received {e:?}");
-

-
                    unsynced.remove(&remote);
-
                    // We can receive synced events from nodes we didn't directly announce to,
-
                    // and it's possible to receive duplicates as well.
-
                    if synced.insert(remote, elapsed).is_none() {
-
                        let event = AnnounceEvent::RefsSynced {
-
                            remote,
-
                            time: elapsed,
-
                        };
-
                        if callback(event, &synced).is_break() {
-
                            break;
+
                    match announcer.synced_with(remote, elapsed) {
+
                        ControlFlow::Continue(progress) => {
+
                            report(&remote, progress);
+
                        }
+
                        ControlFlow::Break(finished) => {
+
                            return Ok(finished.into());
                        }
                    }
                }
                Ok(_) => {}

                Err(Error::TimedOut) => {
-
                    timed_out.extend(unsynced.iter());
-
                    break;
+
                    return Ok(announcer.timed_out());
                }
                Err(e) => return Err(e),
            }
-
            if unsynced.is_empty() {
-
                break;
-
            }
+
            // Ensure that the announcer is still waiting for nodes to be
+
            // in-sync with
+
            announcer = match announcer.can_continue() {
+
                ControlFlow::Continue(cont) => cont,
+
                ControlFlow::Break(finished) => return Ok(finished.into()),
+
            };
        }
-

-
        Ok(AnnounceResult {
-
            timed_out,
-
            synced: synced.into_iter().collect(),
-
        })
    }
}

modified radicle/src/node/sync.rs
@@ -1,3 +1,6 @@
+
pub mod announce;
+
pub use announce::{Announcer, AnnouncerConfig, AnnouncerError, AnnouncerResult};
+

pub mod fetch;
pub use fetch::{Fetcher, FetcherConfig, FetcherError, FetcherResult};

added radicle/src/node/sync/announce.rs
@@ -0,0 +1,464 @@
+
use std::{
+
    collections::{BTreeMap, BTreeSet},
+
    ops::ControlFlow,
+
    time,
+
};
+

+
use crate::node::NodeId;
+

+
use super::{PrivateNetwork, ReplicationFactor};
+

+
pub struct Announcer {
+
    local_node: NodeId,
+
    target: Target,
+
    synced: BTreeMap<NodeId, SyncStatus>,
+
    to_sync: BTreeSet<NodeId>,
+
}
+

+
impl Announcer {
+
    /// Construct a new [`Announcer`] from the [`AnnouncerConfig`].
+
    ///
+
    /// This will ensure that the local [`NodeId`], provided in the
+
    /// [`AnnouncerConfig`], will be removed from all sets.
+
    ///
+
    /// # Errors
+
    ///
+
    /// Returns the following errors:
+
    ///
+
    ///   - [`AnnouncerError::NoSeeds`]: both sets of already synchronized and
+
    ///     un-synchronized nodes were empty
+
    ///     of nodes were empty
+
    ///   - [`AnnouncerError::AlreadySynced`]: no more nodes are available for
+
    ///     synchronizing with
+
    ///   - [`AnnouncerError::Target`]: the target has no preferred seeds and no
+
    ///     replicas
+
    pub fn new(mut config: AnnouncerConfig) -> Result<Self, AnnouncerError> {
+
        // N.b. ensure that local node is none of the sets
+
        config.preferred_seeds.remove(&config.local_node);
+
        config.synced.remove(&config.local_node);
+
        config.unsynced.remove(&config.local_node);
+

+
        if config.synced.is_empty() && config.unsynced.is_empty() {
+
            return Err(AnnouncerError::NoSeeds);
+
        }
+

+
        if config.unsynced.is_empty() {
+
            let preferred = config.synced.intersection(&config.preferred_seeds).count();
+
            return Err(AlreadySynced {
+
                preferred,
+
                synced: config.synced.len(),
+
            }
+
            .into());
+
        }
+

+
        let replicas = config.replicas.min(config.unsynced.len());
+
        let announcer = Self {
+
            local_node: config.local_node,
+
            target: Target::new(config.preferred_seeds, replicas)
+
                .map_err(AnnouncerError::Target)?,
+
            synced: config
+
                .synced
+
                .into_iter()
+
                .map(|nid| (nid, SyncStatus::AlreadySynced))
+
                .collect(),
+
            to_sync: config.unsynced,
+
        };
+
        match announcer.is_target_reached() {
+
            None => Ok(announcer),
+
            Some(outcome) => match outcome {
+
                SuccessfulOutcome::MinReplicationFactor { preferred, synced } => {
+
                    Err(AlreadySynced { preferred, synced }.into())
+
                }
+
                SuccessfulOutcome::MaxReplicationFactor { preferred, synced } => {
+
                    Err(AlreadySynced { preferred, synced }.into())
+
                }
+
            },
+
        }
+
    }
+

+
    /// Mark the `node` as synchronized, with the given `duration` it took to
+
    /// synchronize with.
+
    ///
+
    /// If the target for the [`Announcer`] has been reached, then a [`Success`] is
+
    /// returned via [`ControlFlow::Break`]. Otherwise, [`Progress`] is returned
+
    /// via [`ControlFlow::Continue`].
+
    ///
+
    /// The caller decides whether they wish to continue the announcement process.
+
    pub fn synced_with(
+
        &mut self,
+
        node: NodeId,
+
        duration: time::Duration,
+
    ) -> ControlFlow<Success, Progress> {
+
        if node == self.local_node {
+
            return ControlFlow::Continue(self.progress());
+
        }
+
        self.to_sync.remove(&node);
+
        self.synced.insert(node, SyncStatus::Synced { duration });
+
        self.finished()
+
    }
+

+
    /// Complete the [`Announcer`] process returning a [`AnnouncerResult`].
+
    ///
+
    /// If the target for the [`Announcer`] has been reached, then the result
+
    /// will be [`AnnouncerResult::Success`], otherwise, it will be
+
    /// [`AnnouncerResult::TimedOut`].
+
    pub fn timed_out(self) -> AnnouncerResult {
+
        match self.is_target_reached() {
+
            None => TimedOut {
+
                synced: self.synced,
+
                timed_out: self.to_sync,
+
            }
+
            .into(),
+
            Some(outcome) => Success {
+
                outcome,
+
                synced: self.synced,
+
            }
+
            .into(),
+
        }
+
    }
+

+
    /// Check if the [`Announcer`] can continue synchronizing with more nodes.
+
    /// If there are no more nodes, then [`NoNodes`] is returned in the
+
    /// [`ControlFlow::Break`], otherwise the [`Announcer`] is returned as-is in
+
    /// the [`ControlFlow::Continue`].
+
    pub fn can_continue(self) -> ControlFlow<NoNodes, Self> {
+
        if self.to_sync.is_empty() {
+
            ControlFlow::Break(NoNodes {
+
                synced: self.synced,
+
            })
+
        } else {
+
            ControlFlow::Continue(self)
+
        }
+
    }
+

+
    /// Get all the nodes to be synchronized with.
+
    pub fn to_sync(&self) -> BTreeSet<NodeId> {
+
        self.to_sync
+
            .iter()
+
            .filter(|node| *node != &self.local_node)
+
            .copied()
+
            .collect()
+
    }
+

+
    /// Get the [`Target`] of the [`Announcer`].
+
    pub fn target(&self) -> &Target {
+
        &self.target
+
    }
+

+
    /// Get the [`Progress`] of the [`Announcer`].
+
    pub fn progress(&self) -> Progress {
+
        let (synced, preferred) = self.success_counts();
+
        let unsynced = self.to_sync.len().saturating_sub(synced);
+
        Progress {
+
            preferred,
+
            synced,
+
            unsynced,
+
        }
+
    }
+

+
    fn finished(&self) -> ControlFlow<Success, Progress> {
+
        let progress = self.progress();
+
        self.is_target_reached()
+
            .map_or(ControlFlow::Continue(progress), |outcome| {
+
                ControlFlow::Break(Success {
+
                    outcome,
+
                    synced: self.synced.clone(),
+
                })
+
            })
+
    }
+

+
    fn is_target_reached(&self) -> Option<SuccessfulOutcome> {
+
        let (preferred, synced) = self.success_counts();
+
        let reached_preferred = self.target.preferred_seeds.is_empty()
+
            || preferred >= self.target.preferred_seeds.len();
+

+
        let replicas = self.target.replicas();
+
        let min = replicas.lower_bound();
+
        match replicas.upper_bound() {
+
            None => (reached_preferred && synced >= min)
+
                .then_some(SuccessfulOutcome::MinReplicationFactor { preferred, synced }),
+
            Some(max) => (reached_preferred && synced >= max)
+
                .then_some(SuccessfulOutcome::MaxReplicationFactor { preferred, synced }),
+
        }
+
    }
+

+
    fn success_counts(&self) -> (usize, usize) {
+
        self.synced
+
            .keys()
+
            .fold((0, 0), |(mut preferred, mut succeeded), nid| {
+
                succeeded += 1;
+
                if self.target.preferred_seeds.contains(nid) {
+
                    preferred += 1;
+
                }
+
                (preferred, succeeded)
+
            })
+
    }
+
}
+

+
/// Configuration of the [`Announcer`].
+
pub struct AnnouncerConfig {
+
    local_node: NodeId,
+
    replicas: ReplicationFactor,
+
    preferred_seeds: BTreeSet<NodeId>,
+
    synced: BTreeSet<NodeId>,
+
    unsynced: BTreeSet<NodeId>,
+
}
+

+
impl AnnouncerConfig {
+
    /// Setup a private network `AnnouncerConfig`, populating the
+
    /// [`AnnouncerConfig`]'s preferred seeds with the allowed set from the
+
    /// [`PrivateNetwork`].
+
    ///
+
    /// `replicas` is the target number of seeds the [`Announcer`] should reach
+
    /// before stopping.
+
    ///
+
    /// `local` is the [`NodeId`] of the local node, to ensure it is
+
    /// excluded from the [`Announcer`] process.
+
    pub fn private(local: NodeId, replicas: ReplicationFactor, network: PrivateNetwork) -> Self {
+
        AnnouncerConfig {
+
            local_node: local,
+
            replicas,
+
            preferred_seeds: network.allowed.clone(),
+
            synced: BTreeSet::new(),
+
            unsynced: network.allowed,
+
        }
+
    }
+

+
    /// Setup a public `AnnouncerConfig`.
+
    ///
+
    /// `preferred_seeds` is the target set of preferred seeds that [`Announcer`] should
+
    /// attempt to synchronize with.
+
    ///
+
    /// `synced` and `unsynced` are the set of nodes that are currently
+
    /// synchronized and un-synchronized with, respectively.
+
    ///
+
    /// `replicas` is the target number of seeds the [`Announcer`] should reach
+
    /// before stopping.
+
    ///
+
    /// `local` is the [`NodeId`] of the local node, to ensure it is
+
    /// excluded from the [`Announcer`] process.
+
    pub fn public(
+
        local: NodeId,
+
        replicas: ReplicationFactor,
+
        preferred_seeds: BTreeSet<NodeId>,
+
        synced: BTreeSet<NodeId>,
+
        unsynced: BTreeSet<NodeId>,
+
    ) -> Self {
+
        Self {
+
            local_node: local,
+
            replicas,
+
            preferred_seeds,
+
            synced,
+
            unsynced,
+
        }
+
    }
+
}
+

+
/// Result of running an [`Announcer`] process.
+
pub enum AnnouncerResult {
+
    /// The target of the [`Announcer`] was successfully met.
+
    Success(Success),
+
    /// The [`Announcer`] process was timed out, and all un-synchronized nodes
+
    /// are marked as timed out.
+
    ///
+
    /// Note that some nodes still may have synchronized.
+
    TimedOut(TimedOut),
+
    /// The [`Announcer`] ran out of nodes to synchronize with.
+
    ///
+
    /// Note that some nodes still may have synchronized.
+
    NoNodes(NoNodes),
+
}
+

+
impl AnnouncerResult {
+
    /// Get the synchronized nodes, regardless of the result.
+
    pub fn synced(&self) -> &BTreeMap<NodeId, SyncStatus> {
+
        match self {
+
            AnnouncerResult::Success(Success { synced, .. }) => synced,
+
            AnnouncerResult::TimedOut(TimedOut { synced, .. }) => synced,
+
            AnnouncerResult::NoNodes(NoNodes { synced }) => synced,
+
        }
+
    }
+

+
    /// Check if a given node is synchronized with.
+
    pub fn is_synced(&self, node: &NodeId) -> bool {
+
        let synced = self.synced();
+
        synced.contains_key(node)
+
    }
+
}
+

+
impl From<Success> for AnnouncerResult {
+
    fn from(s: Success) -> Self {
+
        Self::Success(s)
+
    }
+
}
+

+
impl From<TimedOut> for AnnouncerResult {
+
    fn from(to: TimedOut) -> Self {
+
        Self::TimedOut(to)
+
    }
+
}
+

+
impl From<NoNodes> for AnnouncerResult {
+
    fn from(no: NoNodes) -> Self {
+
        Self::NoNodes(no)
+
    }
+
}
+

+
pub struct NoNodes {
+
    synced: BTreeMap<NodeId, SyncStatus>,
+
}
+

+
impl NoNodes {
+
    /// Get the set of synchronized nodes
+
    pub fn synced(&self) -> &BTreeMap<NodeId, SyncStatus> {
+
        &self.synced
+
    }
+
}
+

+
pub struct TimedOut {
+
    synced: BTreeMap<NodeId, SyncStatus>,
+
    timed_out: BTreeSet<NodeId>,
+
}
+

+
impl TimedOut {
+
    /// Get the set of synchronized nodes
+
    pub fn synced(&self) -> &BTreeMap<NodeId, SyncStatus> {
+
        &self.synced
+
    }
+

+
    /// Get the set of timed out nodes
+
    pub fn timed_out(&self) -> &BTreeSet<NodeId> {
+
        &self.timed_out
+
    }
+
}
+

+
pub struct Success {
+
    outcome: SuccessfulOutcome,
+
    synced: BTreeMap<NodeId, SyncStatus>,
+
}
+

+
impl Success {
+
    /// Get the [`SuccessfulOutcome`] of the success.
+
    pub fn outcome(&self) -> SuccessfulOutcome {
+
        self.outcome
+
    }
+

+
    /// Get the set of synchronized nodes.
+
    pub fn synced(&self) -> &BTreeMap<NodeId, SyncStatus> {
+
        &self.synced
+
    }
+
}
+

+
/// Error in constructing the [`Announcer`].
+
pub enum AnnouncerError {
+
    /// Both sets of already synchronized and un-synchronized nodes were empty
+
    /// of nodes were empty.
+
    AlreadySynced(AlreadySynced),
+
    /// No more nodes are available for synchronizing with.
+
    NoSeeds,
+
    /// The target could not be constructed.
+
    Target(TargetError),
+
}
+

+
impl From<AlreadySynced> for AnnouncerError {
+
    fn from(value: AlreadySynced) -> Self {
+
        Self::AlreadySynced(value)
+
    }
+
}
+

+
pub struct AlreadySynced {
+
    preferred: usize,
+
    synced: usize,
+
}
+

+
impl AlreadySynced {
+
    /// Get the number of preferred nodes that are already synchronized.
+
    pub fn preferred(&self) -> usize {
+
        self.preferred
+
    }
+

+
    /// Get the total number of nodes that are already synchronized.
+
    pub fn synced(&self) -> usize {
+
        self.synced
+
    }
+
}
+

+
/// The status of the synchronized node.
+
#[derive(Clone, Copy, Debug)]
+
pub enum SyncStatus {
+
    /// The node was already synchronized before starting the [`Announcer`]
+
    /// process.
+
    AlreadySynced,
+
    /// The node was synchronized as part of the [`Announcer`] process, marking
+
    /// the amount of time that passed to synchronize with the node.
+
    Synced { duration: time::Duration },
+
}
+

+
/// Progress of the [`Announcer`] process.
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+
pub struct Progress {
+
    preferred: usize,
+
    synced: usize,
+
    unsynced: usize,
+
}
+

+
impl Progress {
+
    /// The number of preferred seeds that are synchronized.
+
    pub fn preferred(&self) -> usize {
+
        self.preferred
+
    }
+

+
    /// The number of seeds that are synchronized.
+
    pub fn synced(&self) -> usize {
+
        self.synced
+
    }
+

+
    /// The number of seeds that are un-synchronized.
+
    pub fn unsynced(&self) -> usize {
+
        self.unsynced
+
    }
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
#[non_exhaustive]
+
#[error("a minimum number of replicas or set of preferred seeds must be provided")]
+
pub struct TargetError;
+

+
/// The target for the [`Announcer`] to reach.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Target {
+
    preferred_seeds: BTreeSet<NodeId>,
+
    replicas: ReplicationFactor,
+
}
+

+
impl Target {
+
    pub fn new(
+
        preferred_seeds: BTreeSet<NodeId>,
+
        replicas: ReplicationFactor,
+
    ) -> Result<Self, TargetError> {
+
        if replicas.lower_bound() == 0 && preferred_seeds.is_empty() {
+
            Err(TargetError)
+
        } else {
+
            Ok(Self {
+
                preferred_seeds,
+
                replicas,
+
            })
+
        }
+
    }
+

+
    /// Get the set of preferred seeds that are trying to be synchronized with.
+
    pub fn preferred_seeds(&self) -> &BTreeSet<NodeId> {
+
        &self.preferred_seeds
+
    }
+

+
    /// Get the number of replicas that is trying to be reached.
+
    pub fn replicas(&self) -> &ReplicationFactor {
+
        &self.replicas
+
    }
+
}
+

+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+
pub enum SuccessfulOutcome {
+
    MinReplicationFactor { preferred: usize, synced: usize },
+
    MaxReplicationFactor { preferred: usize, synced: usize },
+
}