1

I've got a prototype bean implementing Runnable that has to retry its run-method and do something if the max retry cap is reached. Now I have the Problem that the recover method seems to be always invoked from the same spring bean not from the corresponding instance.

This is my code so far:

RetryableRunnable

@Slf4j
@AllArgsConstructor
public class RetryableRunnable implements Runnable {

   private final RetryDemoService retryDemoService;
   private final Status someStatus;

   @Override
   @Retryable(
         value = { RuntimeException.class },
         maxAttempts = 2,
         backoff = @Backoff(delay = 2000))
   public void run() {
      log.info( "+++ RetryableRunnable executed! +++" );
      retryDemoService.demoRun();
   }

   @Recover
   private void recover() {
      retryDemoService.demoRecover();
      log.info( String.valueOf( someStatus ) );
   }
}

Config

@Configuration
@AllArgsConstructor
@EnableRetry(proxyTargetClass = true)
public class RetryDemoConfig {

   private final RetryDemoService retryDemoService;

   @Bean
   @Scope( "prototype" )
   public RetryableRunnable retryableRunnable(Status status) {
       return new RetryableRunnable( retryDemoService, status );
   }
}

Service

@Service
@Slf4j
public class RetryDemoService {

   void demoRun() {
      log.info( "+++ Run! +++" );
   }

   void demoRecover() {
      log.info( "+++ Recover! +++" );
   }

}

Status Enum

public enum Status {
   STATUS1, STATUS2
}

Test to show the problem

@RunWith( SpringRunner.class )
@SpringBootTest
public class RetryableRunnableTest {

   @Autowired
   private BeanFactory beanFactory;

   @MockBean
   RetryDemoService retryDemoService;

   @Test
   public void retrieableRunnableIsRetriedOnlyThreeTimesAndRecoverMethodIsRun() throws InterruptedException {
      RetryableRunnable testInstance1 = beanFactory.getBean( RetryableRunnable.class, Status.STATUS1 );
      RetryableRunnable testInstance2 = beanFactory.getBean( RetryableRunnable.class, Status.STATUS2 );
      doThrow( new RuntimeException() )
          .doThrow( new RuntimeException() )
          .doThrow( new RuntimeException() )
          .when( retryDemoService ).demoRun();

      Thread thread1 = new Thread( testInstance1 );
      thread1.start();
      thread1.join();

      Thread thread2 = new Thread( testInstance2 );
      thread2.start();
      thread2.join();
    }
}

Now the output of the log is:

+++ RetryableRunnable executed! +++
+++ RetryableRunnable executed! +++
STATUS1
+++ RetryableRunnable executed! +++
+++ RetryableRunnable executed! +++
STATUS1

While it should be:

+++ RetryableRunnable executed! +++
+++ RetryableRunnable executed! +++
STATUS1
+++ RetryableRunnable executed! +++
+++ RetryableRunnable executed! +++
STATUS2

When I debug this test-method the recover method is invoked by RetryRunnable@3053 first time AND second time!

Is this a bug or am I missing the understanding of a concept? What can I do to solve this problem and invoke the corresponding prototype-bean "Status"-field?

Nas3nmann
  • 480
  • 1
  • 6
  • 13

1 Answers1

2

Prototype scope is not currently supported.

There is only one AnnotationAwareRetryOperationsInterceptor and it caches delegate RetryOperationsInterceptors based on the Method, not the object instance...

private MethodInterceptor getDelegate(Object target, Method method) {
    if (!this.delegates.containsKey(method)) {
         ...
    }
    return this.delegates.get(method);
}

The proper @Retryable method is invoked, but all instances will invoke the first cached @Recoverer.

The cache would have to changed to key on a combination of the target object and Method.

You could open an issue on github referencing this question.

Contributions are welcome.

This is the app I used to reproduce the issue...

@SpringBootApplication
@EnableRetry
public class So47513907Application {

    private static final Log log = LogFactory.getLog(So47513907Application.class);

    public static void main(String[] args) {
        SpringApplication.run(So47513907Application.class, args);
    }

    @Bean
    public ApplicationRunner runner(ApplicationContext ctx) {
        return args -> {
            Baz baz1 = ctx.getBean(Baz.class, "one");
            Baz baz2 = ctx.getBean(Baz.class, "two");
            baz1.foo();
            baz2.foo();
        };
    }

    @Bean
    @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
    public Baz baz(String arg) {
        return new Baz(arg);
    }

    @Service
    public static class Foo {

        void demoRun(Baz baz) {
            log.info(baz.instance + " +++ Run! +++");
            throw new RuntimeException();
        }

        void demoRecover(Baz baz) {
            log.info(baz.instance + " +++ Recover! +++");
        }

    }

    public interface Bar {

        void foo();

        void bar();

    }

    public static class Baz implements Bar {

        public String instance;

        @Autowired
        private Foo foo;

        public Baz(String instance) {
            this.instance = instance;
        }

        @Retryable
        @Override
        public void foo() {
            log.info(this.instance);
            foo.demoRun(this);
        }

        @Recover
        @Override
        public void bar() {
            log.info("recover: " + this.instance);
            foo.demoRecover(this);
        }

    }

}

EDIT

The simplest workaround is to use a RetryTemplate instead of the annotation:

@Bean
public RetryTemplate retryTemplate() {
    RetryTemplate template = new RetryTemplate();
    template.setRetryPolicy(new SimpleRetryPolicy(2));
    return template;
}

public static class Baz implements Bar {

    public String instance;

    @Autowired
    private Foo foo;

    @Autowired
    private RetryTemplate retryTemplate;

    public Baz(String instance) {
        this.instance = instance;
    }

//  @Retryable
    @Override
    public void foo() {
        this.retryTemplate.execute(context -> {
            log.info(this.instance);
            foo.demoRun(this);
            return null;
        }, context -> {
            bar();
            return null;
        });
    }

//  @Recover
    @Override
    public void bar() {
        log.info("recover: " + this.instance);
        foo.demoRecover(this);
    }

}

You can use the retry context to pass information from the failed method to the recoverer if needed.

Gary Russell
  • 166,535
  • 14
  • 146
  • 179