Skip to content

Commit d469aaa

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

5 files changed

Lines changed: 221 additions & 59 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: 55 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,10 @@ public class SavingsSchedularInterestPoster {
6767
public void postInterest() throws JobExecutionException {
6868
if (!savingAccounts.isEmpty()) {
6969
List<Throwable> errors = new ArrayList<>();
70-
LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1);
7170
for (SavingsAccountData savingsAccountData : savingAccounts) {
7271
boolean postInterestAsOn = false;
7372
LocalDate transactionDate = null;
7473
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-
}
8074
SavingsAccountData savingsAccountDataRet = savingsAccountWritePlatformService.postInterest(savingsAccountData,
8175
postInterestAsOn, transactionDate, backdatedTxnsAllowedTill);
8276
savingsAccountDataList.add(savingsAccountDataRet);
@@ -168,15 +162,18 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList)
168162
String queryForSavingsUpdate = batchQueryForSavingsSummaryUpdate();
169163
String queryForTransactionInsertion = batchQueryForTransactionInsertion();
170164
String queryForTransactionUpdate = batchQueryForTransactionsUpdate();
171-
List<Object[]> paramsForTransactionInsertion = new ArrayList<>();
172-
List<Object[]> paramsForSavingsSummary = new ArrayList<>();
173-
List<Object[]> paramsForTransactionUpdate = new ArrayList<>();
174-
List<String> transRefNo = new ArrayList<>();
175165
LocalDate currentDate = DateUtils.getBusinessLocalDate();
176166
Long userId = platformSecurityContext.authenticatedUser().getId();
167+
168+
List<Object[]> paramsForSavingsSummary = new ArrayList<>();
169+
List<List<String>> perAccountRefNos = new ArrayList<>();
170+
List<List<Object[]>> perAccountInsertionParams = new ArrayList<>();
171+
List<List<Object[]>> perAccountUpdateParams = new ArrayList<>();
172+
177173
for (SavingsAccountData savingsAccountData : savingsAccountDataList) {
178174
OffsetDateTime auditTime = DateUtils.getAuditOffsetDateTime();
179175
SavingsAccountSummaryData savingsAccountSummaryData = savingsAccountData.getSummary();
176+
180177
paramsForSavingsSummary.add(new Object[] { savingsAccountSummaryData.getTotalDeposits(),
181178
savingsAccountSummaryData.getTotalWithdrawals(), savingsAccountSummaryData.getTotalInterestEarned(),
182179
savingsAccountSummaryData.getTotalInterestPosted(), savingsAccountSummaryData.getTotalWithdrawalFees(),
@@ -186,14 +183,19 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList)
186183
savingsAccountSummaryData.getLastInterestCalculationDate(),
187184
savingsAccountSummaryData.getInterestPostedTillDate() != null ? savingsAccountSummaryData.getInterestPostedTillDate()
188185
: savingsAccountSummaryData.getLastInterestCalculationDate(),
189-
auditTime, userId, savingsAccountData.getId() });
186+
auditTime, userId, savingsAccountData.getId(), savingsAccountData.getVersion() });
187+
188+
List<String> transRefNo = new ArrayList<>();
189+
List<Object[]> insertionParams = new ArrayList<>();
190+
List<Object[]> updateParams = new ArrayList<>();
191+
190192
List<SavingsAccountTransactionData> savingsAccountTransactionDataList = savingsAccountData.getSavingsAccountTransactionData();
191193
for (SavingsAccountTransactionData savingsAccountTransactionData : savingsAccountTransactionDataList) {
192194
if (savingsAccountTransactionData.getId() == null && !MathUtil.isZero(savingsAccountTransactionData.getAmount())) {
193195
UUID uuid = UUID.randomUUID();
194196
savingsAccountTransactionData.setRefNo(uuid.toString());
195197
transRefNo.add(uuid.toString());
196-
paramsForTransactionInsertion.add(new Object[] { savingsAccountData.getId(), savingsAccountData.getOfficeId(),
198+
insertionParams.add(new Object[] { savingsAccountData.getId(), savingsAccountData.getOfficeId(),
197199
savingsAccountTransactionData.isReversed(), savingsAccountTransactionData.getTransactionType().getId(),
198200
savingsAccountTransactionData.getTransactionDate(), savingsAccountTransactionData.getAmount(),
199201
savingsAccountTransactionData.getBalanceEndDate(), savingsAccountTransactionData.getBalanceNumberOfDays(),
@@ -202,35 +204,58 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList)
202204
savingsAccountTransactionData.getRefNo(), savingsAccountTransactionData.isReversalTransaction(),
203205
savingsAccountTransactionData.getOverdraftAmount(), currentDate });
204206
} else {
205-
paramsForTransactionUpdate.add(new Object[] { savingsAccountTransactionData.isReversed(),
206-
savingsAccountTransactionData.getAmount(), savingsAccountTransactionData.getOverdraftAmount(),
207-
savingsAccountTransactionData.getBalanceEndDate(), savingsAccountTransactionData.getBalanceNumberOfDays(),
208-
savingsAccountTransactionData.getRunningBalance(), savingsAccountTransactionData.getCumulativeBalance(),
209-
savingsAccountTransactionData.isReversalTransaction(), auditTime, userId,
210-
savingsAccountTransactionData.getId() });
207+
updateParams.add(new Object[] { savingsAccountTransactionData.isReversed(), savingsAccountTransactionData.getAmount(),
208+
savingsAccountTransactionData.getOverdraftAmount(), savingsAccountTransactionData.getBalanceEndDate(),
209+
savingsAccountTransactionData.getBalanceNumberOfDays(), savingsAccountTransactionData.getRunningBalance(),
210+
savingsAccountTransactionData.getCumulativeBalance(), savingsAccountTransactionData.isReversalTransaction(),
211+
auditTime, userId, savingsAccountTransactionData.getId() });
211212
}
212213
}
213214
savingsAccountData.setUpdatedTransactions(savingsAccountTransactionDataList);
215+
216+
perAccountRefNos.add(transRefNo);
217+
perAccountInsertionParams.add(insertionParams);
218+
perAccountUpdateParams.add(updateParams);
214219
}
215220

216-
if (transRefNo.size() > 0) {
217-
this.jdbcTemplate.batchUpdate(queryForSavingsUpdate, paramsForSavingsSummary);
218-
this.jdbcTemplate.batchUpdate(queryForTransactionInsertion, paramsForTransactionInsertion);
219-
this.jdbcTemplate.batchUpdate(queryForTransactionUpdate, paramsForTransactionUpdate);
220-
log.debug("`Total No Of Interest Posting:` {}", transRefNo.size());
221-
List<SavingsAccountTransactionData> savingsAccountTransactionDataList = fetchTransactionsFromIds(transRefNo);
222-
if (savingsAccountDataList != null) {
223-
log.debug("Fetched Transactions from DB: {}", savingsAccountTransactionDataList.size());
221+
boolean anyNewTransactions = perAccountRefNos.stream().anyMatch(list -> !list.isEmpty());
222+
if (!anyNewTransactions) {
223+
return;
224+
}
225+
226+
int[] updateCounts = this.jdbcTemplate.batchUpdate(queryForSavingsUpdate, paramsForSavingsSummary);
227+
228+
List<String> allTransRefNo = new ArrayList<>();
229+
List<Object[]> allInsertionParams = new ArrayList<>();
230+
List<Object[]> allUpdateParams = new ArrayList<>();
231+
List<SavingsAccountData> successfulAccounts = new ArrayList<>();
232+
233+
for (int i = 0; i < updateCounts.length; i++) {
234+
if (updateCounts[i] == 0) {
235+
log.warn("Optimistic lock failure for savings account id={} — concurrent modification detected."
236+
+ " Skipping. Will retry on next run.", savingsAccountDataList.get(i).getId());
237+
continue;
224238
}
239+
allTransRefNo.addAll(perAccountRefNos.get(i));
240+
allInsertionParams.addAll(perAccountInsertionParams.get(i));
241+
allUpdateParams.addAll(perAccountUpdateParams.get(i));
242+
successfulAccounts.add(savingsAccountDataList.get(i));
243+
}
244+
245+
if (!allTransRefNo.isEmpty()) {
246+
this.jdbcTemplate.batchUpdate(queryForTransactionInsertion, allInsertionParams);
247+
this.jdbcTemplate.batchUpdate(queryForTransactionUpdate, allUpdateParams);
248+
log.debug("`Total No Of Interest Posting:` {}", allTransRefNo.size());
249+
List<SavingsAccountTransactionData> fetchedTransactions = fetchTransactionsFromIds(allTransRefNo);
250+
log.debug("Fetched Transactions from DB: {}", fetchedTransactions.size());
225251

226252
HashMap<String, SavingsAccountTransactionData> savingsAccountTransactionMap = new HashMap<>();
227-
for (SavingsAccountTransactionData savingsAccountTransactionData : savingsAccountTransactionDataList) {
253+
for (SavingsAccountTransactionData savingsAccountTransactionData : fetchedTransactions) {
228254
final String key = savingsAccountTransactionData.getRefNo();
229255
savingsAccountTransactionMap.put(key, savingsAccountTransactionData);
230256
}
231-
batchUpdateJournalEntries(savingsAccountDataList, savingsAccountTransactionMap);
257+
batchUpdateJournalEntries(successfulAccounts, savingsAccountTransactionMap);
232258
}
233-
234259
}
235260

236261
private String batchQueryForTransactionInsertion() {
@@ -245,20 +270,12 @@ private String batchQueryForSavingsSummaryUpdate() {
245270
return "update m_savings_account set total_deposits_derived=?, total_withdrawals_derived=?, total_interest_earned_derived=?, total_interest_posted_derived=?, total_withdrawal_fees_derived=?, "
246271
+ "total_fees_charge_derived=?, total_penalty_charge_derived=?, total_annual_fees_derived=?, account_balance_derived=?, total_overdraft_interest_derived=?, total_withhold_tax_derived=?, "
247272
+ "last_interest_calculation_date=?, interest_posted_till_date=?, " + LAST_MODIFIED_DATE_DB_FIELD + " = ?, "
248-
+ LAST_MODIFIED_BY_DB_FIELD + " = ? WHERE id=? ";
273+
+ LAST_MODIFIED_BY_DB_FIELD + " = ?, version = version + 1 WHERE id=? AND version=?";
249274
}
250275

251276
private String batchQueryForTransactionsUpdate() {
252277
return "UPDATE m_savings_account_transaction "
253278
+ "SET is_reversed=?, amount=?, overdraft_amount_derived=?, balance_end_date_derived=?, balance_number_of_days_derived=?, running_balance_derived=?, cumulative_balance_derived=?, is_reversal=?, "
254279
+ LAST_MODIFIED_DATE_DB_FIELD + " = ?, " + LAST_MODIFIED_BY_DB_FIELD + " = ? " + "WHERE id=?";
255280
}
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-
}
264281
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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.ArrayList;
25+
import java.util.HashSet;
26+
import java.util.List;
27+
import java.util.Set;
28+
import org.junit.jupiter.api.Test;
29+
30+
class SavingsSchedularInterestPosterTest {
31+
32+
@Test
33+
void testUpdateCountsZeroMeansVersionMismatch() {
34+
int[] updateCounts = { 1, 0, 1 };
35+
Set<Long> skippedAccountIds = new HashSet<>();
36+
List<Long> accountIds = List.of(1L, 2L, 3L);
37+
38+
for (int i = 0; i < updateCounts.length; i++) {
39+
if (updateCounts[i] == 0) {
40+
skippedAccountIds.add(accountIds.get(i));
41+
}
42+
}
43+
44+
assertEquals(1, skippedAccountIds.size(), "Exactly one account should be skipped");
45+
assertTrue(skippedAccountIds.contains(2L), "Account 2 should be skipped due to version mismatch");
46+
}
47+
48+
@Test
49+
void testAllVersionsMatchNoSkippedAccounts() {
50+
int[] updateCounts = { 1, 1, 1 };
51+
Set<Long> skippedAccountIds = new HashSet<>();
52+
List<Long> accountIds = List.of(1L, 2L, 3L);
53+
54+
for (int i = 0; i < updateCounts.length; i++) {
55+
if (updateCounts[i] == 0) {
56+
skippedAccountIds.add(accountIds.get(i));
57+
}
58+
}
59+
60+
assertTrue(skippedAccountIds.isEmpty(), "No accounts should be skipped when all versions match");
61+
}
62+
63+
@Test
64+
void testAllVersionsMismatchAllSkipped() {
65+
int[] updateCounts = { 0, 0, 0 };
66+
Set<Long> skippedAccountIds = new HashSet<>();
67+
List<Long> accountIds = List.of(1L, 2L, 3L);
68+
69+
for (int i = 0; i < updateCounts.length; i++) {
70+
if (updateCounts[i] == 0) {
71+
skippedAccountIds.add(accountIds.get(i));
72+
}
73+
}
74+
75+
assertEquals(3, skippedAccountIds.size(), "All 3 accounts should be detected as version mismatched");
76+
assertTrue(skippedAccountIds.containsAll(List.of(1L, 2L, 3L)), "All account IDs should be in skipped set");
77+
}
78+
79+
@Test
80+
void testVersionMismatchSkipsFailedAccountAndProceedsWithOthers() {
81+
int[] updateCounts = { 1, 0, 1 };
82+
List<Long> accountIds = List.of(1L, 2L, 3L);
83+
List<Long> successfulIds = new ArrayList<>();
84+
85+
for (int i = 0; i < updateCounts.length; i++) {
86+
if (updateCounts[i] == 0) {
87+
// account is skipped due to concurrent modification — logged, not thrown
88+
} else {
89+
successfulIds.add(accountIds.get(i));
90+
}
91+
}
92+
93+
assertEquals(2, successfulIds.size(), "Two accounts should proceed normally");
94+
assertTrue(successfulIds.containsAll(List.of(1L, 3L)), "Accounts 1 and 3 should succeed independently");
95+
}
96+
}

0 commit comments

Comments
 (0)