1- use crate :: config:: { Concurrency , Job } ;
1+ use crate :: config:: { Concurrency , Job , RetryConfig } ;
22use crate :: git;
33use anyhow:: Result ;
44use chrono:: Utc ;
@@ -104,10 +104,36 @@ fn spawn_job(
104104
105105async fn execute_job ( job : & Job , work_dir : & PathBuf ) {
106106 let tag = format ! ( "[job:{}]" , job. id) ;
107+ let max_attempts = job. retry . as_ref ( ) . map ( |r| r. max + 1 ) . unwrap_or ( 1 ) ;
107108
108- println ! ( "{} Starting '{}'" , tag, job. name) ;
109- println ! ( "{} command: {}" , tag, job. command) ;
109+ for attempt in 0 ..max_attempts {
110+ if attempt > 0 {
111+ let delay = calculate_backoff ( job. retry . as_ref ( ) . unwrap ( ) , attempt - 1 ) ;
112+ println ! ( "{} Retry {}/{} after {:?}" , tag, attempt, max_attempts - 1 , delay) ;
113+ sleep ( delay) . await ;
114+ }
115+
116+ println ! ( "{} Starting '{}'" , tag, job. name) ;
117+ println ! ( "{} command: {}" , tag, job. command) ;
118+
119+ let result = run_command ( job, work_dir) . await ;
120+ let success = handle_result ( & tag, job, & result) ;
121+
122+ if success {
123+ return ;
124+ }
110125
126+ if attempt + 1 < max_attempts {
127+ println ! ( "{} Will retry..." , tag) ;
128+ }
129+ }
130+ }
131+
132+ fn calculate_backoff ( retry : & RetryConfig , attempt : u32 ) -> Duration {
133+ retry. delay . saturating_mul ( 2u32 . saturating_pow ( attempt) )
134+ }
135+
136+ async fn run_command ( job : & Job , work_dir : & PathBuf ) -> CommandResult {
111137 let result = tokio:: time:: timeout ( job. timeout , async {
112138 tokio:: task:: spawn_blocking ( {
113139 let cmd = job. command . clone ( ) ;
@@ -124,7 +150,23 @@ async fn execute_job(job: &Job, work_dir: &PathBuf) {
124150 . await ;
125151
126152 match result {
127- Ok ( Ok ( Ok ( output) ) ) => {
153+ Ok ( Ok ( Ok ( output) ) ) => CommandResult :: Completed ( output) ,
154+ Ok ( Ok ( Err ( e) ) ) => CommandResult :: ExecError ( e. to_string ( ) ) ,
155+ Ok ( Err ( e) ) => CommandResult :: TaskError ( e. to_string ( ) ) ,
156+ Err ( _) => CommandResult :: Timeout ,
157+ }
158+ }
159+
160+ enum CommandResult {
161+ Completed ( std:: process:: Output ) ,
162+ ExecError ( String ) ,
163+ TaskError ( String ) ,
164+ Timeout ,
165+ }
166+
167+ fn handle_result ( tag : & str , job : & Job , result : & CommandResult ) -> bool {
168+ match result {
169+ CommandResult :: Completed ( output) => {
128170 let stdout = String :: from_utf8_lossy ( & output. stdout ) ;
129171 let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
130172
@@ -135,6 +177,7 @@ async fn execute_job(job: &Job, work_dir: &PathBuf) {
135177 println ! ( "{} | {}" , tag, line) ;
136178 }
137179 }
180+ true
138181 } else {
139182 eprintln ! ( "{} ✗ Failed (exit code: {:?})" , tag, output. status. code( ) ) ;
140183 if !stderr. trim ( ) . is_empty ( ) {
@@ -147,16 +190,20 @@ async fn execute_job(job: &Job, work_dir: &PathBuf) {
147190 eprintln ! ( "{} | {}" , tag, line) ;
148191 }
149192 }
193+ false
150194 }
151195 }
152- Ok ( Ok ( Err ( e ) ) ) => {
196+ CommandResult :: ExecError ( e ) => {
153197 eprintln ! ( "{} ✗ Failed to execute: {}" , tag, e) ;
198+ false
154199 }
155- Ok ( Err ( e ) ) => {
200+ CommandResult :: TaskError ( e ) => {
156201 eprintln ! ( "{} ✗ Task error: {}" , tag, e) ;
202+ false
157203 }
158- Err ( _ ) => {
204+ CommandResult :: Timeout => {
159205 eprintln ! ( "{} ✗ Timeout after {:?}" , tag, job. timeout) ;
206+ false
160207 }
161208 }
162209}
@@ -176,6 +223,7 @@ mod tests {
176223 command : cmd. to_string ( ) ,
177224 timeout : Duration :: from_secs ( timeout_secs) ,
178225 concurrency : Concurrency :: Skip ,
226+ retry : None ,
179227 }
180228 }
181229
@@ -192,4 +240,44 @@ mod tests {
192240 let dir = tempdir ( ) . unwrap ( ) ;
193241 execute_job ( & job, & dir. path ( ) . to_path_buf ( ) ) . await ;
194242 }
243+
244+ #[ test]
245+ fn exponential_backoff_calculation ( ) {
246+ let retry = RetryConfig {
247+ max : 5 ,
248+ delay : Duration :: from_secs ( 1 ) ,
249+ } ;
250+ assert_eq ! ( calculate_backoff( & retry, 0 ) , Duration :: from_secs( 1 ) ) ;
251+ assert_eq ! ( calculate_backoff( & retry, 1 ) , Duration :: from_secs( 2 ) ) ;
252+ assert_eq ! ( calculate_backoff( & retry, 2 ) , Duration :: from_secs( 4 ) ) ;
253+ assert_eq ! ( calculate_backoff( & retry, 3 ) , Duration :: from_secs( 8 ) ) ;
254+ }
255+
256+ #[ tokio:: test]
257+ async fn job_retry_on_failure ( ) {
258+ let mut job = make_job ( "exit 1" , 10 ) ;
259+ job. retry = Some ( RetryConfig {
260+ max : 2 ,
261+ delay : Duration :: from_millis ( 10 ) ,
262+ } ) ;
263+ let dir = tempdir ( ) . unwrap ( ) ;
264+ let start = std:: time:: Instant :: now ( ) ;
265+ execute_job ( & job, & dir. path ( ) . to_path_buf ( ) ) . await ;
266+ // Should have waited at least 10ms + 20ms = 30ms for 2 retries
267+ assert ! ( start. elapsed( ) >= Duration :: from_millis( 30 ) ) ;
268+ }
269+
270+ #[ tokio:: test]
271+ async fn job_success_no_retry ( ) {
272+ let mut job = make_job ( "echo ok" , 10 ) ;
273+ job. retry = Some ( RetryConfig {
274+ max : 3 ,
275+ delay : Duration :: from_millis ( 100 ) ,
276+ } ) ;
277+ let dir = tempdir ( ) . unwrap ( ) ;
278+ let start = std:: time:: Instant :: now ( ) ;
279+ execute_job ( & job, & dir. path ( ) . to_path_buf ( ) ) . await ;
280+ // Should complete quickly without retries
281+ assert ! ( start. elapsed( ) < Duration :: from_millis( 100 ) ) ;
282+ }
195283}
0 commit comments