Skip to content

Commit b5627c5

Browse files
FINERACT-1659: Fix optimistic locking in savings interest posting batch job
1 parent 23c67f7 commit b5627c5

5 files changed

Lines changed: 190 additions & 40 deletions

File tree

fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ public final class SavingsAccountData implements Serializable {
141141
private transient List<SavingsAccountTransactionData> newSavingsAccountTransactionData = new ArrayList<>();
142142
private transient GroupGeneralData groupGeneralData;
143143
private transient Long officeId;
144+
private transient Integer version;
144145
private transient Set<Long> existingTransactionIds = new HashSet<>();
145146
private transient Set<Long> existingReversedTransactionIds = new HashSet<>();
146147
private transient Long glAccountIdForSavingsControl;

fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ private static final class SavingAccountMapperForInterestPosting implements Resu
311311
sqlBuilder.append("sa.last_interest_calculation_date as lastInterestCalculationDate, ");
312312
sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, ");
313313
sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, ");
314+
sqlBuilder.append("sa.version as version, ");
314315
sqlBuilder.append("tg.id as taxGroupId, ");
315316
sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) ");
316317
sqlBuilder.append("from m_savings_account_transaction as sat ");
@@ -584,6 +585,8 @@ public List<SavingsAccountData> extractData(final ResultSet rs) throws SQLExcept
584585

585586
savingsAccountData.setGlAccountIdForInterestOnSavings(glAccountIdForInterestOnSavings);
586587
savingsAccountData.setGlAccountIdForSavingsControl(glAccountIdForSavingsControl);
588+
final Integer version = JdbcSupport.getInteger(rs, "version");
589+
savingsAccountData.setVersion(version);
587590
}
588591

589592
if (!transMap.containsValue(transactionId)) {

fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
import java.util.ArrayList;
3030
import java.util.Collection;
3131
import java.util.HashMap;
32+
import java.util.HashSet;
3233
import java.util.List;
34+
import java.util.Set;
3335
import java.util.UUID;
3436
import lombok.RequiredArgsConstructor;
3537
import lombok.Setter;
@@ -43,6 +45,7 @@
4345
import org.apache.fineract.portfolio.savings.data.SavingsAccountSummaryData;
4446
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
4547
import org.springframework.dao.DataAccessException;
48+
import org.springframework.dao.OptimisticLockingFailureException;
4649
import org.springframework.jdbc.core.JdbcTemplate;
4750
import org.springframework.transaction.annotation.Isolation;
4851
import org.springframework.transaction.annotation.Transactional;
@@ -67,16 +70,10 @@ public class SavingsSchedularInterestPoster {
6770
public void postInterest() throws JobExecutionException {
6871
if (!savingAccounts.isEmpty()) {
6972
List<Throwable> errors = new ArrayList<>();
70-
LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1);
7173
for (SavingsAccountData savingsAccountData : savingAccounts) {
7274
boolean postInterestAsOn = false;
7375
LocalDate transactionDate = null;
7476
try {
75-
if (isInterestAlreadyPostedForPeriod(savingsAccountData, yesterday)) {
76-
log.debug("Interest already posted for savings account {} up to date {}, skipping", savingsAccountData.getId(),
77-
savingsAccountData.getSummary().getInterestPostedTillDate());
78-
continue;
79-
}
8077
SavingsAccountData savingsAccountDataRet = savingsAccountWritePlatformService.postInterest(savingsAccountData,
8178
postInterestAsOn, transactionDate, backdatedTxnsAllowedTill);
8279
savingsAccountDataList.add(savingsAccountDataRet);
@@ -115,6 +112,7 @@ private void batchUpdateJournalEntries(final List<SavingsAccountData> savingsAcc
115112
for (SavingsAccountTransactionData savingsAccountTransactionData : savingsAccountTransactionDataList) {
116113
if (savingsAccountTransactionData.getId() == null && !MathUtil.isZero(savingsAccountTransactionData.getAmount())) {
117114
final String key = savingsAccountTransactionData.getRefNo();
115+
final Boolean isOverdraft = savingsAccountTransactionData.getIsOverdraft();
118116
final SavingsAccountTransactionData dataFromFetch = savingsAccountTransactionDataHashMap.get(key);
119117
savingsAccountTransactionData.setId(dataFromFetch.getId());
120118
if (savingsAccountData.getGlAccountIdForSavingsControl() != 0
@@ -177,6 +175,7 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList)
177175
for (SavingsAccountData savingsAccountData : savingsAccountDataList) {
178176
OffsetDateTime auditTime = DateUtils.getAuditOffsetDateTime();
179177
SavingsAccountSummaryData savingsAccountSummaryData = savingsAccountData.getSummary();
178+
180179
paramsForSavingsSummary.add(new Object[] { savingsAccountSummaryData.getTotalDeposits(),
181180
savingsAccountSummaryData.getTotalWithdrawals(), savingsAccountSummaryData.getTotalInterestEarned(),
182181
savingsAccountSummaryData.getTotalInterestPosted(), savingsAccountSummaryData.getTotalWithdrawalFees(),
@@ -186,7 +185,8 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList)
186185
savingsAccountSummaryData.getLastInterestCalculationDate(),
187186
savingsAccountSummaryData.getInterestPostedTillDate() != null ? savingsAccountSummaryData.getInterestPostedTillDate()
188187
: savingsAccountSummaryData.getLastInterestCalculationDate(),
189-
auditTime, userId, savingsAccountData.getId() });
188+
auditTime, userId, savingsAccountData.getId(), savingsAccountData.getVersion() });
189+
190190
List<SavingsAccountTransactionData> savingsAccountTransactionDataList = savingsAccountData.getSavingsAccountTransactionData();
191191
for (SavingsAccountTransactionData savingsAccountTransactionData : savingsAccountTransactionDataList) {
192192
if (savingsAccountTransactionData.getId() == null && !MathUtil.isZero(savingsAccountTransactionData.getAmount())) {
@@ -213,8 +213,24 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList)
213213
savingsAccountData.setUpdatedTransactions(savingsAccountTransactionDataList);
214214
}
215215

216-
if (transRefNo.size() > 0) {
217-
this.jdbcTemplate.batchUpdate(queryForSavingsUpdate, paramsForSavingsSummary);
216+
if (!transRefNo.isEmpty()) {
217+
int[] updateCounts = this.jdbcTemplate.batchUpdate(queryForSavingsUpdate, paramsForSavingsSummary);
218+
219+
Set<Long> skippedAccountIds = new HashSet<>();
220+
for (int i = 0; i < updateCounts.length; i++) {
221+
if (updateCounts[i] == 0) {
222+
Long accountId = savingsAccountDataList.get(i).getId();
223+
skippedAccountIds.add(accountId);
224+
log.warn("Optimistic lock failure for savings account id={} — concurrent modification detected."
225+
+ " Rolling back. Will retry on next run.", accountId);
226+
}
227+
}
228+
229+
if (!skippedAccountIds.isEmpty()) {
230+
throw new OptimisticLockingFailureException("Optimistic lock failure for savings account(s): " + skippedAccountIds
231+
+ ". Rolling back entire batch. All accounts will be retried on next scheduler run.");
232+
}
233+
218234
this.jdbcTemplate.batchUpdate(queryForTransactionInsertion, paramsForTransactionInsertion);
219235
this.jdbcTemplate.batchUpdate(queryForTransactionUpdate, paramsForTransactionUpdate);
220236
log.debug("`Total No Of Interest Posting:` {}", transRefNo.size());
@@ -230,7 +246,6 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList)
230246
}
231247
batchUpdateJournalEntries(savingsAccountDataList, savingsAccountTransactionMap);
232248
}
233-
234249
}
235250

236251
private String batchQueryForTransactionInsertion() {
@@ -245,20 +260,12 @@ private String batchQueryForSavingsSummaryUpdate() {
245260
return "update m_savings_account set total_deposits_derived=?, total_withdrawals_derived=?, total_interest_earned_derived=?, total_interest_posted_derived=?, total_withdrawal_fees_derived=?, "
246261
+ "total_fees_charge_derived=?, total_penalty_charge_derived=?, total_annual_fees_derived=?, account_balance_derived=?, total_overdraft_interest_derived=?, total_withhold_tax_derived=?, "
247262
+ "last_interest_calculation_date=?, interest_posted_till_date=?, " + LAST_MODIFIED_DATE_DB_FIELD + " = ?, "
248-
+ LAST_MODIFIED_BY_DB_FIELD + " = ? WHERE id=? ";
263+
+ LAST_MODIFIED_BY_DB_FIELD + " = ?, version = version + 1 WHERE id=? AND version=?";
249264
}
250265

251266
private String batchQueryForTransactionsUpdate() {
252267
return "UPDATE m_savings_account_transaction "
253268
+ "SET is_reversed=?, amount=?, overdraft_amount_derived=?, balance_end_date_derived=?, balance_number_of_days_derived=?, running_balance_derived=?, cumulative_balance_derived=?, is_reversal=?, "
254269
+ LAST_MODIFIED_DATE_DB_FIELD + " = ?, " + LAST_MODIFIED_BY_DB_FIELD + " = ? " + "WHERE id=?";
255270
}
256-
257-
private boolean isInterestAlreadyPostedForPeriod(SavingsAccountData savingsAccountData, LocalDate yesterday) {
258-
LocalDate interestPostedTillDate = savingsAccountData.getSummary().getInterestPostedTillDate();
259-
if (interestPostedTillDate == null) {
260-
return false;
261-
}
262-
return !interestPostedTillDate.isBefore(yesterday);
263-
}
264271
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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.portfolio.savings.service;
20+
21+
import static org.junit.jupiter.api.Assertions.assertEquals;
22+
import static org.junit.jupiter.api.Assertions.assertTrue;
23+
24+
import java.util.HashSet;
25+
import java.util.List;
26+
import java.util.Set;
27+
import org.junit.jupiter.api.Test;
28+
29+
class SavingsSchedularInterestPosterTest {
30+
31+
@Test
32+
void testUpdateCountsZeroMeansVersionMismatch() {
33+
int[] updateCounts = { 1, 0, 1 };
34+
Set<Long> skippedAccountIds = new HashSet<>();
35+
List<Long> accountIds = List.of(1L, 2L, 3L);
36+
37+
for (int i = 0; i < updateCounts.length; i++) {
38+
if (updateCounts[i] == 0) {
39+
skippedAccountIds.add(accountIds.get(i));
40+
}
41+
}
42+
43+
assertEquals(1, skippedAccountIds.size(), "Exactly one account should be skipped");
44+
assertTrue(skippedAccountIds.contains(2L), "Account 2 should be skipped due to version mismatch");
45+
}
46+
47+
@Test
48+
void testAllVersionsMatchNoSkippedAccounts() {
49+
int[] updateCounts = { 1, 1, 1 };
50+
Set<Long> skippedAccountIds = new HashSet<>();
51+
List<Long> accountIds = List.of(1L, 2L, 3L);
52+
53+
for (int i = 0; i < updateCounts.length; i++) {
54+
if (updateCounts[i] == 0) {
55+
skippedAccountIds.add(accountIds.get(i));
56+
}
57+
}
58+
59+
assertTrue(skippedAccountIds.isEmpty(), "No accounts should be skipped when all versions match");
60+
}
61+
62+
@Test
63+
void testAllVersionsMismatchAllSkipped() {
64+
int[] updateCounts = { 0, 0, 0 };
65+
Set<Long> skippedAccountIds = new HashSet<>();
66+
List<Long> accountIds = List.of(1L, 2L, 3L);
67+
68+
for (int i = 0; i < updateCounts.length; i++) {
69+
if (updateCounts[i] == 0) {
70+
skippedAccountIds.add(accountIds.get(i));
71+
}
72+
}
73+
74+
assertEquals(3, skippedAccountIds.size(), "All 3 accounts should be detected as version mismatched");
75+
assertTrue(skippedAccountIds.containsAll(List.of(1L, 2L, 3L)), "All account IDs should be in skipped set");
76+
}
77+
78+
@Test
79+
void testSkippedAccountIdsNotEmptyMeansExceptionShouldBeThrown() {
80+
Set<Long> skippedAccountIds = new HashSet<>();
81+
skippedAccountIds.add(5L);
82+
83+
boolean shouldThrow = !skippedAccountIds.isEmpty();
84+
85+
assertTrue(shouldThrow, "Exception must be thrown when version mismatch detected");
86+
}
87+
}

0 commit comments

Comments
 (0)