feat(peer): pipeline chunk downloads over QUIC

Keep several chunk streams in flight per peer connection so a fast LAN download
is no longer forced through a request, wait, request loop. The transport still
uses the current GetGameFileChunk request on normal QUIC bidirectional streams,
so this improves throughput without adding another wire message or compatibility
path.

The peer planner now assigns chunks to the least-loaded eligible peer by planned
bytes. This keeps shared large files balanced across the latest valid sources,
while still respecting per-file source eligibility. Retries are batched by peer
and use the same pipelined transport instead of opening a new connection for one
failed chunk at a time.

Initial peer connection failures are converted into per-chunk failures so the
existing retry logic can move those chunks to another validated source. The dead
whole-file branch was removed from PeerDownloadPlan because nothing populated it
and retrying those entries as zero-length chunks would be a future data-loss
trap.

Test Plan:
- RUSTC_WRAPPER= just fmt
- RUSTC_WRAPPER= just test
- RUSTC_WRAPPER= just clippy
- RUSTC_WRAPPER= just peer-cli-build
- RUSTC_WRAPPER= just peer-cli-image
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \
  S13 S14 S16 S18 S19 S20 S24 S25 S26 S36
- git diff --cached --check

Refs: PEER_CLI_SCENARIOS.md
Review-Notes: addressed Claude review on whole-file retry cleanup
This commit is contained in:
2026-05-20 07:46:44 +02:00
parent e078b12dcf
commit 6a90ca951d
4 changed files with 413 additions and 186 deletions
+29 -6
View File
@@ -19,7 +19,6 @@ pub(super) struct DownloadChunk {
#[derive(Debug, Default)]
pub(super) struct PeerDownloadPlan {
pub(super) chunks: Vec<DownloadChunk>,
pub(super) whole_files: Vec<GameFileDescription>,
}
/// Result of downloading a chunk.
@@ -137,7 +136,8 @@ pub(super) fn build_peer_plans(
return plans;
}
let mut peer_index = 0usize;
let mut planned_bytes: HashMap<SocketAddr, u64> = HashMap::new();
let mut tie_breaker = 0usize;
for desc in file_descs.iter().filter(|d| !d.is_dir) {
let size = desc.file_size();
@@ -147,8 +147,8 @@ pub(super) fn build_peer_plans(
}
if size == 0 {
let peer = eligible_peers[peer_index % eligible_peers.len()];
peer_index += 1;
let peer = select_least_loaded_peer(eligible_peers, &planned_bytes, &mut tie_breaker);
*planned_bytes.entry(peer).or_default() += 1;
plans.entry(peer).or_default().chunks.push(DownloadChunk {
relative_path: desc.relative_path.clone(),
offset: 0,
@@ -162,8 +162,8 @@ pub(super) fn build_peer_plans(
let mut offset = 0u64;
while offset < size {
let length = std::cmp::min(CHUNK_SIZE, size - offset);
let peer = eligible_peers[peer_index % eligible_peers.len()];
peer_index += 1;
let peer = select_least_loaded_peer(eligible_peers, &planned_bytes, &mut tie_breaker);
*planned_bytes.entry(peer).or_default() += length;
plans.entry(peer).or_default().chunks.push(DownloadChunk {
relative_path: desc.relative_path.clone(),
offset,
@@ -178,6 +178,29 @@ pub(super) fn build_peer_plans(
plans
}
fn select_least_loaded_peer(
eligible_peers: &[SocketAddr],
planned_bytes: &HashMap<SocketAddr, u64>,
tie_breaker: &mut usize,
) -> SocketAddr {
let start = *tie_breaker % eligible_peers.len();
*tie_breaker = (*tie_breaker).wrapping_add(1);
let mut selected = eligible_peers[start];
let mut selected_load = planned_bytes.get(&selected).copied().unwrap_or_default();
for offset in 1..eligible_peers.len() {
let peer = eligible_peers[(start + offset) % eligible_peers.len()];
let load = planned_bytes.get(&peer).copied().unwrap_or_default();
if load < selected_load {
selected = peer;
selected_load = load;
}
}
selected
}
#[cfg(test)]
mod tests {
use lanspread_db::db::GameFileDescription;