@@ -201,7 +201,7 @@ pub type Storage<J> = SqliteStorage<J, JsonCodec<CompactType>, SqliteFetcher>;
201201
202202/// The apalis worker handler. Deserializes the job, logs, and runs
203203/// [`perform`](Job::perform) against the injected input.
204- #[ cfg( not( feature = "test-support" ) ) ]
204+ #[ cfg( not( any ( test , feature = "test-support" ) ) ) ]
205205pub async fn work < J > (
206206 job : J ,
207207 input : Data < Arc < J :: Input > > ,
@@ -220,6 +220,22 @@ where
220220 } )
221221}
222222
223+ /// Test-support [`work`] variant that routes execution through a
224+ /// [`FailureInjector`] so end-to-end tests can force a terminal
225+ /// failure for a targeted job.
226+ #[ cfg( any( test, feature = "test-support" ) ) ]
227+ pub async fn work < J > (
228+ job : J ,
229+ input : Data < Arc < J :: Input > > ,
230+ injector : Data < FailureInjector > ,
231+ attempt : Attempt ,
232+ ) -> Result < J :: Output , JobError >
233+ where
234+ J : Job + Sync ,
235+ {
236+ injector. perform :: < J > ( & job, & input, attempt. current ( ) ) . await
237+ }
238+
223239/// Worker event handler that stops the worker and notifies the
224240/// supervisor on a terminal (retries-exhausted) failure.
225241pub fn on_terminal_failure (
@@ -250,6 +266,81 @@ fn log_processing(label: &Label, attempt: usize) {
250266 debug ! ( target: "job" , %label, attempt, "processing job" ) ;
251267}
252268
269+ /// Fault-injection registry for end-to-end terminal-failure tests.
270+ ///
271+ /// [`arm`](Self::arm) marks a [`Job::KIND`] so the next job of that
272+ /// kind fails terminally; the failure then sticks to that specific
273+ /// job instance (by [`Label`]) so retries of it keep failing while
274+ /// other instances of the same kind run normally.
275+ #[ cfg( any( test, feature = "test-support" ) ) ]
276+ #[ derive( Clone , Debug , Default ) ]
277+ pub struct FailureInjector {
278+ states : Arc < std:: sync:: Mutex < std:: collections:: HashMap < & ' static str , InjectionState > > > ,
279+ }
280+
281+ #[ cfg( any( test, feature = "test-support" ) ) ]
282+ #[ derive( Debug , Default ) ]
283+ enum InjectionState {
284+ #[ default]
285+ Idle ,
286+ Armed ,
287+ Targeted ( String ) ,
288+ }
289+
290+ #[ cfg( any( test, feature = "test-support" ) ) ]
291+ impl FailureInjector {
292+ /// Creates an injector with no kinds armed.
293+ #[ must_use]
294+ pub fn new ( ) -> Self {
295+ Self :: default ( )
296+ }
297+
298+ /// Arms injection for the next job of `kind`.
299+ pub fn arm ( & self , kind : & ' static str ) {
300+ self . lock ( ) . insert ( kind, InjectionState :: Armed ) ;
301+ }
302+
303+ fn should_inject ( & self , kind : & ' static str , label : & Label ) -> bool {
304+ let mut guard = self . lock ( ) ;
305+ let state = guard. entry ( kind) . or_default ( ) ;
306+ let inject = match state {
307+ InjectionState :: Idle => false ,
308+ InjectionState :: Armed => {
309+ * state = InjectionState :: Targeted ( label. as_str ( ) . to_owned ( ) ) ;
310+ true
311+ }
312+ InjectionState :: Targeted ( target) => target == label. as_str ( ) ,
313+ } ;
314+ drop ( guard) ;
315+ inject
316+ }
317+
318+ fn lock (
319+ & self ,
320+ ) -> std:: sync:: MutexGuard < ' _ , std:: collections:: HashMap < & ' static str , InjectionState > > {
321+ self . states
322+ . lock ( )
323+ . unwrap_or_else ( std:: sync:: PoisonError :: into_inner)
324+ }
325+
326+ async fn perform < J : Job + Sync > (
327+ & self ,
328+ job : & J ,
329+ input : & J :: Input ,
330+ attempt : usize ,
331+ ) -> Result < J :: Output , JobError > {
332+ let label = job. label ( ) ;
333+ if self . should_inject ( J :: KIND , & label) {
334+ return Err ( JobError :: Injected ) ;
335+ }
336+ log_processing ( & label, attempt) ;
337+ job. perform ( input) . await . map_err ( |source| JobError :: Failed {
338+ label,
339+ source : Box :: new ( source) ,
340+ } )
341+ }
342+ }
343+
253344#[ cfg( test) ]
254345mod tests {
255346 use serde:: { Deserialize , Serialize } ;
@@ -295,4 +386,28 @@ mod tests {
295386 assert_eq ! ( welcome. label( ) . to_string( ) , "welcome:a@example.com" ) ;
296387 assert_eq ! ( reminder. label( ) . to_string( ) , "reminder:b@example.com" ) ;
297388 }
389+
390+ #[ tokio:: test]
391+ async fn injector_targets_first_armed_instance_and_spares_others ( ) {
392+ let injector = FailureInjector :: new ( ) ;
393+ injector. arm ( SendEmail :: KIND ) ;
394+
395+ let welcome = SendEmail :: Welcome {
396+ address : "a@example.com" . to_string ( ) ,
397+ } ;
398+ let reminder = SendEmail :: Reminder {
399+ address : "b@example.com" . to_string ( ) ,
400+ } ;
401+
402+ let first = injector. perform ( & welcome, & ( ) , 1 ) . await ;
403+ assert ! ( matches!( first, Err ( JobError :: Injected ) ) ) ;
404+
405+ let retry = injector. perform ( & welcome, & ( ) , 2 ) . await ;
406+ assert ! (
407+ matches!( retry, Err ( JobError :: Injected ) ) ,
408+ "the injected failure must stick to the targeted instance across retries"
409+ ) ;
410+
411+ injector. perform ( & reminder, & ( ) , 1 ) . await . unwrap ( ) ;
412+ }
298413}
0 commit comments