Skip to content

Commit 383b306

Browse files
authored
fix: drop max_retries from DownloadCoordinator to prevent sync stall (#632)
* fix: drop `max_retries` from `DownloadCoordinator` to prevent sync stall When a entry exceeded max retries it was permanently dropped from the pipeline which lead to stalling sync. All pipeline retries now continue indefinitely avoid giving up on required sync data. Retry counts are kept for logging visibility. * fix: clean up `retry_counts` entry on successful `receive` Addresses CodeRabbit review comment on PR #632 #632 (comment) * refactor: make all `handle_timeouts` fire-and-forget for consistency `enqueue_retry` already logs each retry attempt, making the manager-level timeout logging redundant. Aligns blocks and filters pipelines with the fire-and-forget pattern already used by filter_headers, masternodes, and block_headers. Addresses CodeRabbit review comment on PR #632 #632 (comment)
1 parent 91606b5 commit 383b306

9 files changed

Lines changed: 57 additions & 317 deletions

File tree

dash-spv/src/sync/block_headers/segment_state.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,7 @@ impl SegmentState {
5050
coordinator: DownloadCoordinator::new(
5151
DownloadConfig::default()
5252
.with_max_concurrent(1) // Only 1 request at a time (sequential getheaders)
53-
.with_timeout(HEADERS_TIMEOUT)
54-
.with_max_retries(3),
53+
.with_timeout(HEADERS_TIMEOUT),
5554
),
5655
buffered_headers: Vec::new(),
5756
complete: false,

dash-spv/src/sync/blocks/pipeline.rs

Lines changed: 4 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,6 @@ const MAX_CONCURRENT_BLOCK_DOWNLOADS: usize = 20;
1919
/// Timeout for block downloads before retry.
2020
const BLOCK_TIMEOUT: Duration = Duration::from_secs(30);
2121

22-
/// Maximum number of retries for block downloads.
23-
const BLOCK_MAX_RETRIES: u32 = 3;
24-
2522
/// Maximum blocks per GetData request, kept a bit lower for better download distribution to multiple peers
2623
const BLOCKS_PER_REQUEST: usize = 8;
2724

@@ -64,8 +61,7 @@ impl BlocksPipeline {
6461
coordinator: DownloadCoordinator::new(
6562
DownloadConfig::default()
6663
.with_max_concurrent(MAX_CONCURRENT_BLOCK_DOWNLOADS)
67-
.with_timeout(BLOCK_TIMEOUT)
68-
.with_max_retries(BLOCK_MAX_RETRIES),
64+
.with_timeout(BLOCK_TIMEOUT),
6965
),
7066
pending_heights: BTreeSet::new(),
7167
downloaded: BTreeMap::new(),
@@ -173,16 +169,8 @@ impl BlocksPipeline {
173169
}
174170

175171
/// Check for timed out downloads and re-queue them.
176-
///
177-
/// Returns the list of timed out block hashes.
178-
pub(super) fn handle_timeouts(&mut self) -> Vec<BlockHash> {
179-
let timed_out = self.coordinator.check_and_retry_timeouts();
180-
181-
if !timed_out.is_empty() {
182-
tracing::debug!("Re-queued {} timed out block downloads", timed_out.len());
183-
}
184-
185-
timed_out
172+
pub(super) fn handle_timeouts(&mut self) {
173+
self.coordinator.check_and_retry_timeouts();
186174
}
187175
}
188176

@@ -324,10 +312,8 @@ mod tests {
324312
// Wait for timeout
325313
std::thread::sleep(Duration::from_millis(20));
326314

327-
let timed_out = pipeline.handle_timeouts();
315+
pipeline.handle_timeouts();
328316

329-
assert_eq!(timed_out.len(), 1);
330-
assert_eq!(timed_out[0], hash);
331317
assert_eq!(pipeline.coordinator.active_count(), 0);
332318
assert_eq!(pipeline.coordinator.pending_count(), 1);
333319
}
@@ -430,53 +416,6 @@ mod tests {
430416
assert!(pipeline.is_complete());
431417
}
432418

433-
#[test]
434-
fn test_handle_timeouts_with_multiple_retries() {
435-
let mut pipeline = BlocksPipeline {
436-
coordinator: DownloadCoordinator::new(
437-
DownloadConfig::default()
438-
.with_max_concurrent(MAX_CONCURRENT_BLOCK_DOWNLOADS)
439-
.with_timeout(Duration::from_millis(1))
440-
.with_max_retries(2),
441-
),
442-
pending_heights: BTreeSet::new(),
443-
downloaded: BTreeMap::new(),
444-
hash_to_height: HashMap::new(),
445-
};
446-
447-
// Use coordinator to set up in-flight state
448-
let hash = test_hash(1);
449-
pipeline.coordinator.enqueue([hash]);
450-
let hashes = pipeline.coordinator.take_pending(1);
451-
pipeline.coordinator.mark_sent(&hashes);
452-
453-
// First timeout - returns item (it's re-queued)
454-
std::thread::sleep(Duration::from_millis(5));
455-
let timed_out = pipeline.handle_timeouts();
456-
assert_eq!(timed_out.len(), 1);
457-
assert_eq!(pipeline.coordinator.pending_count(), 1);
458-
459-
// Re-send the retry
460-
let items = pipeline.coordinator.take_pending(1);
461-
pipeline.coordinator.mark_sent(&items);
462-
463-
// Second timeout - still re-queued
464-
std::thread::sleep(Duration::from_millis(5));
465-
let timed_out = pipeline.handle_timeouts();
466-
assert_eq!(timed_out.len(), 1);
467-
assert_eq!(pipeline.coordinator.pending_count(), 1);
468-
469-
// Re-send
470-
let items = pipeline.coordinator.take_pending(1);
471-
pipeline.coordinator.mark_sent(&items);
472-
473-
// Third timeout - exceeds max retries, NOT re-queued
474-
std::thread::sleep(Duration::from_millis(5));
475-
let timed_out = pipeline.handle_timeouts();
476-
assert_eq!(timed_out.len(), 0);
477-
assert_eq!(pipeline.coordinator.pending_count(), 0);
478-
}
479-
480419
#[test]
481420
fn test_receive_block_duplicate() {
482421
let mut pipeline = BlocksPipeline::new();

dash-spv/src/sync/blocks/sync_manager.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,7 @@ impl<H: BlockHeaderStorage, B: BlockStorage, W: WalletInterface + 'static> SyncM
183183

184184
async fn tick(&mut self, requests: &RequestSender) -> SyncResult<Vec<SyncEvent>> {
185185
// Handle timeouts
186-
let timed_out = self.pipeline.handle_timeouts();
187-
if !timed_out.is_empty() {
188-
tracing::debug!("Re-queued {} timed out block downloads", timed_out.len());
189-
}
186+
self.pipeline.handle_timeouts();
190187

191188
self.send_pending(requests).await?;
192189

dash-spv/src/sync/download_coordinator.rs

Lines changed: 10 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,13 @@ pub struct DownloadConfig {
1717
max_concurrent: usize,
1818
/// Timeout duration for requests.
1919
timeout: Duration,
20-
/// Maximum retry attempts before giving up.
21-
max_retries: u32,
2220
}
2321

2422
impl Default for DownloadConfig {
2523
fn default() -> Self {
2624
Self {
2725
max_concurrent: 10,
2826
timeout: Duration::from_secs(30),
29-
max_retries: 3,
3027
}
3128
}
3229
}
@@ -43,12 +40,6 @@ impl DownloadConfig {
4340
self.timeout = timeout;
4441
self
4542
}
46-
47-
/// Create config with custom max retries.
48-
pub(crate) fn with_max_retries(mut self, max: u32) -> Self {
49-
self.max_retries = max;
50-
self
51-
}
5243
}
5344

5445
/// Generic download coordinator.
@@ -109,17 +100,11 @@ impl<K: Hash + Eq + Clone> DownloadCoordinator<K> {
109100
}
110101

111102
/// Queue an item for retry (goes to front of queue).
112-
///
113-
/// Returns false if max retries exceeded.
114-
pub(crate) fn enqueue_retry(&mut self, item: K) -> bool {
103+
pub(crate) fn enqueue_retry(&mut self, item: K) {
115104
let count = self.retry_counts.entry(item.clone()).or_insert(0);
116-
if *count >= self.config.max_retries {
117-
tracing::warn!("Max retries ({}) exceeded, giving up", self.config.max_retries);
118-
return false;
119-
}
120105
*count += 1;
106+
tracing::warn!("Retrying item (attempt {})", count);
121107
self.pending.push_front(item);
122-
true
123108
}
124109

125110
/// Get the number of items available to send (respecting concurrency limit).
@@ -155,6 +140,7 @@ impl<K: Hash + Eq + Clone> DownloadCoordinator<K> {
155140
/// Returns true if the item was being tracked, false if unexpected.
156141
pub(crate) fn receive(&mut self, key: &K) -> bool {
157142
if self.in_flight.remove(key).is_some() {
143+
self.retry_counts.remove(key);
158144
self.last_progress = Instant::now();
159145
true
160146
} else {
@@ -194,11 +180,13 @@ impl<K: Hash + Eq + Clone> DownloadCoordinator<K> {
194180
/// Check for timed-out items and re-enqueue them for retry.
195181
///
196182
/// Combines `check_timeouts()` and `enqueue_retry()` in one call.
197-
/// Returns only items that were successfully re-queued. Items that
198-
/// exceeded their max retry count are excluded from the result.
183+
/// Returns all timed-out items that were re-queued.
199184
pub(crate) fn check_and_retry_timeouts(&mut self) -> Vec<K> {
200185
let timed_out = self.check_timeouts();
201-
timed_out.into_iter().filter(|item| self.enqueue_retry(item.clone())).collect()
186+
for item in &timed_out {
187+
self.enqueue_retry(item.clone());
188+
}
189+
timed_out
202190
}
203191

204192
/// Check if the coordinator has no work (empty pending and in-flight).
@@ -252,16 +240,6 @@ mod tests {
252240
assert_eq!(items, vec![99, 1, 2]);
253241
}
254242

255-
#[test]
256-
fn test_max_retries() {
257-
let mut coord: DownloadCoordinator<u32> =
258-
DownloadCoordinator::new(DownloadConfig::default().with_max_retries(2));
259-
260-
assert!(coord.enqueue_retry(1));
261-
assert!(coord.enqueue_retry(1));
262-
assert!(!coord.enqueue_retry(1)); // Exceeds max
263-
}
264-
265243
#[test]
266244
fn test_take_pending() {
267245
let mut coord: DownloadCoordinator<u32> = DownloadCoordinator::default();
@@ -363,42 +341,11 @@ mod tests {
363341

364342
#[test]
365343
fn test_config_builders() {
366-
let config = DownloadConfig::default()
367-
.with_max_concurrent(20)
368-
.with_timeout(Duration::from_secs(60))
369-
.with_max_retries(5);
344+
let config =
345+
DownloadConfig::default().with_max_concurrent(20).with_timeout(Duration::from_secs(60));
370346

371347
assert_eq!(config.max_concurrent, 20);
372348
assert_eq!(config.timeout, Duration::from_secs(60));
373-
assert_eq!(config.max_retries, 5);
374-
}
375-
376-
#[test]
377-
fn test_check_and_retry_timeouts_excludes_exceeded_retries() {
378-
let mut coord: DownloadCoordinator<u32> = DownloadCoordinator::new(
379-
DownloadConfig::default().with_timeout(Duration::from_millis(10)).with_max_retries(1),
380-
);
381-
382-
// Send two items and let them time out
383-
coord.mark_sent(&[1, 2]);
384-
std::thread::sleep(Duration::from_millis(20));
385-
386-
// First round: both should be re-queued successfully
387-
let requeued = coord.check_and_retry_timeouts();
388-
assert_eq!(requeued.len(), 2);
389-
assert!(requeued.contains(&1));
390-
assert!(requeued.contains(&2));
391-
392-
// Drain pending and send again so they can time out a second time
393-
let items = coord.take_pending(2);
394-
coord.mark_sent(&items);
395-
std::thread::sleep(Duration::from_millis(20));
396-
397-
// Second round: both have exceeded max_retries (1), so neither should be returned
398-
let requeued = coord.check_and_retry_timeouts();
399-
assert!(requeued.is_empty());
400-
// Items should not have been re-added to pending
401-
assert_eq!(coord.pending_count(), 0);
402349
}
403350

404351
#[test]

dash-spv/src/sync/filter_headers/pipeline.rs

Lines changed: 11 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,6 @@ const MAX_CONCURRENT_CFHEADERS_REQUESTS: usize = 10;
2424
/// Timeout for CFHeaders requests. Single response but allow time for network latency.
2525
const FILTER_HEADERS_TIMEOUT: Duration = Duration::from_secs(20);
2626

27-
/// Maximum number of retries for CFHeaders requests.
28-
const FILTER_HEADERS_MAX_RETRIES: u32 = 3;
29-
3027
/// Pipeline for downloading compact block filter headers.
3128
///
3229
/// Uses DownloadCoordinator<BlockHash> for batch-level tracking (keyed by stop_hash),
@@ -58,8 +55,7 @@ impl FilterHeadersPipeline {
5855
coordinator: DownloadCoordinator::new(
5956
DownloadConfig::default()
6057
.with_max_concurrent(MAX_CONCURRENT_CFHEADERS_REQUESTS)
61-
.with_timeout(FILTER_HEADERS_TIMEOUT)
62-
.with_max_retries(FILTER_HEADERS_MAX_RETRIES),
58+
.with_timeout(FILTER_HEADERS_TIMEOUT),
6359
),
6460
batch_starts: HashMap::new(),
6561
buffered: HashMap::new(),
@@ -265,22 +261,10 @@ impl FilterHeadersPipeline {
265261
}
266262

267263
/// Re-enqueue timed out requests for retry.
268-
///
269-
/// Returns heights that exceeded max retries and were permanently dropped.
270-
pub(super) fn handle_timeouts(&mut self) -> Vec<u32> {
271-
let mut failed = Vec::new();
264+
pub(super) fn handle_timeouts(&mut self) {
272265
for stop_hash in self.coordinator.check_timeouts() {
273-
if !self.coordinator.enqueue_retry(stop_hash) {
274-
if let Some(start_height) = self.batch_starts.remove(&stop_hash) {
275-
tracing::warn!(
276-
"CFHeaders batch at height {} exceeded max retries, dropping",
277-
start_height
278-
);
279-
failed.push(start_height);
280-
}
281-
}
266+
self.coordinator.enqueue_retry(stop_hash);
282267
}
283-
failed
284268
}
285269
}
286270

@@ -402,9 +386,7 @@ mod tests {
402386

403387
let mut pipeline = FilterHeadersPipeline {
404388
coordinator: DownloadCoordinator::new(
405-
DownloadConfig::default()
406-
.with_timeout(Duration::from_millis(1))
407-
.with_max_retries(3),
389+
DownloadConfig::default().with_timeout(Duration::from_millis(1)),
408390
),
409391
batch_starts: HashMap::new(),
410392
buffered: HashMap::new(),
@@ -418,46 +400,10 @@ mod tests {
418400

419401
std::thread::sleep(Duration::from_millis(5));
420402

421-
let failed = pipeline.handle_timeouts();
422-
assert!(failed.is_empty()); // First retry succeeds
403+
pipeline.handle_timeouts();
423404
assert_eq!(pipeline.coordinator.pending_count(), 1);
424405
}
425406

426-
#[test]
427-
fn test_handle_timeouts_max_retries_exceeded() {
428-
use std::time::Duration;
429-
430-
let mut pipeline = FilterHeadersPipeline {
431-
coordinator: DownloadCoordinator::new(
432-
DownloadConfig::default()
433-
.with_timeout(Duration::from_millis(1))
434-
.with_max_retries(1),
435-
),
436-
batch_starts: HashMap::new(),
437-
buffered: HashMap::new(),
438-
next_expected: 100,
439-
target_height: 2000,
440-
};
441-
442-
let stop_hash = BlockHash::from_byte_array([0x01; 32]);
443-
pipeline.batch_starts.insert(stop_hash, 100);
444-
445-
// First timeout + retry
446-
pipeline.coordinator.mark_sent(&[stop_hash]);
447-
std::thread::sleep(Duration::from_millis(5));
448-
let failed = pipeline.handle_timeouts();
449-
assert!(failed.is_empty());
450-
451-
// Re-send retry
452-
let items = pipeline.coordinator.take_pending(1);
453-
pipeline.coordinator.mark_sent(&items);
454-
455-
// Second timeout exceeds max
456-
std::thread::sleep(Duration::from_millis(5));
457-
let failed = pipeline.handle_timeouts();
458-
assert_eq!(failed, vec![100]);
459-
}
460-
461407
#[test]
462408
fn test_send_pending_errors_on_missing_batch_starts() {
463409
let mut pipeline = FilterHeadersPipeline::new();
@@ -482,9 +428,7 @@ mod tests {
482428

483429
let mut pipeline = FilterHeadersPipeline {
484430
coordinator: DownloadCoordinator::new(
485-
DownloadConfig::default()
486-
.with_timeout(Duration::from_millis(1))
487-
.with_max_retries(0),
431+
DownloadConfig::default().with_timeout(Duration::from_millis(1)),
488432
),
489433
batch_starts: HashMap::new(),
490434
buffered: HashMap::new(),
@@ -501,10 +445,10 @@ mod tests {
501445

502446
std::thread::sleep(Duration::from_millis(5));
503447

504-
let mut failed = pipeline.handle_timeouts();
505-
failed.sort();
506-
assert_eq!(failed.len(), 2);
507-
assert!(failed.contains(&1));
508-
assert!(failed.contains(&2001));
448+
pipeline.handle_timeouts();
449+
// Both batches re-queued
450+
assert_eq!(pipeline.coordinator.pending_count(), 2);
451+
assert!(pipeline.batch_starts.contains_key(&hash1));
452+
assert!(pipeline.batch_starts.contains_key(&hash2));
509453
}
510454
}

0 commit comments

Comments
 (0)