@@ -29,10 +29,19 @@ pub struct FetchRequest {
2929 pub commit_hash : String ,
3030}
3131
32+ #[ derive( Clone ) ]
33+ struct DelayedRequest {
34+ repo_url : Option < String > ,
35+ commit_hash : String ,
36+ run_at : std:: time:: Instant ,
37+ }
38+
3239pub struct FetchAgent {
3340 repo_path : PathBuf ,
3441 rx : mpsc:: Receiver < FetchRequest > ,
3542 main_tx : mpsc:: Sender < Event > ,
43+ tick_interval : Duration ,
44+ backoff_base : Duration ,
3645}
3746
3847impl FetchAgent {
@@ -46,15 +55,25 @@ impl FetchAgent {
4655 repo_path,
4756 rx,
4857 main_tx,
58+ tick_interval : Duration :: from_secs ( 10 ) ,
59+ backoff_base : Duration :: from_secs ( 10 ) ,
4960 } ,
5061 tx,
5162 )
5263 }
5364
65+ pub fn with_intervals ( mut self , tick_interval : Duration , backoff_base : Duration ) -> Self {
66+ self . tick_interval = tick_interval;
67+ self . backoff_base = backoff_base;
68+ self
69+ }
70+
5471 pub async fn run ( mut self ) {
5572 info ! ( "FetchAgent started" ) ;
5673 let mut queue: HashMap < Option < String > , HashSet < String > > = HashMap :: new ( ) ;
57- let mut ticker = interval ( Duration :: from_secs ( 10 ) ) ;
74+ let mut delayed_retries: Vec < DelayedRequest > = Vec :: new ( ) ; // In-memory delay vector
75+ let mut attempts_tracker: HashMap < String , u32 > = HashMap :: new ( ) ; // Local attempt tracker
76+ let mut ticker = interval ( self . tick_interval ) ; // Configurable batch timer
5877
5978 loop {
6079 tokio:: select! {
@@ -64,15 +83,41 @@ impl FetchAgent {
6483 . insert( req. commit_hash) ;
6584 }
6685 _ = ticker. tick( ) => {
86+ let now = std:: time:: Instant :: now( ) ;
87+ let mut expired_retries = Vec :: new( ) ;
88+
89+ // Filter out expired retries
90+ delayed_retries. retain( |item| {
91+ if now >= item. run_at {
92+ expired_retries. push( item. clone( ) ) ;
93+ false // Remove from delayed list (promoted)
94+ } else {
95+ true // Keep in delayed list
96+ }
97+ } ) ;
98+
99+ // Promote expired retries into the active batch queue
100+ for retry in expired_retries {
101+ queue. entry( retry. repo_url)
102+ . or_default( )
103+ . insert( retry. commit_hash) ;
104+ }
105+
106+ // Process active batch queue
67107 if !queue. is_empty( ) {
68- self . process_queue( & mut queue) . await ;
108+ self . process_queue( & mut queue, & mut delayed_retries , & mut attempts_tracker ) . await ;
69109 }
70110 }
71111 }
72112 }
73113 }
74114
75- async fn process_queue ( & self , queue : & mut HashMap < Option < String > , HashSet < String > > ) {
115+ async fn process_queue (
116+ & self ,
117+ queue : & mut HashMap < Option < String > , HashSet < String > > ,
118+ delayed_retries : & mut Vec < DelayedRequest > ,
119+ attempts_tracker : & mut HashMap < String , u32 > ,
120+ ) {
76121 info ! ( "Processing fetch queue with {} repos" , queue. len( ) ) ;
77122
78123 for ( url_opt, commits) in queue. drain ( ) {
@@ -102,9 +147,9 @@ impl FetchAgent {
102147 "All commits present locally, skipping fetch for {}" ,
103148 url_display
104149 ) ;
105- } else if let Some ( url) = url_opt {
150+ } else if let Some ( ref url) = url_opt {
106151 // Remote fetch logic
107- let remote_name = self . get_remote_name ( & url) ;
152+ let remote_name = self . get_remote_name ( url) ;
108153
109154 // Check if repo is local (same as self.repo_path)
110155 let is_local = {
@@ -126,17 +171,17 @@ impl FetchAgent {
126171 ) ;
127172 // Do not continue here; let it fall through to Step 3 where it will fail individually
128173 } else {
129- if let Err ( e) = self . ensure_remote ( & remote_name, & url) . await {
174+ if let Err ( e) = self . ensure_remote ( & remote_name, url) . await {
130175 error ! ( "Failed to ensure remote {}: {}" , url, e) ;
131176 for commit in & missing_commits {
132- let _ = self
133- . main_tx
134- . send ( Event :: IngestionFailed {
135- article_id : commit . clone ( ) ,
136- error : format ! ( "Failed to set up remote {}: {}" , url , e ) ,
137- source : MessageSource :: GitFetch ,
138- } )
139- . await ;
177+ self . handle_fetch_failure (
178+ & url_opt ,
179+ commit ,
180+ & format ! ( "Failed to set up remote {}: {}" , url , e ) ,
181+ delayed_retries ,
182+ attempts_tracker ,
183+ )
184+ . await ;
140185 }
141186 continue ;
142187 }
@@ -151,14 +196,14 @@ impl FetchAgent {
151196 if let Err ( e) = self . fetch_all ( & remote_name) . await {
152197 error ! ( "Full fetch failed for {}: {}" , url, e) ;
153198 for commit in & missing_commits {
154- let _ = self
155- . main_tx
156- . send ( Event :: IngestionFailed {
157- article_id : commit . clone ( ) ,
158- error : format ! ( "Failed to fetch from {}: {}" , url , e ) ,
159- source : MessageSource :: GitFetch ,
160- } )
161- . await ;
199+ self . handle_fetch_failure (
200+ & url_opt ,
201+ commit ,
202+ & format ! ( "Failed to fetch from {}: {}" , url , e ) ,
203+ delayed_retries ,
204+ attempts_tracker ,
205+ )
206+ . await ;
162207 }
163208 continue ;
164209 }
@@ -182,14 +227,14 @@ impl FetchAgent {
182227 {
183228 Ok ( shas) => shas,
184229 Err ( e) => {
185- let _ = self
186- . main_tx
187- . send ( Event :: IngestionFailed {
188- article_id : range . clone ( ) ,
189- error : format ! ( "Failed to resolve git range: {}" , e ) ,
190- source : MessageSource :: GitFetch ,
191- } )
192- . await ;
230+ self . handle_fetch_failure (
231+ & url_opt ,
232+ & commit_or_range ,
233+ & format ! ( "Failed to resolve git range: {}" , e ) ,
234+ delayed_retries ,
235+ attempts_tracker ,
236+ )
237+ . await ;
193238 continue ;
194239 }
195240 } ;
@@ -217,26 +262,33 @@ impl FetchAgent {
217262 }
218263 }
219264 }
265+ // Range Success! Clean up attempts tracker and delay queue for the range key
266+ attempts_tracker. remove ( & commit_or_range) ;
267+ delayed_retries. retain ( |item| item. commit_hash != commit_or_range) ;
220268 info ! ( "Successfully submitted remote range {}" , range) ;
221269 } else {
222270 // Single commit
223271 let full_sha = match self . resolve_sha ( & commit_or_range) . await {
224272 Ok ( sha) => sha,
225273 Err ( e) => {
226- let _ = self
227- . main_tx
228- . send ( Event :: IngestionFailed {
229- article_id : commit_or_range . clone ( ) ,
230- error : format ! ( "Failed to resolve SHA: {}" , e ) ,
231- source : MessageSource :: GitFetch ,
232- } )
233- . await ;
274+ self . handle_fetch_failure (
275+ & url_opt ,
276+ & commit_or_range ,
277+ & format ! ( "Failed to resolve SHA: {}" , e ) ,
278+ delayed_retries ,
279+ attempts_tracker ,
280+ )
281+ . await ;
234282 continue ;
235283 }
236284 } ;
237285
238286 match self . extract_patch ( & full_sha, & commit_or_range, 1 , 1 ) . await {
239287 Ok ( mut event) => {
288+ // Success! Clean up attempts tracker and the parked delay queue
289+ attempts_tracker. remove ( & commit_or_range) ;
290+ delayed_retries. retain ( |item| item. commit_hash != commit_or_range) ;
291+
240292 if let Event :: PatchSubmitted {
241293 ref mut message_id, ..
242294 } = event
@@ -251,14 +303,14 @@ impl FetchAgent {
251303 }
252304 Err ( e) => {
253305 error ! ( "Failed to extract patch {}: {}" , commit_or_range, e) ;
254- let _ = self
255- . main_tx
256- . send ( Event :: IngestionFailed {
257- article_id : commit_or_range ,
258- error : format ! ( "Failed to extract patch: {}" , e ) ,
259- source : MessageSource :: GitFetch ,
260- } )
261- . await ;
306+ self . handle_fetch_failure (
307+ & url_opt ,
308+ & commit_or_range ,
309+ & format ! ( "Failed to extract patch: {}" , e ) ,
310+ delayed_retries ,
311+ attempts_tracker ,
312+ )
313+ . await ;
262314 }
263315 }
264316 }
@@ -439,6 +491,61 @@ impl FetchAgent {
439491 total,
440492 } )
441493 }
494+
495+ async fn handle_fetch_failure (
496+ & self ,
497+ url_opt : & Option < String > ,
498+ commit : & str ,
499+ error_msg : & str ,
500+ delayed_retries : & mut Vec < DelayedRequest > ,
501+ attempts_tracker : & mut HashMap < String , u32 > ,
502+ ) {
503+ let attempts = attempts_tracker. get ( commit) . cloned ( ) . unwrap_or ( 0 ) ;
504+ let max_retries = 3 ;
505+
506+ if attempts < max_retries {
507+ let next_attempt = attempts + 1 ;
508+ attempts_tracker. insert ( commit. to_string ( ) , next_attempt) ;
509+
510+ // Exponential backoff using configurable base: base * (2 ^ attempts)
511+ let delay = self . backoff_base * ( 2u32 . pow ( next_attempt) ) ;
512+ let run_at = std:: time:: Instant :: now ( ) + delay;
513+
514+ warn ! (
515+ "Fetch/extract failed for {} (attempts: {}/{}). Parking in delay queue for {}s." ,
516+ commit,
517+ next_attempt,
518+ max_retries,
519+ delay. as_secs( )
520+ ) ;
521+
522+ // Deduplicate inside delayed_retries first to prevent duplicate parked entries!
523+ delayed_retries. retain ( |item| item. commit_hash != commit) ;
524+
525+ delayed_retries. push ( DelayedRequest {
526+ repo_url : url_opt. clone ( ) ,
527+ commit_hash : commit. to_string ( ) ,
528+ run_at,
529+ } ) ;
530+ } else {
531+ // Retries exhausted, clean up attempts tracker and the parked delay queue
532+ attempts_tracker. remove ( commit) ;
533+ delayed_retries. retain ( |item| item. commit_hash != commit) ;
534+ error ! (
535+ "Fetch/extract failed for {} after {} retries. Marking as Failed. Error: {}" ,
536+ commit, max_retries, error_msg
537+ ) ;
538+
539+ let _ = self
540+ . main_tx
541+ . send ( Event :: IngestionFailed {
542+ article_id : commit. to_string ( ) ,
543+ error : error_msg. to_string ( ) ,
544+ source : MessageSource :: GitFetch ,
545+ } )
546+ . await ;
547+ }
548+ }
442549}
443550
444551#[ cfg( test) ]
@@ -590,4 +697,79 @@ mod tests {
590697
591698 Ok ( ( ) )
592699 }
700+
701+ #[ tokio:: test]
702+ async fn test_fetch_agent_delay_queue_retry ( ) -> Result < ( ) > {
703+ let temp_dir = tempfile:: tempdir ( ) ?;
704+ let repo_path = temp_dir. path ( ) . to_path_buf ( ) ;
705+
706+ // Setup empty dummy repo
707+ Command :: new ( "git" )
708+ . current_dir ( & repo_path)
709+ . arg ( "init" )
710+ . output ( )
711+ . await ?;
712+
713+ let ( event_tx, mut event_rx) = mpsc:: channel ( 10 ) ;
714+
715+ // Create agent with tiny sub-second intervals for fast real-time testing!
716+ let ( agent, fetch_tx) = {
717+ let ( a, tx) = FetchAgent :: new ( repo_path. clone ( ) , event_tx) ;
718+ (
719+ a. with_intervals ( Duration :: from_millis ( 50 ) , Duration :: from_millis ( 50 ) ) ,
720+ tx,
721+ )
722+ } ;
723+
724+ // Spawn the agent in the background
725+ let agent_handle = tokio:: spawn ( agent. run ( ) ) ;
726+
727+ // Enqueue a FetchRequest for a missing commit (which will fail resolve_sha!)
728+ let req = FetchRequest {
729+ repo_url : None ,
730+ commit_hash : "0123456789abcdef0123456789abcdef01234567" . to_string ( ) ,
731+ } ;
732+ fetch_tx. send ( req) . await ?;
733+
734+ // Let the first tick (50ms) process. Wait 100ms to be safe.
735+ tokio:: time:: sleep ( Duration :: from_millis ( 100 ) ) . await ;
736+
737+ // The first tick ran process_queue, failed, and parked it in delayed_retries (Attempts = 1, delay = 100ms)
738+ // Verify no event is received yet.
739+ tokio:: select! {
740+ Some ( event) = event_rx. recv( ) => {
741+ panic!( "Received unexpected event: {:?}" , event) ;
742+ }
743+ _ = tokio:: time:: sleep( Duration :: from_millis( 50 ) ) => {
744+ // Expected: no event sent!
745+ }
746+ }
747+
748+ // Wait for retries to exhaust:
749+ // Attempt 1: delay 100ms.
750+ // Attempt 2: delay 200ms.
751+ // Attempt 3: delay 400ms.
752+ // Total delay is 700ms. Plus ticks processing time, we sleep 1.2 seconds to be completely safe.
753+ tokio:: time:: sleep ( Duration :: from_millis ( 1200 ) ) . await ;
754+
755+ // Verify that Event::IngestionFailed is finally received!
756+ tokio:: select! {
757+ Some ( Event :: IngestionFailed { article_id, error, .. } ) = event_rx. recv( ) => {
758+ assert_eq!( article_id, "0123456789abcdef0123456789abcdef01234567" ) ;
759+ assert!(
760+ error. contains( "Failed to resolve SHA" ) || error. contains( "Failed to extract patch" ) ,
761+ "Actual error was: {}" ,
762+ error
763+ ) ;
764+ }
765+ _ = tokio:: time:: sleep( Duration :: from_millis( 500 ) ) => {
766+ panic!( "Timed out waiting for IngestionFailed event after retries!" ) ;
767+ }
768+ }
769+
770+ // Clean up the agent
771+ agent_handle. abort ( ) ;
772+
773+ Ok ( ( ) )
774+ }
593775}
0 commit comments