use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::time::{Duration, Instant};
use futures::future::join_all;
use tape_core::bft::{max_faulty, min_correct};
use tape_core::erasure::{GROUP_SIZE, spool_for_slice};
use tape_core::spooler::GroupIndex;
use tape_core::types::SpoolIndex;
use tape_crypto::address::Address;
use tape_crypto::Hash;
use tape_protocol::api::{Api, ApiError, CertifyRes, PutSliceReq, SlicePayload};
use tape_protocol::ProtocolState;
use tape_retry::{Backoff, RetryConfig, Retryable};
use tokio::sync::{mpsc, watch, Semaphore};
use tokio::time::sleep;
use tracing::{debug, info, warn};
use crate::bootstrap::Reputation;
use crate::codec::encoder::SliceMerkleProof;
use crate::error::UploadError;
const MAX_RATE_LIMIT_WAIT: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub struct SliceWithProof {
pub index: SpoolIndex,
pub data: Arc<Vec<u8>>,
pub leaf_hash: Hash,
pub merkle_proof: SliceMerkleProof,
}
impl SliceWithProof {
pub fn new(index: SpoolIndex, data: Vec<u8>, leaf_hash: Hash, merkle_proof: SliceMerkleProof) -> Self {
Self { index, data: Arc::new(data), leaf_hash, merkle_proof }
}
pub fn to_payload(&self) -> SlicePayload {
SlicePayload::new(self.data.as_ref().clone(), self.leaf_hash, self.merkle_proof.to_vec())
}
}
pub struct DistributedUploader {
track: Address,
group: GroupIndex,
slices: Vec<SliceWithProof>,
group_peers: Vec<(SpoolIndex, Address)>,
group_member_count: usize,
concurrency_limit: Arc<Semaphore>,
reputation: Option<Arc<Reputation>>,
}
struct NodeUploadResult {
stored: Vec<SpoolIndex>,
failed: Vec<SpoolIndex>,
not_responsible: Vec<SpoolIndex>,
receipt: Option<CertifyRes>,
}
impl DistributedUploader {
pub fn new(
track: Address,
group: GroupIndex,
slices: Vec<SliceWithProof>,
state: &ProtocolState,
concurrency: usize,
) -> Result<Self, UploadError> {
if slices.len() != GROUP_SIZE {
return Err(UploadError::InvalidSliceCount {
expected: GROUP_SIZE,
got: slices.len(),
});
}
let group_peers = state.group_peers(group);
let group_member_count = state.group_member_count(group);
Ok(Self {
track,
group,
slices,
group_peers,
group_member_count,
concurrency_limit: Arc::new(Semaphore::new(concurrency.max(1))),
reputation: None,
})
}
pub fn with_reputation(mut self, reputation: Arc<Reputation>) -> Self {
self.reputation = Some(reputation);
self
}
pub async fn upload_all<P: Api>(&self, peer_client: Arc<P>) -> Result<Vec<CertifyRes>, UploadError> {
if self.group_peers.is_empty() {
return Err(UploadError::NoNodesAvailable);
}
let mut node_groups: HashMap<Address, Vec<SpoolIndex>> = HashMap::new();
for &(spool, node) in &self.group_peers {
node_groups.entry(node).or_default().push(spool);
}
let slice_map: HashMap<SpoolIndex, &SliceWithProof> = self
.slices
.iter()
.map(|s| {
let global_spool = spool_for_slice(self.group, s.index.as_usize());
(global_spool, s)
})
.collect();
let required_members = min_correct(self.group_member_count as u64) as usize;
let required_slices = min_correct(GROUP_SIZE as u64) as usize;
let (quorum_tx, quorum_rx) = watch::channel(false);
let (result_sender, mut result_receiver) = mpsc::unbounded_channel();
let node_count = node_groups.len();
for (node, spools) in node_groups {
let track = self.track;
let concurrency_limit = self.concurrency_limit.clone();
let quorum_rx = quorum_rx.clone();
let peer_client = peer_client.clone();
let result_sender = result_sender.clone();
let slices: Vec<(SpoolIndex, SliceWithProof)> = spools
.iter()
.filter_map(|spool| slice_map.get(spool).map(|s| (*spool, (*s).clone())))
.collect();
let is_known_down = self
.reputation
.as_ref()
.map(|reputation| reputation.is_quarantined(&node))
.unwrap_or(false);
let budget = match is_known_down {
true => watch::channel(true).1,
false => quorum_rx,
};
let reputation = self.reputation.clone();
tokio::spawn(async move {
let started = Instant::now();
let result = upload_node_slices(
peer_client.as_ref(),
node,
track,
slices,
budget,
concurrency_limit,
)
.await;
if let Some(reputation) = reputation {
match result.as_ref().map(|outcome| outcome.failed.is_empty()) {
Ok(true) => reputation.record_success(node, started.elapsed()),
Ok(false) | Err(_) => reputation.record_failure(node),
}
}
let _ = result_sender.send(result);
});
}
drop(result_sender);
let mut total_failed_slices = 0;
let mut not_responsible_count = 0usize;
let mut member_failures = 0;
let mut fully_successful_members = 0;
let mut stored_slices: HashSet<SpoolIndex> = HashSet::new();
let mut receipts: Vec<CertifyRes> = Vec::with_capacity(node_count);
while let Some(result) = result_receiver.recv().await {
match result {
Ok(node) => {
total_failed_slices += node.failed.len();
not_responsible_count += node.not_responsible.len();
stored_slices.extend(node.stored);
receipts.extend(node.receipt);
if node.failed.is_empty() && node.not_responsible.is_empty() {
fully_successful_members += 1;
}
}
Err(error) => {
warn!(error = %error, "member upload task failed");
member_failures += 1;
}
}
if fully_successful_members >= required_members
&& stored_slices.len() >= required_slices
{
let _ = quorum_tx.send(true);
if total_failed_slices > 0 {
warn!(
failed_slices = total_failed_slices,
"Some slices failed to upload, left for recovery worker"
);
}
info!(
track = %self.track,
members = fully_successful_members,
required_members,
slices = stored_slices.len(),
required_slices,
"slice upload quorum reached, draining remaining uploads in the background"
);
return Ok(receipts);
}
}
let f = max_faulty(GROUP_SIZE as u64) as usize;
if not_responsible_count > f {
return Err(UploadError::EpochChanged {
not_responsible: not_responsible_count,
});
}
let successful_members = self.group_member_count - member_failures;
if successful_members < required_members || fully_successful_members < required_members {
return Err(UploadError::InsufficientQuorum {
got: fully_successful_members.min(successful_members),
need: required_members,
});
}
Err(UploadError::InsufficientSlices {
got: stored_slices.len(),
need: required_slices,
})
}
pub fn slice_count(&self) -> usize {
self.slices.len()
}
}
async fn upload_node_slices<P: Api>(
peer_client: &P,
node: Address,
track: Address,
slices: Vec<(SpoolIndex, SliceWithProof)>,
quorum: watch::Receiver<bool>,
concurrency_limit: Arc<Semaphore>,
) -> Result<NodeUploadResult, UploadError> {
let _permit = concurrency_limit
.acquire()
.await
.map_err(|_| UploadError::Semaphore)?;
let uploads = slices.into_iter().map(|(global_spool, slice)| {
let quorum = quorum.clone();
async move {
let payload = slice.to_payload();
let payload_bytes = payload.data.len();
let req = PutSliceReq {
track,
spool: global_spool,
payload,
};
let result = upload_slice_with_retry(
peer_client,
node,
track,
req,
payload_bytes,
quorum,
)
.await;
(global_spool, result)
}
});
let mut stored = Vec::new();
let mut failed = Vec::new();
let mut not_responsible = Vec::new();
let mut receipt = None;
for (global_spool, result) in join_all(uploads).await {
match result {
Ok(signed) => {
stored.push(global_spool);
receipt.get_or_insert(signed);
}
Err(e) => {
warn!(
track = %track,
slice = %global_spool,
node = %node,
error = %e,
"Slice upload failed, left for recovery"
);
if matches!(e, ApiError::NotResponsible) {
not_responsible.push(global_spool);
} else {
failed.push(global_spool);
}
}
}
}
Ok(NodeUploadResult { stored, failed, not_responsible, receipt })
}
fn should_retry_put_slice(error: &ApiError) -> bool {
matches!(error, ApiError::NotFound) || error.is_retryable()
}
fn slice_retry_config() -> RetryConfig {
RetryConfig {
base_delay: Duration::from_millis(60),
max_delay: Duration::from_secs(2),
max_retries: Some(10),
}
}
async fn upload_slice_with_retry<P: Api>(
peer_client: &P,
node: Address,
track: Address,
req: PutSliceReq,
payload_bytes: usize,
mut quorum: watch::Receiver<bool>,
) -> Result<CertifyRes, ApiError> {
let started = Instant::now();
let mut backoff = Backoff::new(slice_retry_config());
loop {
let attempt_started = Instant::now();
match peer_client.put_slice(node, &req).await {
Ok(response) => {
debug!(
track = %track,
node = %node,
slice = %req.spool,
bytes = payload_bytes,
push_ms = attempt_started.elapsed().as_millis() as u64,
total_ms = started.elapsed().as_millis() as u64,
attempts = backoff.attempt() + 1,
"slice accepted"
);
return Ok(response.receipt);
}
Err(error) => {
if !should_retry_put_slice(&error) {
warn!(
track = %track,
node = %node,
slice = %req.spool,
bytes = payload_bytes,
elapsed_ms = started.elapsed().as_millis() as u64,
error = %error,
"slice upload failed with non-retryable error"
);
return Err(error);
}
if *quorum.borrow() {
warn!(
track = %track,
node = %node,
slice = %req.spool,
bytes = payload_bytes,
elapsed_ms = started.elapsed().as_millis() as u64,
error = %error,
"slice upload failed after quorum, leaving for recovery"
);
return Err(error);
}
let Some(mut delay) = backoff.next_delay() else {
warn!(
track = %track,
node = %node,
slice = %req.spool,
bytes = payload_bytes,
elapsed_ms = started.elapsed().as_millis() as u64,
error = %error,
"slice upload exhausted retries"
);
return Err(error);
};
warn!(
track = %track,
node = %node,
slice = %req.spool,
bytes = payload_bytes,
attempt = backoff.attempt(),
delay_ms = delay.as_millis() as u64,
elapsed_ms = started.elapsed().as_millis() as u64,
error = %error,
"slice upload failed, retrying after backoff"
);
if let ApiError::RateLimited { retry_after: Some(wait) } = &error {
delay = delay.max((*wait).min(MAX_RATE_LIMIT_WAIT));
}
tokio::select! {
_ = sleep(delay) => {}
_ = quorum.changed() => {
warn!(
track = %track,
node = %node,
slice = %req.spool,
bytes = payload_bytes,
elapsed_ms = started.elapsed().as_millis() as u64,
error = %error,
"slice upload retry skipped after quorum"
);
return Err(error);
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bytemuck::Zeroable;
use tape_api::state::Group;
use tape_core::bls::BlsPubkey;
use tape_core::system::{Member, Spool};
use tape_core::types::coin::TAPE;
use tape_core::types::{EpochNumber, StorageUnits};
use tape_slicer::SLICE_TREE_HEIGHT;
use tape_crypto::address::Address;
fn make_test_slices(count: usize) -> Vec<SliceWithProof> {
(0..count)
.map(|i| {
SliceWithProof::new(
SpoolIndex::from(i as u64),
vec![i as u8; 100],
Hash::default(),
[Hash::default(); SLICE_TREE_HEIGHT],
)
})
.collect()
}
fn make_test_state(member_count: usize) -> ProtocolState {
let mut state = ProtocolState::default();
state.current.epoch.id = EpochNumber(1);
for i in 0..member_count {
let mut bytes = [0u8; 32];
bytes[0] = i as u8 + 1;
state.current.committee.push(Member::new(
Address::new(bytes),
TAPE(1000 - i as u64),
));
}
let mut group = Group {
id: GroupIndex(0),
epoch: EpochNumber(1),
size: StorageUnits::mb(1),
..Group::zeroed()
};
for i in 0..GROUP_SIZE {
let owner = state.current.committee[i % member_count].node;
group.spools[i] = Spool::new(owner, BlsPubkey::zeroed());
}
state.current.groups.push(group);
state
}
#[test]
fn uploader_creation() {
let slices = make_test_slices(GROUP_SIZE);
let state = make_test_state(2);
let uploader = DistributedUploader::new(
Address::new_unique(),
GroupIndex(0),
slices,
&state,
GROUP_SIZE,
)
.unwrap();
assert_eq!(uploader.slice_count(), GROUP_SIZE);
}
#[test]
fn slice_with_proof_to_payload() {
let slice = SliceWithProof::new(
SpoolIndex::from(42),
vec![0xAB; 500],
Hash::default(),
[Hash::default(); SLICE_TREE_HEIGHT],
);
let payload = slice.to_payload();
assert_eq!(payload.data, *slice.data);
assert_eq!(payload.leaf_hash, slice.leaf_hash);
assert_eq!(payload.merkle_proof, slice.merkle_proof);
}
}