Skip to content

Commit 9b14efa

Browse files
committed
Fix indefinite hangs on non-EC2 network paths
coldsnap uploads can hang indefinitely when running from outside AWS. A small fraction of PutSnapshotBlock requests complete the TCP handshake but never receive an HTTP response. The AWS Rust SDK does not set read_timeout, operation_attempt_timeout, or operation_timeout by default, so these requests block the worker forever. This has been reported in #362 (uploads stalling from GitHub Actions), #374 (downloads hanging from non-EC2), #216 (excessive retries with no visibility), and #95 (missing timeouts on remote calls). Changes: - Set SDK timeouts in build_client_config(): read_timeout 12s, operation_attempt_timeout 20s, operation_timeout 120s. These apply to both the upload and download CLI paths. - Set SDK max_attempts to 1. coldsnap has its own per-block retry loop with backoff; layering SDK retries on top produced up to 36 attempts per block with no coordinated timeout. - Reduce block retry count from 12 to 5. With bounded per-attempt timeouts, fewer retries are needed. - Add --workers flag (default 64) to configure concurrent upload workers. Reject --workers 0 since for_each_concurrent treats 0 as unlimited. - Add --client-shards flag (default 1) to create N independent EbsClient instances for uploads. Blocks are distributed by index (block_index % N). This is opt-in; the default preserves the existing single-client behavior. In our testing, --client-shards 8 with 64 workers improved the per-block latency profile on high-latency paths. - Log a latency histogram at INFO level after the upload completes (<250ms, 250-500ms, 500ms-1s, 1-2s, 2-5s, >5s, plus error count). - Log per-block warnings on failure with block index, attempt number, elapsed time, and error. Previously failures were logged at DEBUG with no context. Tested with 18 consecutive uploads of a 4.1 GiB image from GitHub Actions runners across Virginia, Wyoming, and other Azure regions. All 18 succeeded (20-51s depending on config and runner location). Stock coldsnap on the same paths hung indefinitely or took 5-20+ minutes.
1 parent 9552b74 commit 9b14efa

3 files changed

Lines changed: 222 additions & 24 deletions

File tree

src/bin/coldsnap/main.rs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ snapshots.
99
use argh::FromArgs;
1010
use aws_config::default_provider::credentials::DefaultCredentialsChain;
1111
use aws_config::default_provider::region::DefaultRegionChain;
12+
use aws_config::retry::RetryConfig;
13+
use aws_config::timeout::TimeoutConfig;
1214
use aws_sdk_ebs::types::Tag;
1315
use aws_sdk_ebs::Client as EbsClient;
1416
use aws_sdk_ec2::Client as Ec2Client;
@@ -85,8 +87,25 @@ async fn run() -> Result<()> {
8587
}
8688

8789
SubCommand::Upload(upload_args) => {
88-
let client = EbsClient::new(&client_config);
89-
let uploader = SnapshotUploader::new(client);
90+
if upload_args.workers == Some(0) {
91+
eprintln!("Error: --workers must be greater than zero");
92+
std::process::exit(1);
93+
}
94+
if upload_args.client_shards == Some(0) {
95+
eprintln!("Error: --client-shards must be greater than zero");
96+
std::process::exit(1);
97+
}
98+
99+
let num_shards = upload_args.client_shards.unwrap_or(1);
100+
let uploader = if num_shards <= 1 {
101+
SnapshotUploader::new(EbsClient::new(&client_config))
102+
} else {
103+
debug!("Creating {} EBS client shards", num_shards);
104+
let clients = (0..num_shards)
105+
.map(|_| EbsClient::new(&client_config))
106+
.collect();
107+
SnapshotUploader::with_client_shards(clients)
108+
};
90109
ensure!(
91110
upload_args.file.file_name().is_some(),
92111
error::ValidateFilenameSnafu {
@@ -115,6 +134,7 @@ async fn run() -> Result<()> {
115134
progress_bar?,
116135
zero_blocks,
117136
upload_args.kms_key_id,
137+
upload_args.workers,
118138
)
119139
.await
120140
.context(error::UploadSnapshotSnafu)?;
@@ -222,6 +242,22 @@ async fn build_client_config(
222242
config = config.endpoint_url(endpoint);
223243
}
224244

245+
// The AWS SDK does not set response or per-attempt timeouts by default.
246+
// Without these, a request that sends its body but never receives a response
247+
// will block the worker indefinitely.
248+
config = config
249+
.timeout_config(
250+
TimeoutConfig::builder()
251+
.read_timeout(Duration::from_secs(12))
252+
.operation_attempt_timeout(Duration::from_secs(20))
253+
.operation_timeout(Duration::from_secs(120))
254+
.build(),
255+
)
256+
// Disable SDK-level retries; coldsnap already has its own per-block retry
257+
// loop with backoff. Layering SDK retries on top of that leads to excessive
258+
// total attempts and unpredictable wall-clock time.
259+
.retry_config(RetryConfig::standard().with_max_attempts(1));
260+
225261
config.load().await
226262
}
227263

@@ -396,6 +432,14 @@ struct UploadArgs {
396432
#[argh(switch)]
397433
/// omit blocks of all zeros when uploading
398434
omit_zero_blocks: bool,
435+
436+
#[argh(option)]
437+
/// number of concurrent upload workers (default: 64)
438+
workers: Option<usize>,
439+
440+
#[argh(option)]
441+
/// number of independent EBS clients for higher-concurrency uploads (default: 1)
442+
client_shards: Option<usize>,
399443
}
400444

401445
/// Turn a user-specified duration in seconds into a Duration object, for argh parsing.

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ let client = EbsClient::new(&aws_config::from_env().region("us-west-2").load().a
3434
let uploader = SnapshotUploader::new(client);
3535
let path = Path::new("./disk.img");
3636
37-
let snapshot_id = uploader.upload_from_file(&path, None, None, None, None, None, None)
37+
let snapshot_id = uploader.upload_from_file(&path, None, None, None, None, None, None, None)
3838
.await
3939
.expect("failed to upload snapshot");
4040
# }

src/upload.rs

Lines changed: 175 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use base64::Engine as _;
1414
use bytes::BytesMut;
1515
use futures::stream::{self, StreamExt};
1616
use indicatif::ProgressBar;
17-
use log::debug;
17+
use log::{debug, info, warn};
1818
use sha2::{Digest, Sha256};
1919
use snafu::{ensure, OptionExt, ResultExt, Snafu};
2020
use std::cmp;
@@ -24,7 +24,7 @@ use std::ffi::OsStr;
2424
use std::io::SeekFrom;
2525
use std::os::unix::fs::FileTypeExt;
2626
use std::path::{Path, PathBuf};
27-
use std::sync::atomic::{AtomicI32, Ordering as AtomicOrdering};
27+
use std::sync::atomic::{AtomicI32, AtomicU64, Ordering as AtomicOrdering};
2828
use std::sync::{Arc, Mutex};
2929
use std::time::Duration;
3030
use tokio::fs::{self, File};
@@ -39,14 +39,58 @@ const GIBIBYTE: i64 = 1024 * 1024 * 1024;
3939
const SNAPSHOT_BLOCK_WORKERS: usize = 64;
4040
// How long to wait between attempts; this number * attempt number, in seconds.
4141
const SNAPSHOT_BLOCK_RETRY_SCALE: u64 = 2;
42-
// 12 retries with scale 2 gives us 132 seconds, chosen because it got past "snapshot does not
43-
// exist" errors in testing.
44-
const SNAPSHOT_BLOCK_ATTEMPTS: u64 = 12;
42+
// 5 retries with scale 2 gives us 20 seconds of backoff. With SDK-level timeouts
43+
// now in place, each attempt is bounded, so fewer retries are needed.
44+
const SNAPSHOT_BLOCK_ATTEMPTS: u64 = 5;
4545
const SNAPSHOT_TIMEOUT_MINUTES: i32 = 10;
4646
const SHA256_ALGORITHM: ChecksumAlgorithm = ChecksumAlgorithm::ChecksumAlgorithmSha256;
4747
const LINEAR_METHOD: ChecksumAggregationMethod =
4848
ChecksumAggregationMethod::ChecksumAggregationLinear;
4949

50+
/// Collects per-block upload latencies for a summary logged after the upload.
51+
struct UploadStats {
52+
buckets: [AtomicU64; 6],
53+
errors: AtomicU64,
54+
}
55+
56+
impl UploadStats {
57+
fn new() -> Self {
58+
Self {
59+
buckets: Default::default(),
60+
errors: AtomicU64::new(0),
61+
}
62+
}
63+
64+
fn record_success(&self, elapsed: Duration) {
65+
let bucket = match elapsed.as_millis() {
66+
0..250 => 0,
67+
250..500 => 1,
68+
500..1000 => 2,
69+
1000..2000 => 3,
70+
2000..5000 => 4,
71+
_ => 5,
72+
};
73+
self.buckets[bucket].fetch_add(1, AtomicOrdering::Relaxed);
74+
}
75+
76+
fn record_error(&self) {
77+
self.errors.fetch_add(1, AtomicOrdering::Relaxed);
78+
}
79+
80+
fn report(&self) {
81+
let b: Vec<u64> = self
82+
.buckets
83+
.iter()
84+
.map(|a| a.load(AtomicOrdering::Relaxed))
85+
.collect();
86+
let e = self.errors.load(AtomicOrdering::Relaxed);
87+
info!(
88+
"Upload complete: <250ms={} 250-500ms={} 500ms-1s={} 1-2s={} 2-5s={} >5s={} errors={}",
89+
b[0], b[1], b[2], b[3], b[4], b[5], e
90+
);
91+
}
92+
}
93+
5094
/// Specify how blocks of all zeroes should be handled.
5195
#[derive(Copy, Clone)]
5296
pub enum ZeroBlocks {
@@ -59,12 +103,27 @@ pub enum ZeroBlocks {
59103
}
60104

61105
pub struct SnapshotUploader {
62-
ebs_client: EbsClient,
106+
ebs_clients: Vec<EbsClient>,
63107
}
64108

65109
impl SnapshotUploader {
66110
pub fn new(ebs_client: EbsClient) -> Self {
67-
SnapshotUploader { ebs_client }
111+
SnapshotUploader {
112+
ebs_clients: vec![ebs_client],
113+
}
114+
}
115+
116+
/// Create an uploader with multiple independent EBS clients. Blocks are
117+
/// distributed across clients by index, giving each a separate HTTP
118+
/// connection pool. This can reduce head-of-line blocking when many
119+
/// workers share a single pool over high-latency paths.
120+
pub fn with_client_shards(ebs_clients: Vec<EbsClient>) -> Self {
121+
assert!(!ebs_clients.is_empty(), "need at least one EBS client");
122+
SnapshotUploader { ebs_clients }
123+
}
124+
125+
fn client_for_block(&self, block_index: i32) -> &EbsClient {
126+
&self.ebs_clients[block_index as usize % self.ebs_clients.len()]
68127
}
69128

70129
/// Upload a snapshot from the file at the specified path.
@@ -89,6 +148,7 @@ impl SnapshotUploader {
89148
progress_bar: Option<ProgressBar>,
90149
zero_blocks: Option<ZeroBlocks>,
91150
kms_key_id: Option<String>,
151+
workers: Option<usize>,
92152
) -> Result<String> {
93153
let path = path.as_ref();
94154
let description = description.map(|s| s.to_string()).unwrap_or_else(|| {
@@ -187,7 +247,7 @@ impl SnapshotUploader {
187247
block_digests: Arc::clone(&block_digests),
188248
block_errors: Arc::clone(&block_errors),
189249
progress_bar: Arc::clone(&progress_bar),
190-
ebs_client: self.ebs_client.clone(),
250+
ebs_client: self.client_for_block(i).clone(),
191251
zero_blocks,
192252
});
193253

@@ -197,30 +257,56 @@ impl SnapshotUploader {
197257
// Distribute the work across a fixed number of concurrent workers.
198258
// New threads will be created by the runtime as needed, but we'll
199259
// only process this many blocks at once to limit resource usage.
200-
let upload = stream::iter(block_contexts).for_each_concurrent(
201-
SNAPSHOT_BLOCK_WORKERS,
202-
|context| async move {
260+
let worker_count = workers.unwrap_or(SNAPSHOT_BLOCK_WORKERS);
261+
assert!(worker_count > 0, "--workers must be greater than zero");
262+
debug!(
263+
"Using {} concurrent upload workers across {} client shards",
264+
worker_count,
265+
self.ebs_clients.len()
266+
);
267+
let stats = Arc::new(UploadStats::new());
268+
let upload = stream::iter(block_contexts).for_each_concurrent(worker_count, |context| {
269+
let stats = Arc::clone(&stats);
270+
async move {
203271
for attempt in 0..SNAPSHOT_BLOCK_ATTEMPTS {
204-
// Increasing wait between attempts. (No wait to start, on 0th attempt.)
205-
time::sleep(Duration::from_secs(attempt * SNAPSHOT_BLOCK_RETRY_SCALE)).await;
272+
if attempt > 0 {
273+
let backoff = Duration::from_secs(attempt * SNAPSHOT_BLOCK_RETRY_SCALE);
274+
debug!(
275+
"block {}: retry {}/{}, backoff {}s",
276+
context.block_index,
277+
attempt,
278+
SNAPSHOT_BLOCK_ATTEMPTS,
279+
backoff.as_secs()
280+
);
281+
time::sleep(backoff).await;
282+
}
206283

284+
let start = std::time::Instant::now();
207285
let block_result = self.upload_block(&context).await;
286+
let elapsed = start.elapsed();
287+
208288
let mut block_errors = context.block_errors.lock().expect("poisoned");
209289
if let Err(e) = block_result {
210-
debug!(
211-
"Error uploading block, attempt {} of {}",
290+
stats.record_error();
291+
warn!(
292+
"block {}: attempt {}/{} failed after {:.1}s: {}",
293+
context.block_index,
212294
attempt + 1,
213-
SNAPSHOT_BLOCK_ATTEMPTS
295+
SNAPSHOT_BLOCK_ATTEMPTS,
296+
elapsed.as_secs_f64(),
297+
e
214298
);
215299
block_errors.insert(context.block_index, e);
216300
continue;
217301
}
302+
stats.record_success(elapsed);
218303
block_errors.remove(&context.block_index);
219304
break;
220305
}
221-
},
222-
);
306+
}
307+
});
223308
upload.await;
309+
stats.report();
224310

225311
// At this point, all the concurrent jobs have finished, so all of the Arcs we copied have
226312
// been dropped. Hence there's exactly one strong reference and it's safe to `try_unwrap`
@@ -283,8 +369,7 @@ impl SnapshotUploader {
283369
tags: Option<Vec<Tag>>,
284370
kms_key_id: Option<String>,
285371
) -> Result<(String, i32)> {
286-
let mut request = self
287-
.ebs_client
372+
let mut request = self.ebs_clients[0]
288373
.start_snapshot()
289374
.volume_size(volume_size)
290375
.set_description(Some(description))
@@ -316,7 +401,7 @@ impl SnapshotUploader {
316401
changed_blocks_count: i32,
317402
checksum: &str,
318403
) -> Result<()> {
319-
self.ebs_client
404+
self.ebs_clients[0]
320405
.complete_snapshot()
321406
.snapshot_id(snapshot_id)
322407
.changed_blocks_count(changed_blocks_count)
@@ -579,3 +664,72 @@ mod error {
579664
},
580665
}
581666
}
667+
668+
#[cfg(test)]
669+
mod test {
670+
use super::*;
671+
672+
#[test]
673+
fn histogram_bucket_boundaries() {
674+
let stats = UploadStats::new();
675+
676+
stats.record_success(Duration::from_millis(0));
677+
stats.record_success(Duration::from_millis(249));
678+
stats.record_success(Duration::from_millis(250));
679+
stats.record_success(Duration::from_millis(499));
680+
stats.record_success(Duration::from_millis(500));
681+
stats.record_success(Duration::from_millis(999));
682+
stats.record_success(Duration::from_millis(1000));
683+
stats.record_success(Duration::from_millis(1999));
684+
stats.record_success(Duration::from_millis(2000));
685+
stats.record_success(Duration::from_millis(4999));
686+
stats.record_success(Duration::from_millis(5000));
687+
stats.record_success(Duration::from_millis(60000));
688+
689+
let b: Vec<u64> = stats
690+
.buckets
691+
.iter()
692+
.map(|a| a.load(AtomicOrdering::Relaxed))
693+
.collect();
694+
695+
assert_eq!(b[0], 2); // <250ms: 0, 249
696+
assert_eq!(b[1], 2); // 250-500ms: 250, 499
697+
assert_eq!(b[2], 2); // 500ms-1s: 500, 999
698+
assert_eq!(b[3], 2); // 1-2s: 1000, 1999
699+
assert_eq!(b[4], 2); // 2-5s: 2000, 4999
700+
assert_eq!(b[5], 2); // >5s: 5000, 60000
701+
}
702+
703+
#[test]
704+
fn error_counter() {
705+
let stats = UploadStats::new();
706+
stats.record_error();
707+
stats.record_error();
708+
stats.record_error();
709+
assert_eq!(stats.errors.load(AtomicOrdering::Relaxed), 3);
710+
}
711+
712+
#[test]
713+
fn client_for_block_modulo_logic() {
714+
// Verify the shard selection formula: block_index % num_shards.
715+
let num_shards = 3usize;
716+
let expected = [0, 1, 2, 0, 1, 2, 0, 1, 2];
717+
for (i, &want) in expected.iter().enumerate() {
718+
assert_eq!(i % num_shards, want);
719+
}
720+
}
721+
722+
#[test]
723+
#[should_panic(expected = "need at least one EBS client")]
724+
fn with_client_shards_rejects_empty() {
725+
SnapshotUploader::with_client_shards(vec![]);
726+
}
727+
728+
#[test]
729+
#[should_panic(expected = "--workers must be greater than zero")]
730+
fn worker_count_zero_panics() {
731+
// Simulates what happens if workers=Some(0) gets past CLI validation.
732+
let count: usize = 0;
733+
assert!(count > 0, "--workers must be greater than zero");
734+
}
735+
}

0 commit comments

Comments
 (0)