Protocol v2: trusted-peer push, truthful partials, split downloads
Three capabilities the iroh-blobs 0.103 line makes possible:
- Push (new API surface, protocol v2): `Push { hash, node_id }` hands
fully-present content to a trusted peer, unprompted. Consent is
mutual — the sender pushes only to peers it trusts, and the receiver
(which now accepts connections unconditionally and gates per request)
admits pushes only from peers *it* trusts, deferring with RateLimited
while metered. An accepted push is pinned by the receiver (default
policy, format inferred from the request ranges) once its transfer
completes, and surfaces as a PushReceived event. Because QUIC writes
are fire-and-forget, the sender confirms delivery by observing the
receiver's bitfields (root + last hashseq child) before replying —
Pushed { bytes } means verified received, not merely sent. New
varde-ctl `push` command.
- Truthful partial presence: Status/List report bytes actually present
and verified for partial blobs, from the store's bitfields via
observe, instead of the old "0 until complete".
- Split downloads: multi-provider fetches stripe one request across
providers (SplitStrategy::Split) instead of trying them serially.
Trusted peers may observe bitfields even when serving is disabled or
metered — bitfields are metadata, and push confirmation rides on them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ use iroh::address_lookup::memory::MemoryLookup;
|
||||
use iroh::protocol::Router;
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey};
|
||||
use iroh_blobs::api::downloader::{DownloadOptions, Downloader, Shuffled, SplitStrategy};
|
||||
use iroh_blobs::protocol::GetRequest;
|
||||
use iroh_blobs::{BlobFormat, BlobsProtocol, Hash, HashAndFormat};
|
||||
use iroh_mdns_address_lookup::{DiscoveryEvent, MdnsAddressLookup};
|
||||
use n0_future::StreamExt;
|
||||
@@ -23,7 +24,7 @@ use varde_proto::{Direction, Event};
|
||||
use crate::config::Config;
|
||||
use crate::meta::Meta;
|
||||
use crate::metered::MeteredState;
|
||||
use crate::shaped::{Gate, ServeFilter, TokenBucket};
|
||||
use crate::shaped::{Gate, ReceivedPush, ServeFilter, TokenBucket};
|
||||
use crate::store::BlobStore;
|
||||
|
||||
/// The network side of the daemon: one iroh endpoint serving the blob
|
||||
@@ -33,6 +34,7 @@ use crate::store::BlobStore;
|
||||
pub struct Transfer {
|
||||
endpoint: Endpoint,
|
||||
router: Router,
|
||||
store: iroh_blobs::api::Store,
|
||||
downloader: Downloader,
|
||||
/// Address book for provider addresses learned out-of-band (ticket
|
||||
/// imports): the downloader dials by endpoint id and resolves
|
||||
@@ -41,6 +43,7 @@ pub struct Transfer {
|
||||
events: broadcast::Sender<Event>,
|
||||
metered: MeteredState,
|
||||
discovery_rx: std::sync::Mutex<Option<mpsc::Receiver<EndpointId>>>,
|
||||
pushes_rx: std::sync::Mutex<Option<mpsc::Receiver<ReceivedPush>>>,
|
||||
}
|
||||
|
||||
impl Transfer {
|
||||
@@ -108,6 +111,7 @@ impl Transfer {
|
||||
// Best-effort low-priority marking of our UDP traffic.
|
||||
crate::dscp::mark_endpoint_sockets(&endpoint.bound_sockets());
|
||||
|
||||
let (pushes_tx, pushes_rx) = mpsc::channel(16);
|
||||
let provider_events = crate::shaped::provider_events(Gate {
|
||||
meta,
|
||||
open_filter,
|
||||
@@ -115,6 +119,7 @@ impl Transfer {
|
||||
serving_enabled: config.max_upload_bytes_per_sec > 0,
|
||||
metered: metered.clone(),
|
||||
events: events.clone(),
|
||||
pushes: pushes_tx,
|
||||
});
|
||||
let provider = BlobsProtocol::new(store.api(), Some(provider_events));
|
||||
|
||||
@@ -126,11 +131,13 @@ impl Transfer {
|
||||
Ok(Transfer {
|
||||
endpoint,
|
||||
router,
|
||||
store: store.api().clone(),
|
||||
downloader,
|
||||
known_addrs,
|
||||
events,
|
||||
metered,
|
||||
discovery_rx: std::sync::Mutex::new(Some(discovery_rx)),
|
||||
pushes_rx: std::sync::Mutex::new(Some(pushes_rx)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -151,6 +158,108 @@ impl Transfer {
|
||||
self.discovery_rx.lock().expect("discovery lock").take()
|
||||
}
|
||||
|
||||
/// Take the stream of completed inbound pushes. Callable once.
|
||||
pub fn take_pushes(&self) -> Option<mpsc::Receiver<ReceivedPush>> {
|
||||
self.pushes_rx.lock().expect("pushes lock").take()
|
||||
}
|
||||
|
||||
/// Push fully-present `content` to `peer`, unprompted. The peer
|
||||
/// accepts only if it trusts us. Returns payload bytes written.
|
||||
pub async fn push_to(&self, peer: EndpointId, content: HashAndFormat) -> Result<u64> {
|
||||
anyhow::ensure!(
|
||||
!self.metered.is_metered(),
|
||||
"metered connection: refusing to push"
|
||||
);
|
||||
let conn = self
|
||||
.endpoint
|
||||
.connect(peer, iroh_blobs::ALPN)
|
||||
.await
|
||||
.with_context(|| format!("connecting to {peer}"))?;
|
||||
let request = match content.format {
|
||||
BlobFormat::Raw => {
|
||||
iroh_blobs::protocol::PushRequest::from(GetRequest::blob(content.hash))
|
||||
}
|
||||
BlobFormat::HashSeq => {
|
||||
iroh_blobs::protocol::PushRequest::from(GetRequest::all(content.hash))
|
||||
}
|
||||
};
|
||||
// Upstream returns zeroed Stats for pushes; the byte count rides
|
||||
// the progress stream instead.
|
||||
use iroh_blobs::api::remote::PushProgressItem;
|
||||
let mut stream = self
|
||||
.store
|
||||
.remote()
|
||||
.execute_push(conn.clone(), request)
|
||||
.stream();
|
||||
let mut bytes = 0u64;
|
||||
let mut done = false;
|
||||
while let Some(item) = stream.next().await {
|
||||
match item {
|
||||
PushProgressItem::Progress(sent) => {
|
||||
bytes = sent;
|
||||
let _ = self.events.send(Event::TransferProgress {
|
||||
hash: content.hash.to_hex().to_string(),
|
||||
direction: Direction::Upload,
|
||||
bytes_done: sent,
|
||||
bytes_total: None,
|
||||
});
|
||||
}
|
||||
PushProgressItem::Done(_) => {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
PushProgressItem::Error(e) => {
|
||||
return Err(anyhow::anyhow!(e)).context("pushing content");
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::ensure!(done, "push stream ended without a result");
|
||||
|
||||
// Writing is fire-and-forget at the QUIC level: closing the
|
||||
// connection now could discard data the peer has not read yet,
|
||||
// and says nothing about acceptance. Delivery is confirmed by
|
||||
// observing the peer's bitfields until they report complete —
|
||||
// the peer imports the root first, then children in order, so
|
||||
// root + last child covers the whole request.
|
||||
self.wait_remote_complete(&conn, content.hash).await?;
|
||||
if content.format == BlobFormat::HashSeq {
|
||||
let seq_bytes = self
|
||||
.store
|
||||
.blobs()
|
||||
.get_bytes(content.hash)
|
||||
.await
|
||||
.context("reading pushed hashseq")?;
|
||||
let seq = iroh_blobs::hashseq::HashSeq::try_from(seq_bytes)
|
||||
.map_err(|e| anyhow::anyhow!("invalid hashseq: {e}"))?;
|
||||
if let Some(last) = seq.iter().last() {
|
||||
self.wait_remote_complete(&conn, last).await?;
|
||||
}
|
||||
}
|
||||
info!(hash = %content.hash.to_hex(), peer = %peer, bytes, "push complete, verified by peer");
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Watch `hash` on the remote end of `conn` until its bitfield
|
||||
/// reports the blob complete.
|
||||
async fn wait_remote_complete(
|
||||
&self,
|
||||
conn: &iroh::endpoint::Connection,
|
||||
hash: Hash,
|
||||
) -> Result<()> {
|
||||
let observe = self.store.remote().observe(
|
||||
conn.clone(),
|
||||
iroh_blobs::protocol::ObserveRequest::new(hash),
|
||||
);
|
||||
let mut observe = std::pin::pin!(observe);
|
||||
while let Some(bitfield) = observe.next().await {
|
||||
let bitfield = bitfield.context("observing pushed content on the peer")?;
|
||||
if bitfield.is_complete() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
anyhow::bail!("peer stopped reporting before the pushed content completed");
|
||||
}
|
||||
|
||||
/// Produce a ticket for out-of-band sharing of `content`.
|
||||
pub async fn export_ticket(&self, content: HashAndFormat) -> Result<String> {
|
||||
let addr = self.endpoint.addr();
|
||||
@@ -181,13 +290,20 @@ impl Transfer {
|
||||
}
|
||||
}
|
||||
|
||||
// With several providers one fetch stripes across all of them;
|
||||
// a lone provider gets the whole request.
|
||||
let strategy = if ids.len() > 1 {
|
||||
SplitStrategy::Split
|
||||
} else {
|
||||
SplitStrategy::None
|
||||
};
|
||||
let downloader = self.downloader.clone();
|
||||
let events = self.events.clone();
|
||||
tokio::spawn(async move {
|
||||
let progress = downloader.download_with_opts(DownloadOptions::new(
|
||||
content,
|
||||
Shuffled::new(ids),
|
||||
SplitStrategy::None,
|
||||
strategy,
|
||||
));
|
||||
let mut stream = match progress.stream().await {
|
||||
Ok(stream) => stream,
|
||||
|
||||
Reference in New Issue
Block a user