@@ -14,7 +14,7 @@ use base64::Engine as _;
1414use bytes:: BytesMut ;
1515use futures:: stream:: { self , StreamExt } ;
1616use indicatif:: ProgressBar ;
17- use log:: debug;
17+ use log:: { debug, info , warn } ;
1818use sha2:: { Digest , Sha256 } ;
1919use snafu:: { ensure, OptionExt , ResultExt , Snafu } ;
2020use std:: cmp;
@@ -24,7 +24,7 @@ use std::ffi::OsStr;
2424use std:: io:: SeekFrom ;
2525use std:: os:: unix:: fs:: FileTypeExt ;
2626use std:: path:: { Path , PathBuf } ;
27- use std:: sync:: atomic:: { AtomicI32 , Ordering as AtomicOrdering } ;
27+ use std:: sync:: atomic:: { AtomicI32 , AtomicU64 , Ordering as AtomicOrdering } ;
2828use std:: sync:: { Arc , Mutex } ;
2929use std:: time:: Duration ;
3030use tokio:: fs:: { self , File } ;
@@ -39,14 +39,58 @@ const GIBIBYTE: i64 = 1024 * 1024 * 1024;
3939const SNAPSHOT_BLOCK_WORKERS : usize = 64 ;
4040// How long to wait between attempts; this number * attempt number, in seconds.
4141const 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 ;
4545const SNAPSHOT_TIMEOUT_MINUTES : i32 = 10 ;
4646const SHA256_ALGORITHM : ChecksumAlgorithm = ChecksumAlgorithm :: ChecksumAlgorithmSha256 ;
4747const 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 ) ]
5296pub enum ZeroBlocks {
@@ -59,12 +103,27 @@ pub enum ZeroBlocks {
59103}
60104
61105pub struct SnapshotUploader {
62- ebs_client : EbsClient ,
106+ ebs_clients : Vec < EbsClient > ,
63107}
64108
65109impl 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