Skip to content

Commit 9c035fd

Browse files
FINERACT-2421: Retry external asset owner creation in case of unique constraint violation
1 parent 92f8b3d commit 9c035fd

File tree

5 files changed

+284
-41
lines changed

5 files changed

+284
-41
lines changed

fineract-investor/src/main/java/org/apache/fineract/investor/domain/ExternalAssetOwnerRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@
2222
import org.apache.fineract.infrastructure.core.domain.ExternalId;
2323
import org.springframework.data.jpa.repository.JpaRepository;
2424
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
25+
import org.springframework.data.jpa.repository.Query;
2526

2627
public interface ExternalAssetOwnerRepository
2728
extends JpaRepository<ExternalAssetOwner, Long>, JpaSpecificationExecutor<ExternalAssetOwner> {
2829

2930
Optional<ExternalAssetOwner> findByExternalId(ExternalId externalId);
3031

32+
@Query("SELECT e.id FROM ExternalAssetOwner e WHERE e.externalId = :externalId")
33+
Optional<Long> findIdByExternalId(ExternalId externalId);
34+
3135
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.fineract.investor.service;
20+
21+
import lombok.RequiredArgsConstructor;
22+
import org.apache.fineract.infrastructure.core.domain.ExternalId;
23+
import org.apache.fineract.investor.domain.ExternalAssetOwner;
24+
import org.apache.fineract.investor.domain.ExternalAssetOwnerRepository;
25+
import org.springframework.stereotype.Service;
26+
import org.springframework.transaction.annotation.Propagation;
27+
import org.springframework.transaction.annotation.Transactional;
28+
29+
@Service
30+
@RequiredArgsConstructor
31+
public class ExternalAssetOwnerTransactionalHelper {
32+
33+
private final ExternalAssetOwnerRepository repository;
34+
35+
// REQUIRES_NEW isolates the INSERT into a separate transaction and persistence context,
36+
// so a constraint violation does not corrupt the caller's Hibernate Session or mark the
37+
// outer transaction as rollback-only, allowing a safe retry.
38+
@Transactional(propagation = Propagation.REQUIRES_NEW)
39+
public Long findOrCreateId(final ExternalId externalId) {
40+
return repository.findIdByExternalId(externalId).orElseGet(() -> {
41+
final ExternalAssetOwner owner = new ExternalAssetOwner();
42+
owner.setExternalId(externalId);
43+
return repository.saveAndFlush(owner).getId();
44+
});
45+
}
46+
}

fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceImpl.java

Lines changed: 57 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import com.google.gson.JsonElement;
2626
import com.google.gson.reflect.TypeToken;
2727
import java.lang.reflect.Type;
28+
import java.sql.SQLException;
2829
import java.time.LocalDate;
2930
import java.util.ArrayList;
3031
import java.util.Arrays;
@@ -65,6 +66,9 @@
6566
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
6667
import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
6768
import org.apache.fineract.portfolio.loanaccount.exception.LoanNotFoundException;
69+
import org.springframework.dao.DataAccessException;
70+
import org.springframework.dao.DataIntegrityViolationException;
71+
import org.springframework.orm.jpa.JpaSystemException;
6872
import org.springframework.stereotype.Service;
6973
import org.springframework.transaction.annotation.Transactional;
7074

@@ -77,6 +81,8 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW
7781
ExternalTransferStatus.ACTIVE);
7882
private static final List<ExternalTransferStatus> BUYBACK_READY_STATUSES_FOR_DELAY_SETTLEMENT = List
7983
.of(ExternalTransferStatus.ACTIVE_INTERMEDIATE, ExternalTransferStatus.ACTIVE);
84+
private static final String SQL_STATE_INTEGRITY_CONSTRAINT_VIOLATION = "23";
85+
8086
private final ExternalAssetOwnerTransferRepository externalAssetOwnerTransferRepository;
8187
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
8288
private final FromJsonHelper fromApiJsonHelper;
@@ -85,6 +91,7 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW
8591
private final ConfigurationDomainService configurationDomainService;
8692
private final ExternalAssetOwnersReadService externalAssetOwnersReadService;
8793
private final ExternalAssetOwnerValidator externalAssetOwnerValidator;
94+
private final ExternalAssetOwnerTransactionalHelper externalAssetOwnerTransactionalHelper;
8895

8996
@Override
9097
@Transactional
@@ -171,15 +178,16 @@ private void validateEffectiveTransferForSale(final List<ExternalAssetOwnerTrans
171178
if (effectiveTransfers.size() == 2) {
172179
throw new ExternalAssetOwnerInitiateTransferException("This loan cannot be sold, there is already an in progress transfer");
173180
} else if (effectiveTransfers.size() == 1) {
174-
if (PENDING.equals(effectiveTransfers.get(0).getStatus())) {
181+
if (PENDING.equals(effectiveTransfers.getFirst().getStatus())) {
175182
throw new ExternalAssetOwnerInitiateTransferException(
176183
"External asset owner transfer is already in PENDING state for this loan");
177-
} else if (ExternalTransferStatus.ACTIVE.equals(effectiveTransfers.get(0).getStatus())) {
184+
} else if (ExternalTransferStatus.ACTIVE.equals(effectiveTransfers.getFirst().getStatus())) {
178185
throw new ExternalAssetOwnerInitiateTransferException(
179186
"This loan cannot be sold, because it is owned by an external asset owner");
180187
} else {
181-
throw new ExternalAssetOwnerInitiateTransferException(String.format(
182-
"This loan cannot be sold, because it is incorrect state! (transferId = %s)", effectiveTransfers.get(0).getId()));
188+
throw new ExternalAssetOwnerInitiateTransferException(
189+
String.format("This loan cannot be sold, because it is incorrect state! (transferId = %s)",
190+
effectiveTransfers.getFirst().getId()));
183191
}
184192
}
185193
}
@@ -188,7 +196,7 @@ private void validateEffectiveTransferForDelayedSettlementSale(final List<Extern
188196
if (effectiveTransfers.size() > 1) {
189197
throw new ExternalAssetOwnerInitiateTransferException("This loan cannot be sold, there is already an in progress transfer");
190198
} else if (effectiveTransfers.size() == 1) {
191-
if (!ACTIVE_INTERMEDIATE.equals(effectiveTransfers.get(0).getStatus())) {
199+
if (!ACTIVE_INTERMEDIATE.equals(effectiveTransfers.getFirst().getStatus())) {
192200
throw new ExternalAssetOwnerInitiateTransferException(
193201
"This loan cannot be sold, because it is not in ACTIVE-INTERMEDIATE state.");
194202
}
@@ -204,15 +212,16 @@ private void validateEffectiveTransferForIntermediarySale(final ExternalAssetOwn
204212
if (effectiveTransfers.size() > 1) {
205213
throw new ExternalAssetOwnerInitiateTransferException("This loan cannot be sold, there is already an in progress transfer");
206214
} else if (effectiveTransfers.size() == 1) {
207-
if (PENDING_INTERMEDIATE.equals(effectiveTransfers.get(0).getStatus())) {
215+
if (PENDING_INTERMEDIATE.equals(effectiveTransfers.getFirst().getStatus())) {
208216
throw new ExternalAssetOwnerInitiateTransferException(
209217
"External asset owner transfer is already in PENDING_INTERMEDIATE state for this loan");
210-
} else if (ExternalTransferStatus.ACTIVE.equals(effectiveTransfers.get(0).getStatus())) {
218+
} else if (ExternalTransferStatus.ACTIVE.equals(effectiveTransfers.getFirst().getStatus())) {
211219
throw new ExternalAssetOwnerInitiateTransferException(
212220
"This loan cannot be sold, because it is owned by an external asset owner");
213221
} else {
214-
throw new ExternalAssetOwnerInitiateTransferException(String.format(
215-
"This loan cannot be sold, because it is incorrect state! (transferId = %s)", effectiveTransfers.get(0).getId()));
222+
throw new ExternalAssetOwnerInitiateTransferException(
223+
String.format("This loan cannot be sold, because it is incorrect state! (transferId = %s)",
224+
effectiveTransfers.getFirst().getId()));
216225
}
217226
}
218227
}
@@ -232,17 +241,17 @@ private ExternalAssetOwnerTransfer fetchAndValidateEffectiveTransferForBuyback(
232241
} else if (effectiveTransfers.size() == 2) {
233242
throw new ExternalAssetOwnerInitiateTransferException(
234243
"This loan cannot be bought back, external asset owner buyback transfer is already in progress");
235-
} else if (!BUYBACK_READY_STATUSES.contains(effectiveTransfers.get(0).getStatus())) {
244+
} else if (!BUYBACK_READY_STATUSES.contains(effectiveTransfers.getFirst().getStatus())) {
236245
throw new ExternalAssetOwnerInitiateTransferException(
237246
String.format("This loan cannot be bought back, effective transfer is not in right state: %s",
238-
effectiveTransfers.get(0).getStatus()));
239-
} else if (DateUtils.isBefore(settlementDate, effectiveTransfers.get(0).getSettlementDate())) {
247+
effectiveTransfers.getFirst().getStatus()));
248+
} else if (DateUtils.isBefore(settlementDate, effectiveTransfers.getFirst().getSettlementDate())) {
240249
throw new ExternalAssetOwnerInitiateTransferException(
241250
String.format("This loan cannot be bought back, settlement date is earlier than effective transfer settlement date: %s",
242-
effectiveTransfers.get(0).getSettlementDate()));
251+
effectiveTransfers.getFirst().getSettlementDate()));
243252
}
244253

245-
return effectiveTransfers.get(0);
254+
return effectiveTransfers.getFirst();
246255
}
247256

248257
private ExternalAssetOwnerTransfer fetchAndValidateEffectiveTransferForBuybackWithDelayedSettlement(
@@ -265,17 +274,17 @@ private ExternalAssetOwnerTransfer fetchAndValidateEffectiveTransferForBuybackWi
265274
|| Set.of(ExternalTransferStatus.ACTIVE, ExternalTransferStatus.BUYBACK).equals(effectiveTransferStatuses)) {
266275
throw new ExternalAssetOwnerInitiateTransferException(
267276
"This loan cannot be bought back, external asset owner buyback transfer is already in progress");
268-
} else if (!BUYBACK_READY_STATUSES_FOR_DELAY_SETTLEMENT.contains(effectiveTransfers.get(0).getStatus())) {
277+
} else if (!BUYBACK_READY_STATUSES_FOR_DELAY_SETTLEMENT.contains(effectiveTransfers.getFirst().getStatus())) {
269278
throw new ExternalAssetOwnerInitiateTransferException(
270279
String.format("This loan cannot be bought back, effective transfer is not in right state: %s",
271-
effectiveTransfers.get(0).getStatus()));
272-
} else if (DateUtils.isBefore(settlementDate, effectiveTransfers.get(0).getSettlementDate())) {
280+
effectiveTransfers.getFirst().getStatus()));
281+
} else if (DateUtils.isBefore(settlementDate, effectiveTransfers.getFirst().getSettlementDate())) {
273282
throw new ExternalAssetOwnerInitiateTransferException(
274283
String.format("This loan cannot be bought back, settlement date is earlier than effective transfer settlement date: %s",
275-
effectiveTransfers.get(0).getSettlementDate()));
284+
effectiveTransfers.getFirst().getSettlementDate()));
276285
}
277286

278-
return effectiveTransfers.get(0);
287+
return effectiveTransfers.getFirst();
279288
}
280289

281290
private ExternalAssetOwnerTransfer fetchAndValidateEffectiveTransferForCancel(final Long transferId) {
@@ -287,10 +296,9 @@ private ExternalAssetOwnerTransfer fetchAndValidateEffectiveTransferForCancel(fi
287296
.findEffectiveTransfersOrderByIdDesc(selectedTransfer.getLoanId(), DateUtils.getBusinessLocalDate());
288297
if (effective.isEmpty()) {
289298
throw new ExternalAssetOwnerInitiateTransferException(
290-
String.format("This loan cannot be cancelled, there is no effective transfer for this loan"));
291-
} else if (!Objects.equals(effective.get(0).getId(), selectedTransfer.getId())) {
292-
throw new ExternalAssetOwnerInitiateTransferException(
293-
String.format("This loan cannot be cancelled, selected transfer is not the latest"));
299+
"This loan cannot be cancelled, there is no effective transfer for this loan");
300+
} else if (!Objects.equals(effective.getFirst().getId(), selectedTransfer.getId())) {
301+
throw new ExternalAssetOwnerInitiateTransferException("This loan cannot be cancelled, selected transfer is not the latest");
294302
} else if (selectedTransfer.getStatus() != PENDING && selectedTransfer.getStatus() != ExternalTransferStatus.BUYBACK) {
295303
throw new ExternalAssetOwnerInitiateTransferException(
296304
"This loan cannot be cancelled, the selected transfer status is not pending or buyback");
@@ -318,8 +326,7 @@ private ExternalAssetOwnerTransfer createBuybackTransfer(ExternalAssetOwnerTrans
318326

319327
private ExternalTransferStatus determineStatusAfterBuyback(ExternalAssetOwnerTransfer effectiveTransfer) {
320328
return switch (effectiveTransfer.getStatus()) {
321-
case PENDING -> ExternalTransferStatus.BUYBACK;
322-
case ACTIVE -> ExternalTransferStatus.BUYBACK;
329+
case PENDING, ACTIVE -> ExternalTransferStatus.BUYBACK;
323330
case ACTIVE_INTERMEDIATE -> ExternalTransferStatus.BUYBACK_INTERMEDIATE;
324331
default -> throw new ExternalAssetOwnerInitiateTransferException(String.format(
325332
"This loan cannot be bought back, effective transfer is not in right state: %s", effectiveTransfer.getStatus()));
@@ -582,17 +589,33 @@ private String getPurchasePriceRatioFromJson(JsonElement json) {
582589
return fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.PURCHASE_PRICE_RATIO, json);
583590
}
584591

585-
private ExternalAssetOwner getOwner(JsonElement json) {
586-
String ownerExternalId = fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.OWNER_EXTERNAL_ID, json);
587-
Optional<ExternalAssetOwner> byExternalId = externalAssetOwnerRepository
588-
.findByExternalId(ExternalIdFactory.produce(ownerExternalId));
589-
return byExternalId.orElseGet(() -> createAndGetAssetOwner(ownerExternalId));
592+
private ExternalAssetOwner getOwner(final JsonElement json) {
593+
final String ownerExternalId = fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.OWNER_EXTERNAL_ID, json);
594+
final ExternalId externalId = ExternalIdFactory.produce(ownerExternalId);
595+
return externalAssetOwnerRepository.findByExternalId(externalId).orElseGet(() -> {
596+
final Long ownerId = findOrCreateOwnerId(externalId);
597+
// getReferenceById returns a lazy proxy without hitting the DB. findById would fail
598+
// here because the outer transaction's persistence context does not contain the entity
599+
// committed by the inner REQUIRES_NEW transaction.
600+
return externalAssetOwnerRepository.getReferenceById(ownerId);
601+
});
602+
}
603+
604+
private Long findOrCreateOwnerId(final ExternalId externalId) {
605+
try {
606+
return externalAssetOwnerTransactionalHelper.findOrCreateId(externalId);
607+
} catch (JpaSystemException | DataIntegrityViolationException e) {
608+
if (!isConstraintViolation(e)) {
609+
throw e;
610+
}
611+
// Another thread created the owner concurrently - retry
612+
return externalAssetOwnerTransactionalHelper.findOrCreateId(externalId);
613+
}
590614
}
591615

592-
private ExternalAssetOwner createAndGetAssetOwner(String externalId) {
593-
ExternalAssetOwner externalAssetOwner = new ExternalAssetOwner();
594-
externalAssetOwner.setExternalId(ExternalIdFactory.produce(externalId));
595-
return externalAssetOwnerRepository.saveAndFlush(externalAssetOwner);
616+
private boolean isConstraintViolation(final DataAccessException e) {
617+
return e.getMostSpecificCause() instanceof SQLException sqlEx && sqlEx.getSQLState() != null
618+
&& sqlEx.getSQLState().startsWith(SQL_STATE_INTEGRITY_CONSTRAINT_VIOLATION);
596619
}
597620

598621
private List<LoanStatus> getAllowedLoanStatuses() {

0 commit comments

Comments
 (0)