FINERACT-1909: Retry feature for commands - deadlock

This commit is contained in:
jmarta
2023-11-20 19:49:47 +01:00
committed by Adam Saghy
parent 3acb2b3c50
commit ee79c58e15
11 changed files with 200 additions and 50 deletions
@@ -107,7 +107,7 @@ public class JournalEntry extends AbstractAuditableWithUTCDateTimeCustom {
// //
} }
public JournalEntry(final Office office, final PaymentDetail paymentDetail, final GLAccount glAccount, final String currencyCode, protected JournalEntry(final Office office, final PaymentDetail paymentDetail, final GLAccount glAccount, final String currencyCode,
final String transactionId, final boolean manualEntry, final LocalDate transactionDate, final Integer type, final String transactionId, final boolean manualEntry, final LocalDate transactionDate, final Integer type,
final BigDecimal amount, final String description, final Integer entityType, final Long entityId, final String referenceNumber, final BigDecimal amount, final String description, final Integer entityType, final Long entityId, final String referenceNumber,
final Long loanTransactionId, final Long savingsTransactionId, final Long clientTransactionId, final Long shareTransactionId) { final Long loanTransactionId, final Long savingsTransactionId, final Long clientTransactionId, final Long shareTransactionId) {
@@ -145,6 +145,8 @@ public class BatchApiServiceImpl implements BatchApiService {
} catch (RuntimeException ex) { } catch (RuntimeException ex) {
status.setRollbackOnly(); status.setRollbackOnly();
return buildErrorResponses(ex, responseList); return buildErrorResponses(ex, responseList);
} finally {
BatchRequestContextHolder.setEnclosingTransaction(Optional.empty());
} }
}); });
} catch (TransactionException | NonTransientDataAccessException ex) { } catch (TransactionException | NonTransientDataAccessException ex) {
@@ -329,7 +331,7 @@ public class BatchApiServiceImpl implements BatchApiService {
String body = null; String body = null;
Set<Header> headers = new HashSet<>(); Set<Header> headers = new HashSet<>();
if (ex != null) { if (ex != null) {
ErrorInfo errorInfo = errorHandler.handle(errorHandler.getMappable(ex)); ErrorInfo errorInfo = errorHandler.handle(ErrorHandler.getMappable(ex));
statusCode = errorInfo.getStatusCode(); statusCode = errorInfo.getStatusCode();
body = errorInfo.getMessage(); body = errorInfo.getMessage();
headers = Optional.ofNullable(errorInfo.getHeaders()).orElse(new HashSet<>()); headers = Optional.ofNullable(errorInfo.getHeaders()).orElse(new HashSet<>());
@@ -358,7 +360,7 @@ public class BatchApiServiceImpl implements BatchApiService {
String body = null; String body = null;
Set<Header> headers = new HashSet<>(); Set<Header> headers = new HashSet<>();
if (ex != null) { if (ex != null) {
ErrorInfo errorInfo = errorHandler.handle(errorHandler.getMappable(ex)); ErrorInfo errorInfo = errorHandler.handle(ErrorHandler.getMappable(ex));
statusCode = errorInfo.getStatusCode(); statusCode = errorInfo.getStatusCode();
body = errorInfo.getMessage(); body = errorInfo.getMessage();
headers = Optional.ofNullable(errorInfo.getHeaders()).orElse(new HashSet<>()); headers = Optional.ofNullable(errorInfo.getHeaders()).orElse(new HashSet<>());
@@ -62,9 +62,6 @@ public class CommandSourceService {
@NotNull @NotNull
private CommandSource saveInitial(CommandWrapper wrapper, JsonCommand jsonCommand, AppUser maker, String idempotencyKey) { private CommandSource saveInitial(CommandWrapper wrapper, JsonCommand jsonCommand, AppUser maker, String idempotencyKey) {
CommandSource initialCommandSource = getInitialCommandSource(wrapper, jsonCommand, maker, idempotencyKey); CommandSource initialCommandSource = getInitialCommandSource(wrapper, jsonCommand, maker, idempotencyKey);
if (initialCommandSource.getCommandJson() == null) {
initialCommandSource.setCommandJson("{}");
}
return commandSourceRepository.saveAndFlush(initialCommandSource); return commandSourceRepository.saveAndFlush(initialCommandSource);
} }
@@ -84,19 +81,22 @@ public class CommandSourceService {
} }
public ErrorInfo generateErrorInfo(Throwable t) { public ErrorInfo generateErrorInfo(Throwable t) {
return errorHandler.handle(errorHandler.getMappable(t)); return errorHandler.handle(ErrorHandler.getMappable(t));
} }
@Transactional(propagation = Propagation.REQUIRES_NEW)
public CommandSource getCommandSource(Long commandSourceId) { public CommandSource getCommandSource(Long commandSourceId) {
return commandSourceRepository.findById(commandSourceId).orElseThrow(() -> new CommandNotFoundException(commandSourceId)); return commandSourceRepository.findById(commandSourceId).orElseThrow(() -> new CommandNotFoundException(commandSourceId));
} }
@Transactional(propagation = Propagation.REQUIRED)
public CommandSource findCommandSource(CommandWrapper wrapper, String idempotencyKey) { public CommandSource findCommandSource(CommandWrapper wrapper, String idempotencyKey) {
return commandSourceRepository.findByActionNameAndEntityNameAndIdempotencyKey(wrapper.actionName(), wrapper.entityName(), return commandSourceRepository.findByActionNameAndEntityNameAndIdempotencyKey(wrapper.actionName(), wrapper.entityName(),
idempotencyKey); idempotencyKey);
} }
private CommandSource getInitialCommandSource(CommandWrapper wrapper, JsonCommand jsonCommand, AppUser maker, String idempotencyKey) { @Transactional(propagation = Propagation.REQUIRED)
public CommandSource getInitialCommandSource(CommandWrapper wrapper, JsonCommand jsonCommand, AppUser maker, String idempotencyKey) {
CommandSource commandSourceResult; CommandSource commandSourceResult;
if (jsonCommand.commandId() != null) { if (jsonCommand.commandId() != null) {
commandSourceResult = commandSourceRepository.findById(jsonCommand.commandId()) commandSourceResult = commandSourceRepository.findById(jsonCommand.commandId())
@@ -105,6 +105,9 @@ public class CommandSourceService {
} else { } else {
commandSourceResult = CommandSource.fullEntryFrom(wrapper, jsonCommand, maker, idempotencyKey, UNDER_PROCESSING.getValue()); commandSourceResult = CommandSource.fullEntryFrom(wrapper, jsonCommand, maker, idempotencyKey, UNDER_PROCESSING.getValue());
} }
if (commandSourceResult.getCommandJson() == null) {
commandSourceResult.setCommandJson("{}");
}
return commandSourceResult; return commandSourceResult;
} }
} }
@@ -106,9 +106,12 @@ public class SynchronousCommandProcessingService implements CommandProcessingSer
boolean sameTransaction = BatchRequestContextHolder.getEnclosingTransaction().isPresent(); boolean sameTransaction = BatchRequestContextHolder.getEnclosingTransaction().isPresent();
if (commandSource == null) { if (commandSource == null) {
AppUser user = context.authenticatedUser(wrapper); AppUser user = context.authenticatedUser(wrapper);
commandSource = sameTransaction ? commandSourceService.saveInitialSameTransaction(wrapper, command, user, idempotencyKey) if (sameTransaction) {
: commandSourceService.saveInitialNewTransaction(wrapper, command, user, idempotencyKey); commandSource = commandSourceService.getInitialCommandSource(wrapper, command, user, idempotencyKey);
storeCommandIdInContext(commandSource); // Store command id as a request attribute } else {
commandSource = commandSourceService.saveInitialNewTransaction(wrapper, command, user, idempotencyKey);
storeCommandIdInContext(commandSource); // Store command id as a request attribute
}
} }
setIdempotencyKeyStoreFlag(true); setIdempotencyKeyStoreFlag(true);
@@ -116,19 +119,21 @@ public class SynchronousCommandProcessingService implements CommandProcessingSer
try { try {
result = findCommandHandler(wrapper).processCommand(command); result = findCommandHandler(wrapper).processCommand(command);
} catch (Throwable t) { // NOSONAR } catch (Throwable t) { // NOSONAR
ErrorInfo errorInfo = commandSourceService.generateErrorInfo(t); RuntimeException mappable = ErrorHandler.getMappable(t);
ErrorInfo errorInfo = commandSourceService.generateErrorInfo(mappable);
commandSource.setResultStatusCode(errorInfo.getStatusCode()); commandSource.setResultStatusCode(errorInfo.getStatusCode());
commandSource.setResult(errorInfo.getMessage()); commandSource.setResult(errorInfo.getMessage());
commandSource.setStatus(ERROR); commandSource.setStatus(ERROR);
commandSource = sameTransaction ? commandSourceService.saveResultSameTransaction(commandSource) if (!sameTransaction) { // TODO: temporary solution
: commandSourceService.saveResultNewTransaction(commandSource); commandSource = commandSourceService.saveResultNewTransaction(commandSource);
publishHookErrorEvent(wrapper, command, errorInfo); }
throw t; publishHookErrorEvent(wrapper, command, errorInfo); // TODO must be performed in a new transaction
throw mappable;
} }
commandSource.updateForAudit(result); commandSource.updateForAudit(result);
commandSource.setResult(toApiJsonSerializer.serializeResult(result));
commandSource.setResultStatusCode(SC_OK); commandSource.setResultStatusCode(SC_OK);
commandSource.setResult(toApiJsonSerializer.serializeResult(result));
commandSource.setStatus(PROCESSED); commandSource.setStatus(PROCESSED);
boolean isRollback = !isApprovedByChecker && (result.isRollbackTransaction() boolean isRollback = !isApprovedByChecker && (result.isRollbackTransaction()
@@ -139,6 +144,9 @@ public class SynchronousCommandProcessingService implements CommandProcessingSer
} }
commandSource = commandSourceService.saveResultSameTransaction(commandSource); commandSource = commandSourceService.saveResultSameTransaction(commandSource);
if (sameTransaction) {
storeCommandIdInContext(commandSource); // Store command id as a request attribute
}
if (isRollback) { if (isRollback) {
/* /*
@@ -147,14 +155,14 @@ public class SynchronousCommandProcessingService implements CommandProcessingSer
* when checker approves the transaction * when checker approves the transaction
*/ */
commandSource.setTransactionId(command.getTransactionId()); commandSource.setTransactionId(command.getTransactionId());
// TODO: this should be removed together with lines 133-135 // TODO: this should be removed together with lines 147-149
commandSource.setCommandJson(command.json()); // Set back CommandSource json data commandSource.setCommandJson(command.json()); // Set back CommandSource json data
throw new RollbackTransactionAsCommandIsNotApprovedByCheckerException(commandSource); throw new RollbackTransactionAsCommandIsNotApprovedByCheckerException(commandSource);
} }
result.setRollbackTransaction(null); result.setRollbackTransaction(null);
publishHookEvent(wrapper.entityName(), wrapper.actionName(), command, result); publishHookEvent(wrapper.entityName(), wrapper.actionName(), command, result); // TODO must be performed in a
// new transaction
return result; return result;
} }
@@ -211,7 +219,7 @@ public class SynchronousCommandProcessingService implements CommandProcessingSer
if (e instanceof RollbackTransactionAsCommandIsNotApprovedByCheckerException ex) { if (e instanceof RollbackTransactionAsCommandIsNotApprovedByCheckerException ex) {
return logCommand(ex.getCommandSourceResult()); return logCommand(ex.getCommandSourceResult());
} }
throw errorHandler.getMappable(e); throw ErrorHandler.getMappable(e);
} }
private NewCommandSourceHandler findCommandHandler(final CommandWrapper wrapper) { private NewCommandSourceHandler findCommandHandler(final CommandWrapper wrapper) {
@@ -117,7 +117,8 @@ public class ApiGlobalErrorResponse {
if (identifier != null) { if (identifier != null) {
details += " [" + identifier + ']'; details += " [" + identifier + ']';
} }
String msg = "The server is currently unable to handle the request due to concurrent modification" + details + ", please try again"; String msg = "The server is currently unable to handle the request due to concurrent modification " + details
+ ", please try again";
return create(SC_LOCKED, "error.msg.platform.service." + type + ".conflict", msg, msg); return create(SC_LOCKED, "error.msg.platform.service." + type + ".conflict", msg, msg);
} }
@@ -26,7 +26,9 @@ import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper; import jakarta.ws.rs.ext.ExceptionMapper;
import java.sql.SQLException;
import java.text.ParseException; import java.text.ParseException;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -35,6 +37,7 @@ import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.fortuna.ical4j.validate.ValidationException; import net.fortuna.ical4j.validate.ValidationException;
import org.apache.commons.collections4.SetUtils; import org.apache.commons.collections4.SetUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.batch.domain.Header; import org.apache.fineract.batch.domain.Header;
import org.apache.fineract.batch.exception.ErrorInfo; import org.apache.fineract.batch.exception.ErrorInfo;
import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.ApiParameterError;
@@ -45,6 +48,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.springframework.core.NestedRuntimeException; import org.springframework.core.NestedRuntimeException;
import org.springframework.dao.NonTransientDataAccessException; import org.springframework.dao.NonTransientDataAccessException;
import org.springframework.dao.PessimisticLockingFailureException;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
@@ -60,14 +65,55 @@ import org.springframework.stereotype.Component;
@AllArgsConstructor @AllArgsConstructor
public final class ErrorHandler { public final class ErrorHandler {
private static final Gson JSON_HELPER = GoogleGsonSerializerHelper.createGsonBuilder(true).create();
private enum PessimisticLockingFailureCode {
ROLLBACK("40"), // Transaction rollback
DEADLOCK("60"), // Oracle: deadlock
HY00("HY", "Lock wait timeout exceeded"), // MySql deadlock HY00
;
private final String code;
private final String msg;
PessimisticLockingFailureCode(String code, String msg) {
this.code = code;
this.msg = msg;
}
PessimisticLockingFailureCode(String code) {
this(code, null);
}
private static Throwable match(Throwable t) {
Throwable rootCause = ExceptionUtils.getRootCause(t);
return rootCause instanceof SQLException sqle && Arrays.stream(values()).anyMatch(e -> e.matches(sqle)) ? rootCause : null;
}
private boolean matches(SQLException ex) {
return code.equals(getSqlClassCode(ex)) && (msg == null || ex.getMessage().contains(msg));
}
@Nullable
private static String getSqlClassCode(SQLException ex) {
String sqlState = ex.getSQLState();
if (sqlState == null) {
SQLException nestedEx = ex.getNextException();
if (nestedEx != null) {
sqlState = nestedEx.getSQLState();
}
}
return sqlState != null && sqlState.length() > 2 ? sqlState.substring(0, 2) : sqlState;
}
}
@Autowired @Autowired
private final ApplicationContext ctx; private final ApplicationContext ctx;
@Autowired @Autowired
private final DefaultExceptionMapper defaultExceptionMapper; private final DefaultExceptionMapper defaultExceptionMapper;
private static final Gson JSON_HELPER = GoogleGsonSerializerHelper.createGsonBuilder(true).create();
@NotNull @NotNull
public <T extends RuntimeException> ExceptionMapper<T> findMostSpecificExceptionHandler(T exception) { public <T extends RuntimeException> ExceptionMapper<T> findMostSpecificExceptionHandler(T exception) {
Class<?> clazz = exception.getClass(); Class<?> clazz = exception.getClass();
@@ -120,8 +166,13 @@ public final class ErrorHandler {
String msg = defaultMsg == null ? t.getMessage() : defaultMsg; String msg = defaultMsg == null ? t.getMessage() : defaultMsg;
String codePfx = "error.msg" + (param == null ? "" : ("." + param)); String codePfx = "error.msg" + (param == null ? "" : ("." + param));
Object[] args = defaultMsgArgs == null ? new Object[] { t } : defaultMsgArgs; Object[] args = defaultMsgArgs == null ? new Object[] { t } : defaultMsgArgs;
Throwable cause;
if ((cause = PessimisticLockingFailureCode.match(t)) != null) {
return new PessimisticLockingFailureException(msg, cause); // deadlock
}
if (t instanceof NestedRuntimeException nre) { if (t instanceof NestedRuntimeException nre) {
Throwable cause = nre.getMostSpecificCause(); cause = nre.getMostSpecificCause();
msg = defaultMsg == null ? cause.getMessage() : defaultMsg; msg = defaultMsg == null ? cause.getMessage() : defaultMsg;
if (nre instanceof NonTransientDataAccessException) { if (nre instanceof NonTransientDataAccessException) {
msgCode = msgCode == null ? codePfx + ".data.integrity.issue" : msgCode; msgCode = msgCode == null ? codePfx + ".data.integrity.issue" : msgCode;
@@ -150,7 +201,7 @@ public final class ErrorHandler {
return new RuntimeException(msg, t); return new RuntimeException(msg, t);
} }
private <T> Set<T> createSet(T[] array) { private static <T> Set<T> createSet(T[] array) {
if (array == null) { if (array == null) {
return Set.of(); return Set.of();
} else { } else {
@@ -44,11 +44,14 @@ public class ConcurrencyFailureExceptionMapper implements FineractExceptionMappe
@Override @Override
public Response toResponse(final ConcurrencyFailureException exception) { public Response toResponse(final ConcurrencyFailureException exception) {
log.warn("Exception: {}, Message: {}", exception.getClass().getName(), exception.getMessage()); log.warn("Exception: {}, Message: {}", exception.getClass().getName(), exception.getMessage());
String type = "unknown"; String type;
String identifier = "unknown"; String identifier;
if (exception instanceof ObjectOptimisticLockingFailureException olex) { if (exception instanceof ObjectOptimisticLockingFailureException olex) {
type = olex.getPersistentClassName(); type = olex.getPersistentClassName();
identifier = olex.getIdentifier() == null ? null : String.valueOf(olex.getIdentifier()); identifier = olex.getIdentifier() == null ? null : String.valueOf(olex.getIdentifier());
} else {
type = "lock";
identifier = null;
} }
final ApiGlobalErrorResponse dataIntegrityError = ApiGlobalErrorResponse.locked(type, identifier); final ApiGlobalErrorResponse dataIntegrityError = ApiGlobalErrorResponse.locked(type, identifier);
return Response.status(SC_LOCKED).entity(dataIntegrityError).type(MediaType.APPLICATION_JSON).build(); return Response.status(SC_LOCKED).entity(dataIntegrityError).type(MediaType.APPLICATION_JSON).build();
@@ -320,7 +320,7 @@ resilience4j.retry.instances.executeCommand.max-attempts=${FINERACT_COMMAND_PROC
resilience4j.retry.instances.executeCommand.wait-duration=${FINERACT_COMMAND_PROCESSING_RETRY_WAIT_DURATION:1s} resilience4j.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} resilience4j.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} resilience4j.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.CannotAcquireLockException,org.springframework.orm.ObjectOptimisticLockingFailureException,org.eclipse.persistence.exceptions.OptimisticLockException,org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessUnderProcessingException} resilience4j.retry.instances.executeCommand.retryExceptions=${FINERACT_COMMAND_PROCESSING_RETRY_EXCEPTIONS:org.springframework.dao.ConcurrencyFailureException,org.eclipse.persistence.exceptions.OptimisticLockException,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}
@@ -331,10 +331,10 @@ resilience4j.retry.instances.recalculateInterest.max-attempts=${FINERACT_PROCESS
resilience4j.retry.instances.recalculateInterest.wait-duration=${FINERACT_PROCESS_RECALCULATE_INTEREST_RETRY_WAIT_DURATION:1s} resilience4j.retry.instances.recalculateInterest.wait-duration=${FINERACT_PROCESS_RECALCULATE_INTEREST_RETRY_WAIT_DURATION:1s}
resilience4j.retry.instances.recalculateInterest.enable-exponential-backoff=${FINERACT_PROCESS_RECALCULATE_INTEREST_RETRY_ENABLE_EXPONENTIAL_BACKOFF:true} resilience4j.retry.instances.recalculateInterest.enable-exponential-backoff=${FINERACT_PROCESS_RECALCULATE_INTEREST_RETRY_ENABLE_EXPONENTIAL_BACKOFF:true}
resilience4j.retry.instances.recalculateInterest.exponential-backoff-multiplier=${FINERACT_PROCESS_RECALCULATE_INTEREST_RETRY_EXPONENTIAL_BACKOFF_MULTIPLIER:2} resilience4j.retry.instances.recalculateInterest.exponential-backoff-multiplier=${FINERACT_PROCESS_RECALCULATE_INTEREST_RETRY_EXPONENTIAL_BACKOFF_MULTIPLIER:2}
resilience4j.retry.instances.recalculateInterest.retryExceptions=${FINERACT_PROCESS_RECALCULATE_INTEREST_RETRY_EXCEPTIONS:org.springframework.dao.CannotAcquireLockException,org.springframework.orm.ObjectOptimisticLockingFailureException,org.eclipse.persistence.exceptions.OptimisticLockException} resilience4j.retry.instances.recalculateInterest.retryExceptions=${FINERACT_PROCESS_RECALCULATE_INTEREST_RETRY_EXCEPTIONS:org.springframework.dao.ConcurrencyFailureException,org.eclipse.persistence.exceptions.OptimisticLockException}
resilience4j.retry.instances.postInterest.max-attempts=${FINERACT_PROCESS_POST_INTEREST_RETRY_MAX_ATTEMPTS:3} resilience4j.retry.instances.postInterest.max-attempts=${FINERACT_PROCESS_POST_INTEREST_RETRY_MAX_ATTEMPTS:3}
resilience4j.retry.instances.postInterest.wait-duration=${FINERACT_PROCESS_POST_INTEREST_RETRY_WAIT_DURATION:1s} resilience4j.retry.instances.postInterest.wait-duration=${FINERACT_PROCESS_POST_INTEREST_RETRY_WAIT_DURATION:1s}
resilience4j.retry.instances.postInterest.enable-exponential-backoff=${FINERACT_PROCESS_POST_INTEREST_RETRY_ENABLE_EXPONENTIAL_BACKOFF:true} resilience4j.retry.instances.postInterest.enable-exponential-backoff=${FINERACT_PROCESS_POST_INTEREST_RETRY_ENABLE_EXPONENTIAL_BACKOFF:true}
resilience4j.retry.instances.postInterest.exponential-backoff-multiplier=${FINERACT_PROCESS_POST_INTEREST_RETRY_EXPONENTIAL_BACKOFF_MULTIPLIER:2} resilience4j.retry.instances.postInterest.exponential-backoff-multiplier=${FINERACT_PROCESS_POST_INTEREST_RETRY_EXPONENTIAL_BACKOFF_MULTIPLIER:2}
resilience4j.retry.instances.postInterest.retryExceptions=${FINERACT_PROCESS_POST_INTEREST_RETRY_EXCEPTIONS:org.springframework.dao.CannotAcquireLockException,org.springframework.orm.ObjectOptimisticLockingFailureException,org.eclipse.persistence.exceptions.OptimisticLockException} resilience4j.retry.instances.postInterest.retryExceptions=${FINERACT_PROCESS_POST_INTEREST_RETRY_EXCEPTIONS:org.springframework.dao.ConcurrencyFailureException,org.eclipse.persistence.exceptions.OptimisticLockException}
@@ -179,15 +179,13 @@ public class BatchApiTest {
*/ */
@Test @Test
public void shouldRollBackAllTransactionsOnFailure() { public void shouldRollBackAllTransactionsOnFailure() {
// Create first client request // Create first client request
final BatchRequest br1 = BatchHelper.createClientRequest(4713L, "TestExtId11"); final BatchRequest br1 = BatchHelper.createClientRequest(4713L, "TestExtId11");
// Create second client request // Create second client request
final BatchRequest br2 = BatchHelper.createClientRequest(4714L, "TestExtId12"); final BatchRequest br2 = BatchHelper.createClientRequest(4714L, "TestExtId12");
// Create third client request, having same externalID as second client, // Create third client request, having same externalID as second client, hence cause of error
// hence cause of error
final BatchRequest br3 = BatchHelper.createClientRequest(4715L, "TestExtId11"); final BatchRequest br3 = BatchHelper.createClientRequest(4715L, "TestExtId11");
final List<BatchRequest> batchRequests = new ArrayList<>(); final List<BatchRequest> batchRequests = new ArrayList<>();
@@ -200,14 +198,13 @@ public class BatchApiTest {
final List<BatchResponse> response = BatchHelper.postBatchRequestsWithEnclosingTransaction(this.requestSpec, this.responseSpec, final List<BatchResponse> response = BatchHelper.postBatchRequestsWithEnclosingTransaction(this.requestSpec, this.responseSpec,
jsonifiedRequest); jsonifiedRequest);
// Verifies that none of the client in BatchRequest is created on the // Verifies that none of the client in BatchRequest is created on the server
// server BatchHelper.verifyClientNotCreatedOnServer(this.requestSpec, this.responseSpec, "TestExtId11");
BatchHelper.verifyClientCreatedOnServer(this.requestSpec, this.responseSpec, "TestExtId11"); BatchHelper.verifyClientNotCreatedOnServer(this.requestSpec, this.responseSpec, "TestExtId12");
BatchHelper.verifyClientCreatedOnServer(this.requestSpec, this.responseSpec, "TestExtId12");
// Asserts that all the transactions have been successfully rolled back // Asserts that all the transactions have been successfully rolled back
Assertions.assertEquals(1, response.size()); Assertions.assertEquals(1, response.size());
Assertions.assertEquals(SC_FORBIDDEN, response.get(0).getStatusCode(), "Verify Status code 500"); Assertions.assertEquals(SC_FORBIDDEN, response.get(0).getStatusCode(), "Verify Status code 403");
} }
/** /**
@@ -27,6 +27,7 @@ import static org.hamcrest.Matchers.anyOf;
import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
@@ -48,6 +49,7 @@ import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.stream.Collectors; import java.util.stream.Collectors;
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;
@@ -77,7 +79,6 @@ public class SavingsAccountTransactionTest {
private static final Logger log = LoggerFactory.getLogger(SavingsAccountTransactionTest.class); private static final Logger log = LoggerFactory.getLogger(SavingsAccountTransactionTest.class);
public static final String ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL"; public static final String ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL";
public static final String DEFAULT_DATE_FORMAT = "dd MMMM yyyy";
final String startDateString = "03 June 2023"; final String startDateString = "03 June 2023";
final String depositDateString = "05 June 2023"; final String depositDateString = "05 June 2023";
final String withdrawDateString = "10 June 2023"; final String withdrawDateString = "10 June 2023";
@@ -193,16 +194,17 @@ public class SavingsAccountTransactionTest {
String transactionDate = SavingsAccountHelper.TRANSACTION_DATE; String transactionDate = SavingsAccountHelper.TRANSACTION_DATE;
String transactionAmount = "10"; String transactionAmount = "10";
ExecutorService executor = Executors.newFixedThreadPool(30); ExecutorService executor = Executors.newFixedThreadPool(30);
for (int i = 0; i < 10; i++) { ArrayList<Future<?>> results = new ArrayList<>();
for (int i = 0; i < 5; i++) {
log.info("Starting concurrent transaction number {}", i); log.info("Starting concurrent transaction number {}", i);
SavingsTransactionData transactionData = SavingsTransactionData.builder().transactionDate(transactionDate) SavingsTransactionData transactionData = SavingsTransactionData.builder().transactionDate(transactionDate)
.transactionAmount(transactionAmount).paymentTypeId(PAYMENT_TYPE_ID).note("note_" + i).build(); .transactionAmount(transactionAmount).paymentTypeId(PAYMENT_TYPE_ID).note("note_" + i).build();
Runnable workerWithTransaction = new TransactionExecutor(batchWithTransactionHelper, savingsId, transactionData, true, Runnable workerWithTransaction = new TransactionExecutor(batchWithTransactionHelper, savingsId, transactionData, true,
datatableName, columnNames); datatableName, columnNames);
executor.execute(workerWithTransaction); results.add(executor.submit(workerWithTransaction));
Runnable workerWithoutTransaction = new TransactionExecutor(batchWithoutTransactionHelper, savingsId, transactionData, false, Runnable workerWithoutTransaction = new TransactionExecutor(batchWithoutTransactionHelper, savingsId, transactionData, false,
datatableName, columnNames); datatableName, columnNames);
executor.execute(workerWithoutTransaction); results.add(executor.submit(workerWithoutTransaction));
} }
executor.shutdown(); executor.shutdown();
@@ -211,6 +213,64 @@ public class SavingsAccountTransactionTest {
} }
this.datatableHelper.deleteDatatable(datatableName); this.datatableHelper.deleteDatatable(datatableName);
try {
for (Future<?> result : results) {
assertNull(result.get());
}
} catch (Exception e) {
throw new RuntimeException(e);
}
log.info("\nFinished all threads");
}
// @Test
public void testDeadlockSavingsBatchTransactions() {
final Integer clientID = ClientHelper.createClient(requestSpec, responseSpec);
ClientHelper.verifyClientCreatedOnServer(requestSpec, responseSpec, clientID);
final Integer savingsProductId = createSavingsProductDailyPosting();
assertNotNull(savingsProductId);
final Integer savingsId1 = savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductId, ACCOUNT_TYPE_INDIVIDUAL);
savingsAccountHelper.approveSavings(savingsId1);
savingsAccountHelper.activateSavings(savingsId1);
final Integer savingsId2 = savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductId, ACCOUNT_TYPE_INDIVIDUAL);
savingsAccountHelper.approveSavings(savingsId2);
savingsAccountHelper.activateSavings(savingsId2);
SavingsAccountHelper batchWithTransactionHelper = new SavingsAccountHelper(requestSpec, concurrentResponseSpec);
String transactionDate = SavingsAccountHelper.TRANSACTION_DATE;
String transactionAmount = "10";
ExecutorService executor = Executors.newFixedThreadPool(30);
ArrayList<Future<?>> results = new ArrayList<>();
for (int i = 0; i < 5; i++) {
log.info("Starting concurrent transaction number {}", i);
SavingsTransactionData transactionData1 = SavingsTransactionData.builder().transactionDate(transactionDate)
.transactionAmount(transactionAmount).paymentTypeId(PAYMENT_TYPE_ID).note("note1_" + i).build();
results.add(executor.submit(() -> {
runDeadlockBatch(batchWithTransactionHelper, savingsId1, savingsId2, transactionData1);
}));
SavingsTransactionData transactionData2 = SavingsTransactionData.builder().transactionDate(transactionDate)
.transactionAmount(transactionAmount).paymentTypeId(PAYMENT_TYPE_ID).note("note2_" + i).build();
results.add(executor.submit(() -> {
runDeadlockBatch(batchWithTransactionHelper, savingsId2, savingsId1, transactionData2);
}));
}
executor.shutdown();
// Wait until all threads are finish
while (!executor.isTerminated()) {
}
try {
for (Future<?> result : results) {
assertNull(result.get());
}
} catch (Exception e) {
throw new RuntimeException(e);
}
log.info("\nFinished all threads"); log.info("\nFinished all threads");
} }
@@ -268,13 +328,13 @@ public class SavingsAccountTransactionTest {
public static class TransactionExecutor implements Runnable { public static class TransactionExecutor implements Runnable {
private SavingsAccountHelper savingsHelper; private final SavingsAccountHelper savingsHelper;
private final Integer savingsId; private final Integer savingsId;
SavingsTransactionData transactionData; SavingsTransactionData transactionData;
boolean batch; private final boolean batch;
boolean enclosingTransaction; private final boolean enclosingTransaction;
String datatableName; private final String datatableName;
List<String> columnNames; private final List<String> columnNames;
private TransactionExecutor(SavingsAccountHelper savingsHelper, Integer savingsId, SavingsTransactionData transactionData, private TransactionExecutor(SavingsAccountHelper savingsHelper, Integer savingsId, SavingsTransactionData transactionData,
boolean batch, boolean enclosingTransaction, String datatableName, List<String> columnNames) { boolean batch, boolean enclosingTransaction, String datatableName, List<String> columnNames) {
@@ -338,7 +398,8 @@ public class SavingsAccountTransactionTest {
assertTrue(SC_OK == statusCode1 || SC_LOCKED == statusCode1); assertTrue(SC_OK == statusCode1 || SC_LOCKED == statusCode1);
Integer statusCode4 = responses.get(3).getStatusCode(); Integer statusCode4 = responses.get(3).getStatusCode();
assertNotNull(statusCode4); assertNotNull(statusCode4);
assertTrue(SC_OK == statusCode1 ? (SC_OK == statusCode4 || SC_LOCKED == statusCode4) : SC_FORBIDDEN == statusCode4); assertTrue(SC_OK == statusCode1 ? (SC_OK == statusCode4 || SC_LOCKED == statusCode4)
: (SC_FORBIDDEN == statusCode4 || SC_LOCKED == statusCode4));
} }
} else { } else {
String json = transactionData.getJson(); String json = transactionData.getJson();
@@ -364,6 +425,30 @@ public class SavingsAccountTransactionTest {
} }
} }
private void runDeadlockBatch(SavingsAccountHelper savingsHelper, Integer savingsId1, Integer savingsId2,
SavingsTransactionData transactionData) {
final BatchRequest depositRequest1 = BatchHelper.depositSavingAccount(1L, savingsId1.longValue(), transactionData);
final BatchRequest withdrawRequest1 = BatchHelper.withdrawSavingAccount(2L, savingsId1.longValue(), transactionData);
final BatchRequest depositRequest2 = BatchHelper.depositSavingAccount(3L, savingsId2.longValue(), transactionData);
final BatchRequest withdrawRequest2 = BatchHelper.withdrawSavingAccount(4L, savingsId2.longValue(), transactionData);
String json = BatchHelper.toJsonString(Arrays.asList(depositRequest1, withdrawRequest1, depositRequest2, withdrawRequest2));
RequestSpecification requestSpec = savingsHelper.getRequestSpec();
ResponseSpecification responseSpec = savingsHelper.getResponseSpec();
final List<BatchResponse> responses = BatchHelper.postBatchRequestsWithEnclosingTransaction(requestSpec, responseSpec, json);
assertNotNull(responses);
Integer statusCode = responses.get(0).getStatusCode();
assertNotNull(statusCode);
assertTrue(SC_OK == statusCode || SC_LOCKED == statusCode);
if (SC_OK == statusCode) {
assertEquals(4, responses.size());
Integer statusCode4 = responses.get(3).getStatusCode();
assertNotNull(statusCode4);
assertEquals(SC_OK, statusCode4);
} else {
assertEquals(1, responses.size());
}
}
// Reset configuration fields // Reset configuration fields
@AfterEach @AfterEach
public void tearDown() { public void tearDown() {
@@ -1009,7 +1009,7 @@ public final class BatchHelper {
* @param responseSpec * @param responseSpec
* @param externalId * @param externalId
*/ */
public static void verifyClientCreatedOnServer(final RequestSpecification requestSpec, final ResponseSpecification responseSpec, public static void verifyClientNotCreatedOnServer(final RequestSpecification requestSpec, final ResponseSpecification responseSpec,
final String externalId) { final String externalId) {
LOG.info("------------------------------CHECK CLIENT DETAILS------------------------------------\n"); LOG.info("------------------------------CHECK CLIENT DETAILS------------------------------------\n");
final String CLIENT_URL = "/fineract-provider/api/v1/clients?externalId=" + externalId + "&" + Utils.TENANT_IDENTIFIER; final String CLIENT_URL = "/fineract-provider/api/v1/clients?externalId=" + externalId + "&" + Utils.TENANT_IDENTIFIER;