This is an automated email from the ASF dual-hosted git repository.
adamsaghy pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git
The following commit(s) were added to refs/heads/develop by this push:
new 5c2a5c7cfe FINERACT-2455: Retry loan originator creation in case of
unique constraint violation
5c2a5c7cfe is described below
commit 5c2a5c7cfe7c95e82d67c0b7a7176fc97048a5fa
Author: Oleksii Novikov <[email protected]>
AuthorDate: Tue Mar 10 01:28:10 2026 +0200
FINERACT-2455: Retry loan originator creation in case of unique constraint
violation
---
.../service/LoanOriginatorHelper.java | 104 ++++++++++++++++++++
.../service/LoanOriginatorLinkingServiceImpl.java | 107 ++++++++-------------
.../client/feign/helpers/FeignLoanHelper.java | 12 ++-
.../FeignLoanOriginatorDuringApplicationTest.java | 76 +++++++++++++++
4 files changed, 229 insertions(+), 70 deletions(-)
diff --git
a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorHelper.java
b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorHelper.java
new file mode 100644
index 0000000000..161c2aadcf
--- /dev/null
+++
b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorHelper.java
@@ -0,0 +1,104 @@
+/**
+ * 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.portfolio.loanorigination.service;
+
+import static
org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants.ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION;
+import static
org.apache.fineract.portfolio.loanorigination.api.LoanOriginatorApiConstants.CHANNEL_TYPE_CODE_NAME;
+import static
org.apache.fineract.portfolio.loanorigination.api.LoanOriginatorApiConstants.ORIGINATOR_TYPE_CODE_NAME;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.fineract.infrastructure.codes.domain.CodeValue;
+import
org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrapper;
+import
org.apache.fineract.infrastructure.configuration.domain.GlobalConfigurationProperty;
+import
org.apache.fineract.infrastructure.configuration.domain.GlobalConfigurationRepositoryWrapper;
+import
org.apache.fineract.infrastructure.configuration.exception.GlobalConfigurationPropertyNotFoundException;
+import org.apache.fineract.infrastructure.core.domain.ExternalId;
+import
org.apache.fineract.portfolio.loanorigination.data.LoanApplicationOriginatorData;
+import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginator;
+import
org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorRepository;
+import
org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorStatus;
+import
org.apache.fineract.portfolio.loanorigination.exception.LoanOriginatorCreationNotAllowedException;
+import
org.apache.fineract.portfolio.loanorigination.exception.LoanOriginatorNotActiveException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class LoanOriginatorHelper {
+
+ private final LoanOriginatorRepository loanOriginatorRepository;
+ private final GlobalConfigurationRepositoryWrapper
globalConfigurationRepository;
+ private final CodeValueRepositoryWrapper codeValueRepositoryWrapper;
+
+ // REQUIRES_NEW isolates the INSERT into a separate transaction and
persistence context,
+ // so a constraint violation does not corrupt the caller's session or mark
the
+ // outer transaction as rollback-only, allowing a safe retry.
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public Long findOrCreateOriginatorId(final LoanApplicationOriginatorData
data) {
+ final ExternalId externalId = new ExternalId(data.getExternalId());
+ return
loanOriginatorRepository.findByExternalId(externalId).map(existing -> {
+ validateActive(existing);
+ return existing.getId();
+ }).orElseGet(() -> {
+ if (!isOriginatorCreationDuringLoanApplicationEnabled()) {
+ throw new
LoanOriginatorCreationNotAllowedException(data.getExternalId());
+ }
+ return createNewOriginator(data, externalId).getId();
+ });
+ }
+
+ private void validateActive(final LoanOriginator originator) {
+ if (originator.getStatus() != LoanOriginatorStatus.ACTIVE) {
+ throw new LoanOriginatorNotActiveException(originator.getId(),
originator.getStatus().getValue());
+ }
+ }
+
+ private boolean isOriginatorCreationDuringLoanApplicationEnabled() {
+ try {
+ final GlobalConfigurationProperty config =
globalConfigurationRepository
+
.findOneByNameWithNotFoundDetection(ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION);
+ return config.isEnabled();
+ } catch (final GlobalConfigurationPropertyNotFoundException e) {
+ log.warn("Global configuration '{}' not found, defaulting to
disabled", ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION);
+ return false;
+ }
+ }
+
+ private LoanOriginator createNewOriginator(final
LoanApplicationOriginatorData data, final ExternalId externalId) {
+ log.info("Creating new originator with externalId: {} during loan
application", data.getExternalId());
+
+ final CodeValue originatorType = resolveCodeValue(data.getTypeId(),
ORIGINATOR_TYPE_CODE_NAME);
+ final CodeValue channelType =
resolveCodeValue(data.getChannelTypeId(), CHANNEL_TYPE_CODE_NAME);
+
+ final LoanOriginator originator = LoanOriginator.create(externalId,
data.getName(), LoanOriginatorStatus.ACTIVE, originatorType,
+ channelType);
+
+ return loanOriginatorRepository.saveAndFlush(originator);
+ }
+
+ private CodeValue resolveCodeValue(final Long codeValueId, final String
codeName) {
+ if (codeValueId == null) {
+ return null;
+ }
+ return
codeValueRepositoryWrapper.findOneByCodeNameAndIdWithNotFoundDetection(codeName,
codeValueId);
+ }
+}
diff --git
a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorLinkingServiceImpl.java
b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorLinkingServiceImpl.java
index bc4115a6ed..edfc273f9c 100644
---
a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorLinkingServiceImpl.java
+++
b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorLinkingServiceImpl.java
@@ -18,24 +18,14 @@
*/
package org.apache.fineract.portfolio.loanorigination.service;
-import static
org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants.ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION;
-import static
org.apache.fineract.portfolio.loanorigination.api.LoanOriginatorApiConstants.CHANNEL_TYPE_CODE_NAME;
-import static
org.apache.fineract.portfolio.loanorigination.api.LoanOriginatorApiConstants.ORIGINATOR_TYPE_CODE_NAME;
-
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
+import java.sql.SQLException;
import java.util.HashSet;
-import java.util.Optional;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.apache.fineract.infrastructure.codes.domain.CodeValue;
-import
org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrapper;
-import
org.apache.fineract.infrastructure.configuration.domain.GlobalConfigurationProperty;
-import
org.apache.fineract.infrastructure.configuration.domain.GlobalConfigurationRepositoryWrapper;
-import
org.apache.fineract.infrastructure.configuration.exception.GlobalConfigurationPropertyNotFoundException;
-import org.apache.fineract.infrastructure.core.domain.ExternalId;
import
org.apache.fineract.portfolio.loanaccount.service.LoanOriginatorLinkingService;
import
org.apache.fineract.portfolio.loanorigination.data.LoanApplicationOriginatorData;
import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginator;
@@ -43,11 +33,13 @@ import
org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMappin
import
org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMappingRepository;
import
org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorRepository;
import
org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorStatus;
-import
org.apache.fineract.portfolio.loanorigination.exception.LoanOriginatorCreationNotAllowedException;
import
org.apache.fineract.portfolio.loanorigination.exception.LoanOriginatorNotActiveException;
import
org.apache.fineract.portfolio.loanorigination.exception.LoanOriginatorNotFoundException;
import
org.apache.fineract.portfolio.loanorigination.serialization.LoanApplicationOriginatorDataValidator;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.dao.DataAccessException;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -61,98 +53,75 @@ import
org.springframework.transaction.annotation.Transactional;
@ConditionalOnProperty(value = "fineract.module.loan-origination.enabled",
havingValue = "true")
public class LoanOriginatorLinkingServiceImpl implements
LoanOriginatorLinkingService {
+ private static final String SQL_STATE_INTEGRITY_CONSTRAINT_VIOLATION =
"23";
+
private final LoanOriginatorRepository loanOriginatorRepository;
private final LoanOriginatorMappingRepository
loanOriginatorMappingRepository;
private final LoanApplicationOriginatorDataValidator validator;
- private final GlobalConfigurationRepositoryWrapper
globalConfigurationRepository;
- private final CodeValueRepositoryWrapper codeValueRepositoryWrapper;
+ private final LoanOriginatorHelper loanOriginatorHelper;
@Transactional
@Override
- public void processOriginatorsForLoanApplication(Long loanId, JsonArray
originatorsArray) {
+ public void processOriginatorsForLoanApplication(final Long loanId, final
JsonArray originatorsArray) {
if (originatorsArray == null || originatorsArray.isEmpty()) {
return;
}
log.debug("Processing {} originators for loan application {}",
originatorsArray.size(), loanId);
- Set<Long> attachedOriginatorIds = new HashSet<>();
+ final Set<Long> attachedOriginatorIds = new HashSet<>();
- for (JsonElement element : originatorsArray) {
+ for (final JsonElement element : originatorsArray) {
if (!element.isJsonObject()) {
continue;
}
- JsonObject jsonObject = element.getAsJsonObject();
- LoanApplicationOriginatorData originatorData =
validator.validateAndExtract(jsonObject);
- LoanOriginator originator =
resolveOrCreateOriginator(originatorData);
+ final JsonObject jsonObject = element.getAsJsonObject();
+ final LoanApplicationOriginatorData originatorData =
validator.validateAndExtract(jsonObject);
+ final Long originatorId =
resolveOrCreateOriginatorId(originatorData);
- if (attachedOriginatorIds.contains(originator.getId())) {
- log.debug("Originator {} already attached to loan {}, skipping
duplicate", originator.getId(), loanId);
+ if (attachedOriginatorIds.contains(originatorId)) {
+ log.debug("Originator {} already attached to loan {}, skipping
duplicate", originatorId, loanId);
continue;
}
- if (originator.getStatus() != LoanOriginatorStatus.ACTIVE) {
- throw new LoanOriginatorNotActiveException(originator.getId(),
originator.getStatus().getValue());
- }
-
- if
(!loanOriginatorMappingRepository.existsByLoanIdAndOriginatorId(loanId,
originator.getId())) {
- LoanOriginatorMapping mapping =
LoanOriginatorMapping.create(loanId, originator);
+ if
(!loanOriginatorMappingRepository.existsByLoanIdAndOriginatorId(loanId,
originatorId)) {
+ final LoanOriginator originatorRef =
loanOriginatorRepository.getReferenceById(originatorId);
+ final LoanOriginatorMapping mapping =
LoanOriginatorMapping.create(loanId, originatorRef);
loanOriginatorMappingRepository.save(mapping);
- log.debug("Attached originator {} to loan {}",
originator.getId(), loanId);
+ log.debug("Attached originator {} to loan {}", originatorId,
loanId);
}
- attachedOriginatorIds.add(originator.getId());
+ attachedOriginatorIds.add(originatorId);
}
}
- private LoanOriginator
resolveOrCreateOriginator(LoanApplicationOriginatorData originatorData) {
+ private Long resolveOrCreateOriginatorId(final
LoanApplicationOriginatorData originatorData) {
if (originatorData.getId() != null) {
- return loanOriginatorRepository.findById(originatorData.getId())
+ final LoanOriginator originator =
loanOriginatorRepository.findById(originatorData.getId())
.orElseThrow(() -> new
LoanOriginatorNotFoundException(originatorData.getId()));
+ if (originator.getStatus() != LoanOriginatorStatus.ACTIVE) {
+ throw new LoanOriginatorNotActiveException(originator.getId(),
originator.getStatus().getValue());
+ }
+ return originator.getId();
}
-
- String externalId = originatorData.getExternalId();
- Optional<LoanOriginator> existingOriginator =
loanOriginatorRepository.findByExternalId(new ExternalId(externalId));
-
- if (existingOriginator.isPresent()) {
- return existingOriginator.get();
- }
-
- if (!isOriginatorCreationDuringLoanApplicationEnabled()) {
- throw new LoanOriginatorCreationNotAllowedException(externalId);
- }
-
- return createNewOriginator(originatorData);
+ return findOrCreateOriginatorIdByExternalId(originatorData);
}
- private boolean isOriginatorCreationDuringLoanApplicationEnabled() {
+ private Long findOrCreateOriginatorIdByExternalId(final
LoanApplicationOriginatorData originatorData) {
try {
- GlobalConfigurationProperty config = globalConfigurationRepository
-
.findOneByNameWithNotFoundDetection(ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION);
- return config.isEnabled();
- } catch (GlobalConfigurationPropertyNotFoundException e) {
- log.warn("Global configuration '{}' not found, defaulting to
disabled", ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION);
- return false;
+ return
loanOriginatorHelper.findOrCreateOriginatorId(originatorData);
+ } catch (final JpaSystemException | DataIntegrityViolationException e)
{
+ if (!isConstraintViolation(e)) {
+ throw e;
+ }
+ // Another thread created the originator concurrently - retry
+ return
loanOriginatorHelper.findOrCreateOriginatorId(originatorData);
}
}
- private LoanOriginator createNewOriginator(LoanApplicationOriginatorData
data) {
- log.info("Creating new originator with externalId: {} during loan
application", data.getExternalId());
-
- CodeValue originatorType = resolveCodeValue(data.getTypeId(),
ORIGINATOR_TYPE_CODE_NAME);
- CodeValue channelType = resolveCodeValue(data.getChannelTypeId(),
CHANNEL_TYPE_CODE_NAME);
-
- LoanOriginator originator = LoanOriginator.create(new
ExternalId(data.getExternalId()), data.getName(), LoanOriginatorStatus.ACTIVE,
- originatorType, channelType);
-
- return loanOriginatorRepository.saveAndFlush(originator);
- }
-
- private CodeValue resolveCodeValue(Long codeValueId, String codeName) {
- if (codeValueId == null) {
- return null;
- }
- return
codeValueRepositoryWrapper.findOneByCodeNameAndIdWithNotFoundDetection(codeName,
codeValueId);
+ private boolean isConstraintViolation(final DataAccessException e) {
+ return e.getMostSpecificCause() instanceof SQLException sqlEx &&
sqlEx.getSQLState() != null
+ &&
sqlEx.getSQLState().startsWith(SQL_STATE_INTEGRITY_CONSTRAINT_VIOLATION);
}
}
diff --git
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java
index 5cf0c3bced..93496f0f74 100644
---
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java
+++
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java
@@ -182,6 +182,13 @@ public class FeignLoanHelper {
return response.getLoanId();
}
+ public Long createSubmittedLoanWithOriginators(Long clientId, Long
productId, List<PostLoansOriginatorData> originators) {
+ PostLoansRequest request = buildSubmittedLoanRequest(clientId,
productId);
+ request.setOriginators(originators);
+ PostLoansResponse response = ok(() ->
fineractClient.loans().calculateLoanScheduleOrSubmitLoanApplication(request,
(String) null));
+ return response.getLoanId();
+ }
+
public CallFailedRuntimeException
createSubmittedLoanWithOriginatorsExpectingError(Long clientId,
List<PostLoansOriginatorData> originators) {
PostLoansRequest request = buildSubmittedLoanRequest(clientId);
@@ -190,7 +197,10 @@ public class FeignLoanHelper {
}
private PostLoansRequest buildSubmittedLoanRequest(Long clientId) {
- Long productId = createSimpleLoanProduct();
+ return buildSubmittedLoanRequest(clientId, createSimpleLoanProduct());
+ }
+
+ private PostLoansRequest buildSubmittedLoanRequest(Long clientId, Long
productId) {
String todayDate =
org.apache.fineract.integrationtests.common.Utils.dateFormatter
.format(org.apache.fineract.integrationtests.common.Utils.getLocalDateOfTenant());
return new PostLoansRequest()//
diff --git
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanOriginatorDuringApplicationTest.java
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanOriginatorDuringApplicationTest.java
index 8fb841e3b1..4f71c7d149 100644
---
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanOriginatorDuringApplicationTest.java
+++
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanOriginatorDuringApplicationTest.java
@@ -18,7 +18,17 @@
*/
package org.apache.fineract.integrationtests.client.feign.tests;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
import org.apache.fineract.client.feign.FineractFeignClient;
import org.apache.fineract.client.feign.util.CallFailedRuntimeException;
import org.apache.fineract.client.models.PostLoansOriginatorData;
@@ -215,4 +225,70 @@ public class FeignLoanOriginatorDuringApplicationTest
extends FeignIntegrationTe
originatorHelper.detachOriginatorFromLoan(loanId, originatorId);
originatorHelper.deleteOriginator(originatorId);
}
+
+ @Test
+ public void
testCreateLoanWithSameOriginatorExternalIdInParallelShouldNotFail() throws
InterruptedException {
+ configHelper.enableOriginatorCreationDuringLoanApplication();
+
+ try {
+ final int threadCount = 10;
+ final String sharedOriginatorExternalId =
FeignLoanOriginatorHelper.generateUniqueExternalId();
+ final Long productId = loanHelper.createSimpleLoanProduct();
+
+ final List<Long> clientIds = new ArrayList<>();
+ for (int i = 0; i < threadCount; i++) {
+ clientIds.add(clientHelper.createClient());
+ }
+
+ final ExecutorService executorService =
Executors.newFixedThreadPool(threadCount);
+ final CountDownLatch startLatch = new CountDownLatch(1);
+ final CountDownLatch doneLatch = new CountDownLatch(threadCount);
+ final List<Long> results = Collections.synchronizedList(new
ArrayList<>());
+ final List<Exception> exceptions =
Collections.synchronizedList(new ArrayList<>());
+
+ for (int i = 0; i < threadCount; i++) {
+ final Long clientId = clientIds.get(i);
+ executorService.execute(() -> {
+ try {
+ startLatch.await();
+ final List<PostLoansOriginatorData> originators =
List.of(
+ new
PostLoansOriginatorData().externalId(sharedOriginatorExternalId).name("Parallel
Created Originator"));
+ final Long loanId =
loanHelper.createSubmittedLoanWithOriginators(clientId, productId, originators);
+ results.add(loanId);
+ } catch (final Exception e) {
+ exceptions.add(e);
+ } finally {
+ doneLatch.countDown();
+ }
+ });
+ }
+
+ startLatch.countDown();
+ assertTrue(doneLatch.await(30, TimeUnit.SECONDS), "All threads
should complete within timeout");
+ executorService.shutdown();
+ assertTrue(executorService.awaitTermination(10, TimeUnit.SECONDS),
"ExecutorService should terminate");
+
+ assertTrue(exceptions.isEmpty(),
+ "Expected no exceptions but got " + exceptions.size() + ":
" + exceptions.stream().map(Throwable::getMessage).toList());
+ assertEquals(threadCount, results.size(), "All loan applications
should succeed");
+
+ // Verify all loans have the same originator attached
+ for (final Long loanId : results) {
+ final var loanDetails =
loanHelper.getLoanDetailsWithAssociations(loanId, "originators");
+ assertNotNull(loanDetails.getOriginators());
+ assertEquals(1, loanDetails.getOriginators().size());
+ assertEquals(sharedOriginatorExternalId,
loanDetails.getOriginators().get(0).getExternalId(),
+ "All loans should reference the same originator");
+ }
+
+ // Cleanup
+ final var createdOriginator =
originatorHelper.getOriginatorByExternalId(sharedOriginatorExternalId);
+ for (final Long loanId : results) {
+ originatorHelper.detachOriginatorFromLoan(loanId,
createdOriginator.getId());
+ }
+ originatorHelper.deleteOriginator(createdOriginator.getId());
+ } finally {
+ configHelper.disableOriginatorCreationDuringLoanApplication();
+ }
+ }
}