Skip to content

Commit 073fb0b

Browse files
authored
feat: segmented fetch with backoff retry (#59)
Split 256 MB chunks into 64 MB segments. Each segment fetched independently with exponential backoff (1s/2s/4s, 4 attempts). Mid-chunk failure refetches one 64 MB segment, not 256 MB. Segment cache (LRU, 3 entries = 192 MB) makes backward seeks within the cache window free. curl gets --connect-timeout, --max-time, and --retry flags for connection-level resilience. No API change — HttpRangeReader::new() and ::with_chunk_size() work exactly as before. Internal segmentation is transparent.
1 parent e900ad0 commit 073fb0b

1 file changed

Lines changed: 129 additions & 51 deletions

File tree

src/hpc/http_reader.rs

Lines changed: 129 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,39 @@ use std::process::{Command, Stdio};
1717

1818
/// HTTP range reader that implements Read + Seek.
1919
///
20-
/// Each `read()` call fetches bytes via `curl -r start-end`.
21-
/// Buffered: fetches chunks of `chunk_size` to avoid per-byte HTTP calls.
20+
/// Internally splits chunks into smaller segments (default 64 MB) with
21+
/// retry + exponential backoff. A mid-download failure only refetches
22+
/// the failed segment, not the entire 256 MB chunk.
23+
///
24+
/// Caches the last `SEGMENT_CACHE_SIZE` segments so backward seeks
25+
/// within the cache window are free (no re-fetch).
2226
pub struct HttpRangeReader {
2327
url: String,
2428
position: u64,
2529
total_size: u64,
26-
buffer: Vec<u8>,
27-
buf_start: u64, // file offset where buffer starts
28-
buf_len: usize, // valid bytes in buffer
2930
chunk_size: usize,
3031
bytes_downloaded: u64,
32+
33+
// Segmented cache: each entry = (file_offset, data)
34+
segment_size: usize,
35+
segment_cache: Vec<(u64, Vec<u8>)>,
36+
max_cached_segments: usize,
37+
38+
// Current active segment (for Read trait)
39+
active_segment_start: u64,
40+
active_segment_len: usize,
41+
active_segment_idx: Option<usize>, // index into segment_cache
3142
}
3243

44+
/// Maximum retry attempts per segment.
45+
const MAX_RETRIES: u32 = 4;
46+
/// Initial backoff delay in milliseconds.
47+
const INITIAL_BACKOFF_MS: u64 = 1000;
48+
/// Default segment size: 64 MB (4 segments per 256 MB chunk).
49+
const DEFAULT_SEGMENT_SIZE: usize = 64 * 1024 * 1024;
50+
/// Number of segments to cache (192 MB at 64 MB segments).
51+
const SEGMENT_CACHE_SIZE: usize = 3;
52+
3353
impl HttpRangeReader {
3454
/// Default chunk: 256 MB (fewer HTTP round-trips, fits in RAM easily).
3555
const DEFAULT_CHUNK: usize = 256 * 1024 * 1024;
@@ -42,25 +62,33 @@ impl HttpRangeReader {
4262
url,
4363
position: 0,
4464
total_size,
45-
buffer: vec![0u8; Self::DEFAULT_CHUNK],
46-
buf_start: 0,
47-
buf_len: 0,
4865
chunk_size: Self::DEFAULT_CHUNK,
4966
bytes_downloaded: 0,
67+
segment_size: DEFAULT_SEGMENT_SIZE,
68+
segment_cache: Vec::with_capacity(SEGMENT_CACHE_SIZE),
69+
max_cached_segments: SEGMENT_CACHE_SIZE,
70+
active_segment_start: 0,
71+
active_segment_len: 0,
72+
active_segment_idx: None,
5073
}
5174
}
5275

5376
/// Create with custom chunk size.
5477
pub fn with_chunk_size(url: String, total_size: u64, chunk_size: usize) -> Self {
78+
// Segment size = chunk_size / 4, minimum 16 MB
79+
let seg = (chunk_size / 4).max(16 * 1024 * 1024);
5580
Self {
5681
url,
5782
position: 0,
5883
total_size,
59-
buffer: vec![0u8; chunk_size],
60-
buf_start: 0,
61-
buf_len: 0,
6284
chunk_size,
6385
bytes_downloaded: 0,
86+
segment_size: seg,
87+
segment_cache: Vec::with_capacity(SEGMENT_CACHE_SIZE),
88+
max_cached_segments: SEGMENT_CACHE_SIZE,
89+
active_segment_start: 0,
90+
active_segment_len: 0,
91+
active_segment_idx: None,
6492
}
6593
}
6694

@@ -69,56 +97,100 @@ impl HttpRangeReader {
6997
self.bytes_downloaded
7098
}
7199

72-
/// Fetch a range of bytes from the URL via curl.
73-
fn fetch_range(&mut self, start: u64, len: usize) -> io::Result<usize> {
100+
/// Fetch a segment with retry + exponential backoff.
101+
///
102+
/// Returns the fetched bytes. On permanent failure after MAX_RETRIES, returns error.
103+
fn fetch_segment_with_retry(&mut self, start: u64, len: usize) -> io::Result<Vec<u8>> {
74104
let end = (start + len as u64 - 1).min(self.total_size - 1);
75105
let range = format!("{}-{}", start, end);
106+
let expected = (end - start + 1) as usize;
107+
108+
for attempt in 0..MAX_RETRIES {
109+
if attempt > 0 {
110+
let delay = INITIAL_BACKOFF_MS * (1u64 << (attempt - 1));
111+
eprintln!(" retry {}/{} after {}ms (segment {}-{})",
112+
attempt + 1, MAX_RETRIES, delay, start, end);
113+
std::thread::sleep(std::time::Duration::from_millis(delay));
114+
}
76115

77-
let output = Command::new("curl")
78-
.args(&["-sL", "-r", &range, &self.url])
79-
.stdout(Stdio::piped())
80-
.stderr(Stdio::null())
81-
.output()
82-
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("curl failed: {}", e)))?;
83-
84-
if !output.status.success() {
85-
return Err(io::Error::new(
86-
io::ErrorKind::Other,
87-
format!("curl returned status {}", output.status),
88-
));
89-
}
90-
91-
let fetched = output.stdout.len();
92-
if fetched == 0 {
93-
return Ok(0);
116+
let result = Command::new("curl")
117+
.args(&[
118+
"-sL",
119+
"--retry", "2", // curl-level retry for connection drops
120+
"--retry-delay", "1",
121+
"--connect-timeout", "30",
122+
"--max-time", "300", // 5 min max per segment
123+
"-r", &range,
124+
&self.url,
125+
])
126+
.stdout(Stdio::piped())
127+
.stderr(Stdio::null())
128+
.output();
129+
130+
match result {
131+
Ok(output) if output.status.success() && output.stdout.len() == expected => {
132+
self.bytes_downloaded += output.stdout.len() as u64;
133+
return Ok(output.stdout);
134+
}
135+
Ok(output) if output.status.success() && !output.stdout.is_empty() => {
136+
// Partial read — might be near EOF, accept it
137+
self.bytes_downloaded += output.stdout.len() as u64;
138+
if output.stdout.len() >= expected / 2 {
139+
return Ok(output.stdout);
140+
}
141+
eprintln!(" short read: got {}/{} bytes", output.stdout.len(), expected);
142+
}
143+
Ok(output) => {
144+
eprintln!(" fetch failed: status={} got={} bytes",
145+
output.status, output.stdout.len());
146+
}
147+
Err(e) => {
148+
eprintln!(" curl error: {}", e);
149+
}
150+
}
94151
}
95152

96-
// Copy to buffer
97-
let copy_len = fetched.min(self.buffer.len());
98-
self.buffer[..copy_len].copy_from_slice(&output.stdout[..copy_len]);
99-
self.buf_start = start;
100-
self.buf_len = copy_len;
101-
self.bytes_downloaded += fetched as u64;
102-
103-
Ok(copy_len)
153+
Err(io::Error::new(
154+
io::ErrorKind::Other,
155+
format!("segment fetch failed after {} retries: bytes {}-{}", MAX_RETRIES, start, end),
156+
))
104157
}
105158

106-
/// Ensure the buffer covers `self.position` and has data ready.
107-
fn ensure_buffered(&mut self) -> io::Result<()> {
159+
/// Find or fetch the segment containing `self.position`.
160+
fn ensure_segment(&mut self) -> io::Result<()> {
108161
if self.position >= self.total_size {
109162
return Ok(());
110163
}
111164

112-
// Check if position is within current buffer
113-
let buf_end = self.buf_start + self.buf_len as u64;
114-
if self.position >= self.buf_start && self.position < buf_end {
115-
return Ok(()); // already buffered
165+
// Check if position is within any cached segment
166+
for (idx, (seg_start, seg_data)) in self.segment_cache.iter().enumerate() {
167+
let seg_end = *seg_start + seg_data.len() as u64;
168+
if self.position >= *seg_start && self.position < seg_end {
169+
self.active_segment_start = *seg_start;
170+
self.active_segment_len = seg_data.len();
171+
self.active_segment_idx = Some(idx);
172+
return Ok(());
173+
}
116174
}
117175

118-
// Need to fetch
176+
// Not cached — fetch new segment
119177
let remaining = (self.total_size - self.position) as usize;
120-
let fetch_len = self.chunk_size.min(remaining);
121-
self.fetch_range(self.position, fetch_len)?;
178+
let fetch_len = self.segment_size.min(remaining);
179+
let data = self.fetch_segment_with_retry(self.position, fetch_len)?;
180+
let data_len = data.len();
181+
182+
// Evict oldest segment if cache is full
183+
if self.segment_cache.len() >= self.max_cached_segments {
184+
self.segment_cache.remove(0);
185+
}
186+
187+
self.segment_cache.push((self.position, data));
188+
let idx = self.segment_cache.len() - 1;
189+
190+
self.active_segment_start = self.position;
191+
self.active_segment_len = data_len;
192+
self.active_segment_idx = Some(idx);
193+
122194
Ok(())
123195
}
124196
}
@@ -129,17 +201,23 @@ impl Read for HttpRangeReader {
129201
return Ok(0); // EOF
130202
}
131203

132-
self.ensure_buffered()?;
204+
self.ensure_segment()?;
205+
206+
let idx = match self.active_segment_idx {
207+
Some(i) if i < self.segment_cache.len() => i,
208+
_ => return Ok(0),
209+
};
133210

134-
let buf_offset = (self.position - self.buf_start) as usize;
135-
let available = self.buf_len - buf_offset;
211+
let (seg_start, ref seg_data) = self.segment_cache[idx];
212+
let offset = (self.position - seg_start) as usize;
213+
let available = seg_data.len() - offset;
136214
let to_copy = buf.len().min(available);
137215

138216
if to_copy == 0 {
139217
return Ok(0);
140218
}
141219

142-
buf[..to_copy].copy_from_slice(&self.buffer[buf_offset..buf_offset + to_copy]);
220+
buf[..to_copy].copy_from_slice(&seg_data[offset..offset + to_copy]);
143221
self.position += to_copy as u64;
144222
Ok(to_copy)
145223
}

0 commit comments

Comments
 (0)