FINERACT-1909: Retry feature for commands - deadlock
This commit is contained in:
+1
-1
@@ -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) {
|
||||||
|
|||||||
+4
-2
@@ -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<>());
|
||||||
|
|||||||
+8
-5
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-13
@@ -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) {
|
||||||
|
|||||||
+2
-1
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+55
-4
@@ -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 {
|
||||||
|
|||||||
+5
-2
@@ -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}
|
||||||
|
|||||||
+5
-8
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+95
-10
@@ -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() {
|
||||||
|
|||||||
+1
-1
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user