Skip to main content

hashiverse_lib/client/post_bundle/
post_bundle_healing.rs

1//! # Peer-to-peer reconciliation of divergent post bundles
2//!
3//! When multiple peers return different versions of the same post bundle — one
4//! might hold posts the others have missed — this module spawns a background task that
5//! sends each laggard peer the posts it's missing.
6//!
7//! The sync is a two-phase, PoW-gated protocol:
8//!
9//! 1. **Claim** (`HealPostBundleClaimV1`) — the initiator describes what it holds; the
10//!    remote server returns which post IDs it's still missing and issues a commit token
11//!    authorising the follow-up upload.
12//! 2. **Commit** (`HealPostBundleCommitV1`) — the initiator uploads just those missing
13//!    post bytes under the token.
14//!
15//! Splitting into claim + commit keeps the bandwidth-hungry upload from happening until
16//! the receiver has confirmed it actually wants the data, and it makes per-server
17//! response authentication cheap (the token is what the commit carries).
18
19use crate::anyhow_assert_eq;
20use crate::protocol::payload::payload::{HealPostBundleClaimResponseV1, HealPostBundleClaimV1, HealPostBundleCommitResponseV1, HealPostBundleCommitV1, PayloadRequestKind, PayloadResponseKind};
21use crate::protocol::peer::Peer;
22use crate::protocol::posting::encoded_post_bundle::{EncodedPostBundleHeaderV1, EncodedPostBundleV1};
23use crate::protocol::rpc;
24use crate::tools::buckets::BucketLocation;
25use crate::tools::json;
26use crate::tools::runtime_services::RuntimeServices;
27use crate::tools::types::Id;
28use bytes::Bytes;
29use log::{info, warn};
30use std::collections::HashMap;
31use std::sync::Arc;
32
33struct HealWork {
34    target_peer: Peer,
35    bucket_location: BucketLocation,
36    donor_header: EncodedPostBundleHeaderV1,
37    donor_posts_bytes: Bytes,
38    post_byte_map: HashMap<Id, (usize, usize)>,
39}
40
41/// Spawns a background task that serially heals every target server whose bundle is missing posts that a donor bundle has.
42///
43/// - `donor_bundles` must be authoritative bundles (verified, holding at least one genuinely signed post) - direct responses or cached snapshots,
44///   including snapshots signed by servers that have since died.
45/// - `target_bundles` are the bundles returned directly by the servers we visited, paired with the [`Peer`] we actually sent the RPC to.
46///   We only heal servers we have spoken to (never one we only hold a cached snapshot of), and we address them using that authenticated
47///   peer rather than the `peer` inside the bundle header, whose address is whatever the bundle's publisher chose to put there.
48pub fn heal_post_bundles(runtime_services: Arc<RuntimeServices>, sponsor_id: Id, bucket_location: BucketLocation, donor_bundles: &[EncodedPostBundleV1], target_bundles: &[(Peer, EncodedPostBundleV1)]) {
49    let work = build_heal_work(&bucket_location, donor_bundles, target_bundles);
50
51    if !work.is_empty() {
52        crate::tools::tools::spawn_background_task(async move {
53            for task in work {
54                let result = try_heal_single_bundle(&runtime_services, &sponsor_id, &task.target_peer, task.bucket_location, task.donor_header, &task.donor_posts_bytes, &task.post_byte_map).await;
55                if let Err(e) = result {
56                    warn!("Healing failed for target_peer={}: {}", task.target_peer, e);
57                }
58            }
59        });
60    }
61}
62
63/// For each (donor, target) pair: if the donor has posts that the target is missing, queue a heal of the target from that donor.
64/// This might queue up duplicate heal tasks for a server, but the server will deny the second heal attempt.
65fn build_heal_work(bucket_location: &BucketLocation, donor_bundles: &[EncodedPostBundleV1], target_bundles: &[(Peer, EncodedPostBundleV1)]) -> Vec<HealWork> {
66    // Build a post_byte_map for each donor once (offset, len per post_id)
67    let donor_byte_maps: Vec<HashMap<Id, (usize, usize)>> = donor_bundles
68        .iter()
69        .map(|donor_bundle| {
70            let mut map = HashMap::new();
71            let mut offset = 0usize;
72            for (post_id, &len) in donor_bundle.header.encoded_post_ids.iter().zip(donor_bundle.header.encoded_post_lengths.iter()) {
73                map.insert(*post_id, (offset, len));
74                offset += len;
75            }
76            map
77        })
78        .collect();
79
80    let mut work: Vec<HealWork> = Vec::new();
81    for (donor_bundle, donor_byte_map) in donor_bundles.iter().zip(donor_byte_maps.iter()) {
82        for (target_peer, target_bundle) in target_bundles {
83            if donor_bundle.header.peer.id == target_peer.id {
84                continue;
85            }
86            if donor_bundle.header.encoded_post_ids.iter().any(|id| !target_bundle.header.encoded_post_ids.contains(id)) {
87                info!("Queueing healing for target_peer={} bucket_location={}", target_peer, bucket_location);
88                work.push(HealWork {
89                    target_peer: target_peer.clone(),
90                    bucket_location: bucket_location.clone(),
91                    donor_header: donor_bundle.header.clone(),
92                    donor_posts_bytes: donor_bundle.encoded_posts_bytes.clone(),
93                    post_byte_map: donor_byte_map.clone(),
94                });
95            }
96        }
97    }
98    work
99}
100
101async fn try_heal_single_bundle(
102    runtime_services: &RuntimeServices,
103    sponsor_id: &Id,
104    target_peer: &Peer,
105    bucket_location: BucketLocation,
106    donor_header: EncodedPostBundleHeaderV1,
107    donor_posts_bytes: &[u8],
108    post_byte_map: &HashMap<Id, (usize, usize)>,
109) -> anyhow::Result<()> {
110    info!("About to heal {}", target_peer);
111
112    // Phase 1: Claim — ask the server which posts it needs from this donor bundle
113    let request_bytes = HealPostBundleClaimV1::new_to_bytes(&donor_header, &bucket_location)?;
114    let response = rpc::rpc::rpc_server_known(runtime_services, sponsor_id, target_peer, PayloadRequestKind::HealPostBundleClaimV1, request_bytes).await?;
115    anyhow_assert_eq!(&PayloadResponseKind::HealPostBundleClaimResponseV1, &response.response_request_kind);
116    let response = json::bytes_to_struct::<HealPostBundleClaimResponseV1>(&response.bytes)?;
117
118    let Some(token) = response.token
119    else {
120        info!("Healing skipped because of no HealPostBundleClaimTokenV1 for {}", target_peer);
121        return Ok(()); // Server is up to date or is busy with another heal
122    };
123
124    // Assemble the bytes for the posts the server needs, in token order
125    let mut encoded_posts_bytes = Vec::new();
126    for post_id in &token.needed_post_ids {
127        let &(offset, len) = post_byte_map.get(post_id).ok_or_else(|| anyhow::anyhow!("needed post {} not in donor bundle", post_id))?;
128        encoded_posts_bytes.extend_from_slice(&donor_posts_bytes[offset..offset + len]);
129    }
130
131    // Phase 2: Commit — send the missing post bytes
132    let request_bytes = HealPostBundleCommitV1::new_to_bytes(&token, &donor_header, &encoded_posts_bytes)?;
133    let response = rpc::rpc::rpc_server_known_with_no_compression(runtime_services, sponsor_id, target_peer, PayloadRequestKind::HealPostBundleCommitV1, request_bytes).await?;
134    anyhow_assert_eq!(&PayloadResponseKind::HealPostBundleCommitResponseV1, &response.response_request_kind);
135    let response = json::bytes_to_struct::<HealPostBundleCommitResponseV1>(&response.bytes)?;
136
137    if response.accepted {
138        info!("Healed {} post(s) on {} from {}", token.needed_post_ids.len(), target_peer, donor_header.peer);
139    }
140    else {
141        info!("Healing not accepted  for {} post(s) on {} from {}", token.needed_post_ids.len(), target_peer, donor_header.peer);
142    }
143
144    Ok(())
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::protocol::posting::encoded_post_bundle::test_fixtures::BucketBundleFixture;
151    use crate::tools::time_provider::time_provider::RealTimeProvider;
152
153    #[tokio::test]
154    async fn heals_targets_missing_donor_posts_addressed_via_the_rpc_peer() -> anyhow::Result<()> {
155        let fixture = BucketBundleFixture::new().await?;
156        let posts = fixture.make_own_posts(2).await?;
157
158        let donor_server_id = BucketBundleFixture::new_server_id().await?;
159        let donor_bundle = fixture.make_bundle_from(&donor_server_id, &posts)?;
160
161        // The target's own bundle claims an address chosen by its publisher; the peer we actually spoke to has a different one
162        let target_server_id = BucketBundleFixture::new_server_id().await?;
163        let mut header_peer = target_server_id.to_peer(&RealTimeProvider)?;
164        header_peer.address = "attacker-chosen-address".to_string();
165        header_peer.sign(&RealTimeProvider, &target_server_id.keys.signature_key)?;
166        let target_bundle = fixture.make_bundle_signed_by(&target_server_id, header_peer, fixture.time_millis_now, &posts[..1])?;
167        let mut rpc_peer = target_server_id.to_peer(&RealTimeProvider)?;
168        rpc_peer.address = "the-address-we-actually-used".to_string();
169
170        let work = build_heal_work(&fixture.bucket_location, std::slice::from_ref(&donor_bundle), &[(rpc_peer, target_bundle)]);
171        assert_eq!(work.len(), 1);
172        assert_eq!(work[0].target_peer.address, "the-address-we-actually-used");
173        assert_eq!(work[0].donor_header, donor_bundle.header);
174        Ok(())
175    }
176
177    #[tokio::test]
178    async fn does_not_heal_up_to_date_targets_or_donors_themselves() -> anyhow::Result<()> {
179        let fixture = BucketBundleFixture::new().await?;
180        let posts = fixture.make_own_posts(2).await?;
181
182        let donor_server_id = BucketBundleFixture::new_server_id().await?;
183        let donor_bundle = fixture.make_bundle_from(&donor_server_id, &posts)?;
184        let up_to_date_server_id = BucketBundleFixture::new_server_id().await?;
185        let up_to_date_bundle = fixture.make_bundle_from(&up_to_date_server_id, &posts)?;
186
187        let target_bundles = vec![
188            (donor_server_id.to_peer(&RealTimeProvider)?, donor_bundle.clone()),
189            (up_to_date_server_id.to_peer(&RealTimeProvider)?, up_to_date_bundle),
190        ];
191        assert!(build_heal_work(&fixture.bucket_location, std::slice::from_ref(&donor_bundle), &target_bundles).is_empty());
192        Ok(())
193    }
194
195    #[tokio::test]
196    async fn empty_direct_responder_is_a_target() -> anyhow::Result<()> {
197        let fixture = BucketBundleFixture::new().await?;
198        let posts = fixture.make_own_posts(1).await?;
199
200        let donor_server_id = BucketBundleFixture::new_server_id().await?;
201        let donor_bundle = fixture.make_bundle_from(&donor_server_id, &posts)?;
202        let empty_server_id = BucketBundleFixture::new_server_id().await?;
203        let empty_bundle = fixture.make_bundle_from(&empty_server_id, &[])?;
204
205        let work = build_heal_work(&fixture.bucket_location, &[donor_bundle], &[(empty_server_id.to_peer(&RealTimeProvider)?, empty_bundle)]);
206        assert_eq!(work.len(), 1);
207        assert_eq!(work[0].target_peer.id, empty_server_id.id);
208        Ok(())
209    }
210}