FINERACT-2304: Create Configuration assembler and move resilience4j props into fineract namespace implement proper retry for SynchronousCommandProcessingService and BatchApiServiceImpl

This commit is contained in:
Soma Sörös
2025-06-06 14:03:11 +02:00
committed by Adam Saghy
parent a687227f33
commit caf42f733c
11 changed files with 450 additions and 101 deletions
@@ -25,6 +25,7 @@ import com.google.gson.Gson;
import com.jayway.jsonpath.JsonPathException; import com.jayway.jsonpath.JsonPathException;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.github.resilience4j.core.functions.Either; import io.github.resilience4j.core.functions.Either;
import io.github.resilience4j.retry.Retry;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext; import jakarta.persistence.PersistenceContext;
import jakarta.ws.rs.core.UriInfo; import jakarta.ws.rs.core.UriInfo;
@@ -51,17 +52,18 @@ import org.apache.fineract.batch.domain.Header;
import org.apache.fineract.batch.exception.BatchReferenceInvalidException; import org.apache.fineract.batch.exception.BatchReferenceInvalidException;
import org.apache.fineract.batch.exception.ErrorInfo; import org.apache.fineract.batch.exception.ErrorInfo;
import org.apache.fineract.batch.service.ResolutionHelper.BatchRequestNode; import org.apache.fineract.batch.service.ResolutionHelper.BatchRequestNode;
import org.apache.fineract.commands.configuration.RetryConfigurationAssembler;
import org.apache.fineract.infrastructure.core.domain.BatchRequestContextHolder; import org.apache.fineract.infrastructure.core.domain.BatchRequestContextHolder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler; import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.filters.BatchCallHandler; import org.apache.fineract.infrastructure.core.filters.BatchCallHandler;
import org.apache.fineract.infrastructure.core.filters.BatchFilter; import org.apache.fineract.infrastructure.core.filters.BatchFilter;
import org.apache.fineract.infrastructure.core.filters.BatchRequestPreprocessor; import org.apache.fineract.infrastructure.core.filters.BatchRequestPreprocessor;
import org.apache.fineract.infrastructure.core.persistence.ExtendedJpaTransactionManager;
import org.springframework.dao.ConcurrencyFailureException; import org.springframework.dao.ConcurrencyFailureException;
import org.springframework.dao.NonTransientDataAccessException; import org.springframework.dao.NonTransientDataAccessException;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionException; import org.springframework.transaction.TransactionException;
import org.springframework.transaction.TransactionExecution; import org.springframework.transaction.TransactionExecution;
import org.springframework.transaction.TransactionSystemException; import org.springframework.transaction.TransactionSystemException;
@@ -90,6 +92,8 @@ public class BatchApiServiceImpl implements BatchApiService {
private final List<BatchRequestPreprocessor> batchPreprocessors; private final List<BatchRequestPreprocessor> batchPreprocessors;
private final RetryConfigurationAssembler retryConfigurationAssembler;
private EntityManager entityManager; private EntityManager entityManager;
/** /**
@@ -138,28 +142,35 @@ public class BatchApiServiceImpl implements BatchApiService {
*/ */
private List<BatchResponse> callInTransaction(Consumer<TransactionTemplate> transactionConfigurator, private List<BatchResponse> callInTransaction(Consumer<TransactionTemplate> transactionConfigurator,
Supplier<List<BatchResponse>> request) { Supplier<List<BatchResponse>> request) {
// Retry logic for enclosingTransaction=true and when the isolation level is REPEATABLE_READ or stricter we need
// to restart the transaction as well!
Retry retry = retryConfigurationAssembler.getRetryConfigurationForBatchApiWithEnclosingTransaction();
List<BatchResponse> responseList = new ArrayList<>(); List<BatchResponse> responseList = new ArrayList<>();
try { Supplier<List<BatchResponse>> batchSupplier = () -> {
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); responseList.clear();
if (transactionManager instanceof ExtendedJpaTransactionManager extendedJpaTransactionManager) { try {
transactionTemplate.setReadOnly(extendedJpaTransactionManager.isReadOnlyConnection()); TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
} transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
transactionConfigurator.accept(transactionTemplate); transactionConfigurator.accept(transactionTemplate);
return transactionTemplate.execute(status -> { return transactionTemplate.execute(status -> {
BatchRequestContextHolder.setEnclosingTransaction(status); BatchRequestContextHolder.setEnclosingTransaction(status);
try {
responseList.addAll(request.get()); responseList.addAll(request.get());
return responseList; return responseList;
} catch (BatchExecutionException ex) { });
log.error("Exception during the batch request processing", ex); } finally {
responseList.add(buildErrorResponse(ex.getCause(), ex.getRequest())); BatchRequestContextHolder.resetTransaction();
return responseList; }
} finally { };
BatchRequestContextHolder.resetTransaction(); Supplier<List<BatchResponse>> retryingBatch = Retry.decorateSupplier(retry, batchSupplier);
} try {
}); return retryingBatch.get();
} catch (TransactionException | NonTransientDataAccessException ex) { } catch (TransactionException | NonTransientDataAccessException ex) {
return buildErrorResponses(ex, responseList); return buildErrorResponses(ex, responseList);
} catch (BatchExecutionException ex) {
log.error("Exception during the batch request processing", ex);
responseList.add(buildErrorResponse(ex.getCause(), ex.getRequest()));
return responseList;
} }
} }
@@ -0,0 +1,94 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.commands.configuration;
import io.github.resilience4j.core.IntervalFunction;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import io.github.resilience4j.retry.RetryRegistry;
import java.util.Arrays;
import lombok.AllArgsConstructor;
import org.apache.fineract.batch.service.BatchExecutionException;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.stereotype.Service;
@AllArgsConstructor
@Service
public class RetryConfigurationAssembler {
public static final String EXECUTE_COMMAND = "executeCommand";
public static final String BATCH_RETRY = "batchRetry";
private final RetryRegistry registry;
private final FineractProperties fineractProperties;
private boolean isAssignableFrom(Object e, Class<? extends Throwable>[] exceptionList) {
return Arrays.stream(exceptionList).anyMatch(re -> re.isAssignableFrom(e.getClass()));
}
public Retry getRetryConfigurationForExecuteCommand() {
Class<? extends Throwable>[] exceptionList = fineractProperties.getRetry().getInstances().getExecuteCommand().getRetryExceptions();
RetryConfig.Builder configBuilder = buildCommonExecuteCommandConfiguration();
if (exceptionList != null) {
configBuilder.retryOnException(e -> isAssignableFrom(e, exceptionList));
}
RetryConfig config = configBuilder.build();
return registry.retry(EXECUTE_COMMAND, config);
}
public Retry getRetryConfigurationForBatchApiWithEnclosingTransaction() {
Class<? extends Throwable>[] exceptionList = fineractProperties.getRetry().getInstances().getExecuteCommand().getRetryExceptions();
RetryConfig.Builder configBuilder = buildCommonExecuteCommandConfiguration();
if (exceptionList != null) {
configBuilder.retryOnException(ex -> {
if (ex instanceof BatchExecutionException e) {
return isAssignableFrom(e.getCause(), exceptionList);
} else {
return isAssignableFrom(ex, exceptionList);
}
});
}
RetryConfig config = configBuilder.build();
return registry.retry(BATCH_RETRY, config);
}
private RetryConfig.Builder buildCommonExecuteCommandConfiguration() {
var props = fineractProperties.getRetry().getInstances().getExecuteCommand();
RetryConfig.Builder configBuilder = RetryConfig.custom().maxAttempts(props.getMaxAttempts());
if (props.getWaitDuration() != null && props.getWaitDuration().toMillis() >= 0) {
if (Boolean.TRUE.equals(props.getEnableExponentialBackoff())) {
Double multiplier = props.getExponentialBackoffMultiplier();
if (multiplier != null) {
configBuilder.intervalFunction(IntervalFunction.ofExponentialBackoff(props.getWaitDuration(), multiplier));
} else {
configBuilder.intervalFunction(IntervalFunction.ofExponentialBackoff(props.getWaitDuration()));
}
} else {
configBuilder.waitDuration(props.getWaitDuration());
}
}
return configBuilder;
}
}
@@ -24,15 +24,16 @@ import static org.apache.http.HttpStatus.SC_OK;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken; import com.google.gson.reflect.TypeToken;
import io.github.resilience4j.retry.annotation.Retry;
import java.lang.reflect.Type; import java.lang.reflect.Type;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.batch.exception.ErrorInfo; import org.apache.fineract.batch.exception.ErrorInfo;
import org.apache.fineract.commands.configuration.RetryConfigurationAssembler;
import org.apache.fineract.commands.domain.CommandProcessingResultType; import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource; import org.apache.fineract.commands.domain.CommandSource;
import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.CommandWrapper;
@@ -76,81 +77,96 @@ public class SynchronousCommandProcessingService implements CommandProcessingSer
private final CommandHandlerProvider commandHandlerProvider; private final CommandHandlerProvider commandHandlerProvider;
private final IdempotencyKeyResolver idempotencyKeyResolver; private final IdempotencyKeyResolver idempotencyKeyResolver;
private final CommandSourceService commandSourceService; private final CommandSourceService commandSourceService;
private final RetryConfigurationAssembler retryConfigurationAssembler;
private final FineractRequestContextHolder fineractRequestContextHolder; private final FineractRequestContextHolder fineractRequestContextHolder;
private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson(); private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson();
private CommandProcessingResult retryWrapper(Supplier<CommandProcessingResult> supplier) {
if (!BatchRequestContextHolder.isEnclosingTransaction()) {
try {
return retryConfigurationAssembler.getRetryConfigurationForExecuteCommand().executeSupplier(supplier);
} catch (RuntimeException e) {
fallbackExecuteCommand(e);
}
}
return supplier.get();
}
@Override @Override
@Retry(name = "executeCommand", fallbackMethod = "fallbackExecuteCommand")
public CommandProcessingResult executeCommand(final CommandWrapper wrapper, final JsonCommand command, public CommandProcessingResult executeCommand(final CommandWrapper wrapper, final JsonCommand command,
final boolean isApprovedByChecker) { final boolean isApprovedByChecker) {
// Do not store the idempotency key because of the exception handling return retryWrapper(() -> {
setIdempotencyKeyStoreFlag(false); // Do not store the idempotency key because of the exception handling
setIdempotencyKeyStoreFlag(false);
Long commandId = (Long) fineractRequestContextHolder.getAttribute(COMMAND_SOURCE_ID, null); Long commandId = (Long) fineractRequestContextHolder.getAttribute(COMMAND_SOURCE_ID, null);
boolean isRetry = commandId != null; boolean isRetry = commandId != null;
boolean isEnclosingTransaction = BatchRequestContextHolder.isEnclosingTransaction(); boolean isEnclosingTransaction = BatchRequestContextHolder.isEnclosingTransaction();
CommandSource commandSource = null; CommandSource commandSource = null;
String idempotencyKey; String idempotencyKey;
if (isRetry) { if (isRetry) {
commandSource = commandSourceService.getCommandSource(commandId); commandSource = commandSourceService.getCommandSource(commandId);
idempotencyKey = commandSource.getIdempotencyKey(); idempotencyKey = commandSource.getIdempotencyKey();
} else if ((commandId = command.commandId()) != null) { // action on the command itself } else if ((commandId = command.commandId()) != null) { // action on the command itself
commandSource = commandSourceService.getCommandSource(commandId); commandSource = commandSourceService.getCommandSource(commandId);
idempotencyKey = commandSource.getIdempotencyKey(); idempotencyKey = commandSource.getIdempotencyKey();
} else {
idempotencyKey = idempotencyKeyResolver.resolve(wrapper);
}
exceptionWhenTheRequestAlreadyProcessed(wrapper, idempotencyKey, isRetry);
AppUser user = context.authenticatedUser(wrapper);
if (commandSource == null) {
if (isEnclosingTransaction) {
commandSource = commandSourceService.getInitialCommandSource(wrapper, command, user, idempotencyKey);
} else { } else {
commandSource = commandSourceService.saveInitialNewTransaction(wrapper, command, user, idempotencyKey); idempotencyKey = idempotencyKeyResolver.resolve(wrapper);
commandId = commandSource.getId();
} }
} exceptionWhenTheRequestAlreadyProcessed(wrapper, idempotencyKey, isRetry);
if (commandId != null) {
AppUser user = context.authenticatedUser(wrapper);
if (commandSource == null) {
if (isEnclosingTransaction) {
commandSource = commandSourceService.getInitialCommandSource(wrapper, command, user, idempotencyKey);
} else {
commandSource = commandSourceService.saveInitialNewTransaction(wrapper, command, user, idempotencyKey);
commandId = commandSource.getId();
}
}
if (commandId != null) {
storeCommandIdInContext(commandSource); // Store command id as a request attribute
}
setIdempotencyKeyStoreFlag(true);
final CommandProcessingResult result;
try {
result = commandSourceService.processCommand(findCommandHandler(wrapper), command, commandSource, user,
isApprovedByChecker);
} catch (Throwable t) { // NOSONAR
RuntimeException mappable = ErrorHandler.getMappable(t);
ErrorInfo errorInfo = commandSourceService.generateErrorInfo(mappable);
Integer statusCode = errorInfo.getStatusCode();
commandSource.setResultStatusCode(statusCode);
commandSource.setResult(errorInfo.getMessage());
if (statusCode != SC_OK) {
commandSource.setStatus(ERROR);
}
if (!isEnclosingTransaction) { // TODO: temporary solution
commandSource = commandSourceService.saveResultNewTransaction(commandSource);
}
// must not throw any exception; must persist in new transaction as the current transaction was already
// marked as rollback
publishHookErrorEvent(wrapper, command, errorInfo);
throw mappable;
}
commandSource.setResultStatusCode(SC_OK);
commandSource.updateForAudit(result);
commandSource.setResult(toApiResultJsonSerializer.serializeResult(result));
commandSource.setStatus(PROCESSED);
commandSource = commandSourceService.saveResultSameTransaction(commandSource);
storeCommandIdInContext(commandSource); // Store command id as a request attribute storeCommandIdInContext(commandSource); // Store command id as a request attribute
}
setIdempotencyKeyStoreFlag(true); result.setRollbackTransaction(null);
publishHookEvent(wrapper.entityName(), wrapper.actionName(), command, result); // TODO must be performed in
final CommandProcessingResult result; // a
try { // new transaction
result = commandSourceService.processCommand(findCommandHandler(wrapper), command, commandSource, user, isApprovedByChecker); return result;
} catch (Throwable t) { // NOSONAR });
RuntimeException mappable = ErrorHandler.getMappable(t);
ErrorInfo errorInfo = commandSourceService.generateErrorInfo(mappable);
Integer statusCode = errorInfo.getStatusCode();
commandSource.setResultStatusCode(statusCode);
commandSource.setResult(errorInfo.getMessage());
if (statusCode != SC_OK) {
commandSource.setStatus(ERROR);
}
if (!isEnclosingTransaction) { // TODO: temporary solution
commandSource = commandSourceService.saveResultNewTransaction(commandSource);
}
// must not throw any exception; must persist in new transaction as the current transaction was already
// marked as rollback
publishHookErrorEvent(wrapper, command, errorInfo);
throw mappable;
}
commandSource.setResultStatusCode(SC_OK);
commandSource.updateForAudit(result);
commandSource.setResult(toApiResultJsonSerializer.serializeResult(result));
commandSource.setStatus(PROCESSED);
commandSource = commandSourceService.saveResultSameTransaction(commandSource);
storeCommandIdInContext(commandSource); // Store command id as a request attribute
result.setRollbackTransaction(null);
publishHookEvent(wrapper.entityName(), wrapper.actionName(), command, result); // TODO must be performed in a
// new transaction
return result;
} }
private void storeCommandIdInContext(CommandSource savedCommandSource) { private void storeCommandIdInContext(CommandSource savedCommandSource) {
@@ -188,8 +204,7 @@ public class SynchronousCommandProcessingService implements CommandProcessingSer
fineractRequestContextHolder.setAttribute(IDEMPOTENCY_KEY_STORE_FLAG, flag); fineractRequestContextHolder.setAttribute(IDEMPOTENCY_KEY_STORE_FLAG, flag);
} }
@SuppressWarnings("unused") public void fallbackExecuteCommand(Exception e) {
public CommandProcessingResult fallbackExecuteCommand(Exception e) {
throw ErrorHandler.getMappable(e); throw ErrorHandler.getMappable(e);
} }
@@ -84,6 +84,8 @@ public class FineractProperties {
private FineractCache cache; private FineractCache cache;
private RetryProperties retry;
@Getter @Getter
@Setter @Setter
public static class FineractTenantProperties { public static class FineractTenantProperties {
@@ -612,4 +614,30 @@ public class FineractProperties {
private Duration ttl; private Duration ttl;
private Integer maximumEntries; private Integer maximumEntries;
} }
@Setter
@Getter
public static class RetryProperties {
private InstancesProperties instances;
@Setter
@Getter
public static class InstancesProperties {
private ExecuteCommandProperties executeCommand;
@Getter
@Setter
public static class ExecuteCommandProperties {
private Class<? extends Throwable>[] retryExceptions;
private Integer maxAttempts;
private Boolean enableExponentialBackoff;
private Double exponentialBackoffMultiplier;
private Duration waitDuration;
}
}
}
} }
@@ -21,16 +21,28 @@ package org.apache.fineract.batch.service;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import io.github.resilience4j.retry.RetryRegistry;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import jakarta.ws.rs.core.UriInfo; import jakarta.ws.rs.core.UriInfo;
import java.time.Duration;
import java.util.List; import java.util.List;
import org.apache.fineract.batch.command.CommandStrategy; import org.apache.fineract.batch.command.CommandStrategy;
import org.apache.fineract.batch.command.CommandStrategyProvider; import org.apache.fineract.batch.command.CommandStrategyProvider;
import org.apache.fineract.batch.domain.BatchRequest; import org.apache.fineract.batch.domain.BatchRequest;
import org.apache.fineract.batch.domain.BatchResponse; import org.apache.fineract.batch.domain.BatchResponse;
import org.apache.fineract.batch.exception.ErrorInfo;
import org.apache.fineract.commands.configuration.RetryConfigurationAssembler;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.filters.BatchRequestPreprocessor; import org.apache.fineract.infrastructure.core.filters.BatchRequestPreprocessor;
import org.apache.fineract.infrastructure.core.persistence.ExtendedJpaTransactionManager; import org.apache.fineract.infrastructure.core.persistence.ExtendedJpaTransactionManager;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
@@ -57,8 +69,21 @@ class BatchApiServiceImplTest {
private CommandStrategy commandStrategy; private CommandStrategy commandStrategy;
@Mock @Mock
private UriInfo uriInfo; private UriInfo uriInfo;
@Mock
private ErrorHandler errorHandler;
@Mock
private RetryRegistry registry;
@Mock
private FineractProperties fineractProperties;
@InjectMocks
private RetryConfigurationAssembler retryConfigurationAssembler;
private final ResolutionHelper resolutionHelper = Mockito.spy(new ResolutionHelper(new FromJsonHelper())); private final ResolutionHelper resolutionHelper = Mockito.spy(new ResolutionHelper(new FromJsonHelper()));
private final List<BatchRequestPreprocessor> batchPreprocessors = Mockito.spy(List.of()); private final List<BatchRequestPreprocessor> batchPreprocessors = Mockito.spy(List.of());
@InjectMocks @InjectMocks
private BatchApiServiceImpl batchApiService; private BatchApiServiceImpl batchApiService;
private BatchRequest request; private BatchRequest request;
@@ -66,6 +91,8 @@ class BatchApiServiceImplTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
batchApiService = new BatchApiServiceImpl(strategyProvider, resolutionHelper, transactionManager, errorHandler, List.of(),
batchPreprocessors, retryConfigurationAssembler);
batchApiService.setEntityManager(entityManager); batchApiService.setEntityManager(entityManager);
request = new BatchRequest(); request = new BatchRequest();
request.setRequestId(1L); request.setRequestId(1L);
@@ -75,6 +102,16 @@ class BatchApiServiceImplTest {
response.setRequestId(1L); response.setRequestId(1L);
response.setStatusCode(200); response.setStatusCode(200);
response.setBody("Success"); response.setBody("Success");
FineractProperties.RetryProperties settings = new FineractProperties.RetryProperties();
settings.setInstances(new FineractProperties.RetryProperties.InstancesProperties());
settings.getInstances().setExecuteCommand(new FineractProperties.RetryProperties.InstancesProperties.ExecuteCommandProperties());
settings.getInstances().getExecuteCommand().setMaxAttempts(3);
settings.getInstances().getExecuteCommand().setWaitDuration(Duration.ofMillis(2));
settings.getInstances().getExecuteCommand().setEnableExponentialBackoff(false);
settings.getInstances().getExecuteCommand().setRetryExceptions(new Class[] { RetryException.class });
when(fineractProperties.getRetry()).thenReturn(settings);
when(registry.retry(anyString(), any(RetryConfig.class)))
.thenAnswer(i -> Retry.of((String) i.getArgument(0), (RetryConfig) i.getArgument(1)));
} }
@AfterEach @AfterEach
@@ -87,6 +124,74 @@ class BatchApiServiceImplTest {
Mockito.reset(transactionManager); Mockito.reset(transactionManager);
} }
@Test
void testHandleBatchRequestsWithEnclosingTransactionResult200WithRetryOnTransactionFailure() {
List<BatchRequest> requestList = List.of(request);
when(strategyProvider.getCommandStrategy(any())).thenReturn(commandStrategy);
when(commandStrategy.execute(any(), any())).thenReturn(response).thenReturn(response);
// throw exception at transaction commit to verify If OptimisticLockException or similar exceptions are on the
// retry list, they can perform a retry.
// do nothing on 2nd hit to simulate success commit
doThrow(new RetryException()).doNothing().when(transactionManager).commit(any());
// Regular transaction
when(transactionManager.getTransaction(any()))
.thenReturn(new DefaultTransactionStatus("txn_name", null, true, true, false, false, false, null));
List<BatchResponse> result = batchApiService.handleBatchRequestsWithEnclosingTransaction(requestList, uriInfo);
assertEquals(1, result.size());
assertEquals(200, result.getFirst().getStatusCode());
assertTrue(result.getFirst().getBody().contains("Success"));
verify(transactionManager, times(2)).commit(any());
verify(entityManager, times(2)).flush();
}
@Test
void testHandleBatchRequestsWithEnclosingTransactionResult200WithRetry() {
ErrorInfo errorInfo = mock(ErrorInfo.class);
when(errorInfo.getMessage()).thenReturn("Failed");
when(errorInfo.getStatusCode()).thenReturn(500);
when(errorHandler.handle(any())).thenReturn(errorInfo);
List<BatchRequest> requestList = List.of(request);
when(strategyProvider.getCommandStrategy(any())).thenReturn(commandStrategy);
when(commandStrategy.execute(any(), any())).thenThrow(new RetryException()).thenReturn(response);
// Regular transaction
when(transactionManager.getTransaction(any()))
.thenReturn(new DefaultTransactionStatus("txn_name", null, true, true, false, false, false, null));
List<BatchResponse> result = batchApiService.handleBatchRequestsWithEnclosingTransaction(requestList, uriInfo);
assertEquals(1, result.size());
assertEquals(200, result.getFirst().getStatusCode());
assertTrue(result.getFirst().getBody().contains("Success"));
Mockito.verify(entityManager, times(2)).flush();
}
@Test
void testHandleBatchRequestsWithEnclosingTransactionFailsWithRetry() {
List<BatchRequest> requestList = List.of(request);
when(strategyProvider.getCommandStrategy(any())).thenReturn(commandStrategy);
when(commandStrategy.execute(any(), any())).thenThrow(new RetryException()).thenThrow(new RetryException())
.thenThrow(new RetryException());
ErrorInfo errorInfo = mock(ErrorInfo.class);
when(errorInfo.getMessage()).thenReturn("Failed");
when(errorInfo.getStatusCode()).thenReturn(500);
when(errorHandler.handle(any())).thenReturn(errorInfo);
// Regular transaction
when(transactionManager.getTransaction(any()))
.thenReturn(new DefaultTransactionStatus("txn_name", null, true, true, false, false, false, null));
List<BatchResponse> result = batchApiService.handleBatchRequestsWithEnclosingTransaction(requestList, uriInfo);
assertEquals(1, result.size());
assertEquals(500, result.getFirst().getStatusCode());
assertTrue(result.getFirst().getBody().contains("Failed"));
Mockito.verify(entityManager, times(3)).flush();
}
@Test @Test
void testHandleBatchRequestsWithEnclosingTransaction() { void testHandleBatchRequestsWithEnclosingTransaction() {
List<BatchRequest> requestList = List.of(request); List<BatchRequest> requestList = List.of(request);
@@ -116,4 +221,7 @@ class BatchApiServiceImplTest {
assertTrue(result.get(0).getBody().contains("Success")); assertTrue(result.get(0).getBody().contains("Success"));
Mockito.verifyNoInteractions(entityManager); Mockito.verifyNoInteractions(entityManager);
} }
private static final class RetryException extends RuntimeException {}
} }
@@ -6,23 +6,28 @@ For a deeper understanding of resilience4j, refer to the https://resilience4j.re
|=== |===
|Name |Env Variable |Default Value |Description |Name |Env Variable |Default Value |Description
|resilience4j.retry.instances.executeCommand.max-attempts |fineract.retry.instances.executeCommand.max-attempts
|FINERACT_COMMAND_PROCESSING_RETRY_MAX_ATTEMPTS |FINERACT_COMMAND_PROCESSING_RETRY_MAX_ATTEMPTS
|3 |3
|The number of attempts that resilience4j will attempt to execute a command after a failed execution. Refer to org. apache. fineract. commands. service. SynchronousCommandProcessingService#executeCommand for more details |The number of attempts that resilience4j will attempt to execute a command after a failed execution. Refer to org. apache. fineract. commands. service. SynchronousCommandProcessingService#executeCommand for more details
|resilience4j.retry.instances.executeCommand.wait-duration |fineract.retry.instances.executeCommand.wait-duration
|FINERACT_COMMAND_PROCESSING_RETRY_WAIT_DURATION |FINERACT_COMMAND_PROCESSING_RETRY_WAIT_DURATION
|1s |1s
|The fixed time value that the retry instance will wait before the next attempt can be made to execute a command |The fixed time value that the retry instance will wait before the next attempt can be made to execute a command
|resilience4j.retry.instances.executeCommand.enable-exponential-backoff |fineract.retry.instances.executeCommand.enable-exponential-backoff
|FINERACT_COMMAND_PROCESSING_RETRY_ENABLE_EXPONENTIAL_BACKOFF |FINERACT_COMMAND_PROCESSING_RETRY_ENABLE_EXPONENTIAL_BACKOFF
|true |true
|If set to true, the wait-duration will increase exponentially between each retry to execute a command |If set to true, the wait-duration will increase exponentially between each retry to execute a command
|resilience4j.retry.instances.executeCommand.retryExceptions |fineract.retry.instances.executeCommand.exponential-backoff-multiplier
|FINERACT_COMMAND_PROCESSING_RETRY_EXPONENTIAL_BACKOFF_MULTIPLIER |FINERACT_COMMAND_PROCESSING_RETRY_EXPONENTIAL_BACKOFF_MULTIPLIER
|3
|The multiplier for exponential backoff, this is useful only when enable-exponential-backoff is set to true
|fineract.retry.instances.executeCommand.retryExceptions
|FINERACT_COMMAND_PROCESSING_RETRY_EXCEPTIONS
|org.springframework.dao.ConcurrencyFailureException,org.eclipse.persistence.exceptions.OptimisticLockException,jakarta.persistence.OptimisticLockException,org.springframework.orm.jpa.JpaOptimisticLockingFailureException,org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessUnderProcessingException |org.springframework.dao.ConcurrencyFailureException,org.eclipse.persistence.exceptions.OptimisticLockException,jakarta.persistence.OptimisticLockException,org.springframework.orm.jpa.JpaOptimisticLockingFailureException,org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessUnderProcessingException
|This property specifies the list of exceptions that the execute command retry instance will retry on |This property specifies the list of exceptions that the execute command retry instance will retry on
@@ -441,11 +441,11 @@ spring.batch.initialize-schema=NEVER
# Disabling Spring Batch jobs on startup # Disabling Spring Batch jobs on startup
spring.batch.job.enabled=false spring.batch.job.enabled=false
resilience4j.retry.instances.executeCommand.max-attempts=${FINERACT_COMMAND_PROCESSING_RETRY_MAX_ATTEMPTS:3} fineract.retry.instances.executeCommand.max-attempts=${FINERACT_COMMAND_PROCESSING_RETRY_MAX_ATTEMPTS:3}
resilience4j.retry.instances.executeCommand.wait-duration=${FINERACT_COMMAND_PROCESSING_RETRY_WAIT_DURATION:1s} fineract.retry.instances.executeCommand.wait-duration=${FINERACT_COMMAND_PROCESSING_RETRY_WAIT_DURATION:1s}
resilience4j.retry.instances.executeCommand.enable-exponential-backoff=${FINERACT_COMMAND_PROCESSING_RETRY_ENABLE_EXPONENTIAL_BACKOFF:true} fineract.retry.instances.executeCommand.enable-exponential-backoff=${FINERACT_COMMAND_PROCESSING_RETRY_ENABLE_EXPONENTIAL_BACKOFF:true}
resilience4j.retry.instances.executeCommand.exponential-backoff-multiplier=${FINERACT_COMMAND_PROCESSING_RETRY_EXPONENTIAL_BACKOFF_MULTIPLIER:2} fineract.retry.instances.executeCommand.exponential-backoff-multiplier=${FINERACT_COMMAND_PROCESSING_RETRY_EXPONENTIAL_BACKOFF_MULTIPLIER:2}
resilience4j.retry.instances.executeCommand.retryExceptions=${FINERACT_COMMAND_PROCESSING_RETRY_EXCEPTIONS:org.springframework.dao.ConcurrencyFailureException,org.eclipse.persistence.exceptions.OptimisticLockException,jakarta.persistence.OptimisticLockException,org.springframework.orm.jpa.JpaOptimisticLockingFailureException,org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessUnderProcessingException} fineract.retry.instances.executeCommand.retryExceptions=${FINERACT_COMMAND_PROCESSING_RETRY_EXCEPTIONS:org.springframework.dao.ConcurrencyFailureException,org.eclipse.persistence.exceptions.OptimisticLockException,jakarta.persistence.OptimisticLockException,org.springframework.orm.jpa.JpaOptimisticLockingFailureException,org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessUnderProcessingException}
resilience4j.retry.instances.processJobDetailForExecution.max-attempts=${FINERACT_PROCESS_JOB_DETAIL_RETRY_MAX_ATTEMPTS:3} resilience4j.retry.instances.processJobDetailForExecution.max-attempts=${FINERACT_PROCESS_JOB_DETAIL_RETRY_MAX_ATTEMPTS:3}
resilience4j.retry.instances.processJobDetailForExecution.wait-duration=${FINERACT_PROCESS_JOB_DETAIL_RETRY_WAIT_DURATION:1s} resilience4j.retry.instances.processJobDetailForExecution.wait-duration=${FINERACT_PROCESS_JOB_DETAIL_RETRY_WAIT_DURATION:1s}
@@ -23,10 +23,12 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import io.cucumber.java8.En; import io.cucumber.java8.En;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryRegistry; import io.github.resilience4j.retry.RetryRegistry;
import io.github.resilience4j.retry.event.RetryEvent; import io.github.resilience4j.retry.event.RetryEvent;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import org.apache.fineract.commands.configuration.RetryConfigurationAssembler;
import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException; import org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -50,6 +52,9 @@ public class CommandServiceStepDefinitions implements En {
@Autowired @Autowired
private RetryRegistry retryRegistry; private RetryRegistry retryRegistry;
@Autowired
private RetryConfigurationAssembler retryConfigurationAssembler;
private PortfolioCommandSourceWritePlatformService commandSourceWritePlatformService; private PortfolioCommandSourceWritePlatformService commandSourceWritePlatformService;
private DummyCommand command; private DummyCommand command;
@@ -67,8 +72,9 @@ public class CommandServiceStepDefinitions implements En {
Mockito.when(contextHolder.getAttribute(any(), any())).thenThrow(new CannotAcquireLockException("BLOW IT UP!!!")) Mockito.when(contextHolder.getAttribute(any(), any())).thenThrow(new CannotAcquireLockException("BLOW IT UP!!!"))
.thenThrow(new ObjectOptimisticLockingFailureException("Dummy", new RuntimeException("BLOW IT UP!!!"))) .thenThrow(new ObjectOptimisticLockingFailureException("Dummy", new RuntimeException("BLOW IT UP!!!")))
.thenThrow(new RollbackTransactionNotApprovedException(1L, null)); .thenThrow(new RollbackTransactionNotApprovedException(1L, null));
Retry retry1 = retryConfigurationAssembler.getRetryConfigurationForExecuteCommand();
this.retryRegistry.retry("executeCommand").getEventPublisher().onRetry(event -> { assertNotNull(retry1);
retry1.getEventPublisher().onRetry(event -> {
log.warn("... retry event: {}", event); log.warn("... retry event: {}", event);
counter.incrementAndGet(); counter.incrementAndGet();
@@ -92,7 +98,7 @@ public class CommandServiceStepDefinitions implements En {
assertEquals(2, retryEvent.getNumberOfRetryAttempts()); assertEquals(2, retryEvent.getNumberOfRetryAttempts());
}); });
Then("/^The command processing service execute function should be called 3 times$/", () -> { Then("/^The command processing service execute function should be called 2 times$/", () -> {
assertEquals(2, counter.get()); assertEquals(2, counter.get());
}); });
} }
@@ -20,12 +20,21 @@ package org.apache.fineract.commands.service;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import java.time.Duration;
import java.util.Map; import java.util.Map;
import org.apache.fineract.batch.exception.ErrorInfo;
import org.apache.fineract.commands.configuration.RetryConfigurationAssembler;
import org.apache.fineract.commands.domain.CommandProcessingResultType; import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource; import org.apache.fineract.commands.domain.CommandSource;
import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.CommandWrapper;
@@ -39,6 +48,7 @@ import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidati
import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer; import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
@@ -70,6 +80,9 @@ public class SynchronousCommandProcessingServiceTest {
@Mock @Mock
private CommandSourceService commandSourceService; private CommandSourceService commandSourceService;
@Mock
private RetryConfigurationAssembler retryConfigurationAssembler;
@Spy @Spy
private FineractRequestContextHolder fineractRequestContextHolder; private FineractRequestContextHolder fineractRequestContextHolder;
@@ -83,6 +96,71 @@ public class SynchronousCommandProcessingServiceTest {
public void setup() { public void setup() {
MockitoAnnotations.openMocks(this); MockitoAnnotations.openMocks(this);
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
when(retryConfigurationAssembler.getRetryConfigurationForExecuteCommand())
.thenReturn(Retry.of("IDENTIFIER", RetryConfig.custom().maxAttempts(3).failAfterMaxAttempts(true)
.waitDuration(Duration.ofMillis(1)).retryOnException(e -> e instanceof RetryException).build()));
ErrorInfo errorInfo = mock(ErrorInfo.class);
when(errorInfo.getMessage()).thenReturn("Failed");
when(errorInfo.getStatusCode()).thenReturn(500);
when(commandSourceService.generateErrorInfo(any())).thenReturn(errorInfo);
}
@AfterEach
public void teardown() {
reset(context);
reset(applicationContext);
reset(toApiJsonSerializer);
reset(toApiResultJsonSerializer);
reset(configurationDomainService);
reset(commandHandlerProvider);
reset(idempotencyKeyResolver);
reset(commandSourceService);
reset(retryConfigurationAssembler);
}
@Test
public void testExecuteCommandSuccessAfter2Fails() {
CommandWrapper commandWrapper = Mockito.mock(CommandWrapper.class);
when(commandWrapper.isDatatableResource()).thenReturn(false);
when(commandWrapper.isNoteResource()).thenReturn(false);
when(commandWrapper.isSurveyResource()).thenReturn(false);
when(commandWrapper.isLoanDisburseDetailResource()).thenReturn(false);
long commandId = 1L;
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
when(jsonCommand.commandId()).thenReturn(commandId);
NewCommandSourceHandler commandHandler = Mockito.mock(NewCommandSourceHandler.class);
CommandProcessingResult commandProcessingResult = Mockito.mock(CommandProcessingResult.class);
when(commandProcessingResult.isRollbackTransaction()).thenReturn(false);
when(commandHandler.processCommand(jsonCommand)).thenReturn(commandProcessingResult);
when(commandHandlerProvider.getHandler(Mockito.any(), Mockito.any())).thenReturn(commandHandler);
when(configurationDomainService.isMakerCheckerEnabledForTask(Mockito.any())).thenReturn(false);
String idk = "idk";
when(idempotencyKeyResolver.resolve(commandWrapper)).thenReturn(idk);
CommandSource commandSource = Mockito.mock(CommandSource.class);
when(commandSource.getId()).thenReturn(commandId);
when(commandSourceService.findCommandSource(commandWrapper, idk)).thenReturn(null);
when(commandSourceService.getCommandSource(commandId)).thenReturn(commandSource);
AppUser appUser = Mockito.mock(AppUser.class);
when(commandSourceService.saveInitialNewTransaction(commandWrapper, jsonCommand, appUser, idk)).thenReturn(commandSource);
when(commandSourceService.saveResultSameTransaction(commandSource)).thenReturn(commandSource);
when(commandSource.getStatus()).thenReturn(CommandProcessingResultType.PROCESSED.getValue());
when(context.authenticatedUser(Mockito.any(CommandWrapper.class))).thenReturn(appUser);
when(commandSourceService.processCommand(commandHandler, jsonCommand, commandSource, appUser, false))
.thenThrow(new RetryException()).thenThrow(new RetryException()).thenReturn(commandProcessingResult);
CommandProcessingResult actualCommandProcessingResult = underTest.executeCommand(commandWrapper, jsonCommand, false);
assertEquals(CommandProcessingResultType.PROCESSED.getValue(), commandSource.getStatus());
assertEquals(commandProcessingResult, actualCommandProcessingResult);
// verify 2x throw before success
verify(commandSourceService, times(2)).generateErrorInfo(any());
verify(commandSourceService).saveResultSameTransaction(commandSource);
} }
@Test @Test
@@ -186,4 +264,7 @@ public class SynchronousCommandProcessingServiceTest {
underTest.publishHookEvent(entityName, actionName, command, Object.class); underTest.publishHookEvent(entityName, actionName, command, Object.class);
}); });
} }
private static final class RetryException extends RuntimeException {}
} }
@@ -259,11 +259,11 @@ spring.batch.initialize-schema=NEVER
# Disabling Spring Batch jobs on startup # Disabling Spring Batch jobs on startup
spring.batch.job.enabled=false spring.batch.job.enabled=false
resilience4j.retry.instances.executeCommand.max-attempts=3 fineract.retry.instances.executeCommand.max-attempts=3
resilience4j.retry.instances.executeCommand.wait-duration=1s fineract.retry.instances.executeCommand.wait-duration=1s
resilience4j.retry.instances.executeCommand.enable-exponential-backoff=true fineract.retry.instances.executeCommand.enable-exponential-backoff=true
resilience4j.retry.instances.executeCommand.exponential-backoff-multiplier=2 fineract.retry.instances.executeCommand.exponential-backoff-multiplier=2
resilience4j.retry.instances.executeCommand.retryExceptions=org.springframework.dao.CannotAcquireLockException,org.springframework.orm.ObjectOptimisticLockingFailureException fineract.retry.instances.executeCommand.retryExceptions=org.springframework.dao.CannotAcquireLockException,org.springframework.orm.ObjectOptimisticLockingFailureException
resilience4j.retry.instances.processJobDetailForExecution.max-attempts=3 resilience4j.retry.instances.processJobDetailForExecution.max-attempts=3
resilience4j.retry.instances.processJobDetailForExecution.wait-duration=1s resilience4j.retry.instances.processJobDetailForExecution.wait-duration=1s
@@ -19,9 +19,10 @@
Feature: Commands Service Feature: Commands Service
@soma
Scenario: Verify that command source write service are working with fallback function Scenario: Verify that command source write service are working with fallback function
Given A command source write service Given A command source write service
When The user executes the command via a command write service with exceptions When The user executes the command via a command write service with exceptions
Then The command processing service should fallback as expected Then The command processing service should fallback as expected
Then The command processing service execute function should be called 3 times Then The command processing service execute function should be called 2 times