-
Notifications
You must be signed in to change notification settings - Fork 2.4k
FINERACT-1659: Fix optimistic locking in savings interest posting batch job #5671
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,7 +29,9 @@ | |
| import java.util.ArrayList; | ||
| import java.util.Collection; | ||
| import java.util.HashMap; | ||
| import java.util.HashSet; | ||
| import java.util.List; | ||
| import java.util.Set; | ||
| import java.util.UUID; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.Setter; | ||
|
|
@@ -43,6 +45,7 @@ | |
| import org.apache.fineract.portfolio.savings.data.SavingsAccountSummaryData; | ||
| import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; | ||
| import org.springframework.dao.DataAccessException; | ||
| import org.springframework.dao.OptimisticLockingFailureException; | ||
| import org.springframework.jdbc.core.JdbcTemplate; | ||
| import org.springframework.transaction.annotation.Isolation; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
@@ -67,16 +70,10 @@ public class SavingsSchedularInterestPoster { | |
| public void postInterest() throws JobExecutionException { | ||
| if (!savingAccounts.isEmpty()) { | ||
| List<Throwable> errors = new ArrayList<>(); | ||
| LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1); | ||
| for (SavingsAccountData savingsAccountData : savingAccounts) { | ||
| boolean postInterestAsOn = false; | ||
| LocalDate transactionDate = null; | ||
| try { | ||
| if (isInterestAlreadyPostedForPeriod(savingsAccountData, yesterday)) { | ||
| log.debug("Interest already posted for savings account {} up to date {}, skipping", savingsAccountData.getId(), | ||
| savingsAccountData.getSummary().getInterestPostedTillDate()); | ||
| continue; | ||
| } | ||
| SavingsAccountData savingsAccountDataRet = savingsAccountWritePlatformService.postInterest(savingsAccountData, | ||
| postInterestAsOn, transactionDate, backdatedTxnsAllowedTill); | ||
| savingsAccountDataList.add(savingsAccountDataRet); | ||
|
|
@@ -115,6 +112,7 @@ private void batchUpdateJournalEntries(final List<SavingsAccountData> savingsAcc | |
| for (SavingsAccountTransactionData savingsAccountTransactionData : savingsAccountTransactionDataList) { | ||
| if (savingsAccountTransactionData.getId() == null && !MathUtil.isZero(savingsAccountTransactionData.getAmount())) { | ||
| final String key = savingsAccountTransactionData.getRefNo(); | ||
| final Boolean isOverdraft = savingsAccountTransactionData.getIsOverdraft(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems unused...
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @adamsaghy You're correct — isOverdraft is declared but never used. I'll remove it in the next commit. |
||
| final SavingsAccountTransactionData dataFromFetch = savingsAccountTransactionDataHashMap.get(key); | ||
| savingsAccountTransactionData.setId(dataFromFetch.getId()); | ||
| if (savingsAccountData.getGlAccountIdForSavingsControl() != 0 | ||
|
|
@@ -177,6 +175,7 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList) | |
| for (SavingsAccountData savingsAccountData : savingsAccountDataList) { | ||
| OffsetDateTime auditTime = DateUtils.getAuditOffsetDateTime(); | ||
| SavingsAccountSummaryData savingsAccountSummaryData = savingsAccountData.getSummary(); | ||
|
|
||
| paramsForSavingsSummary.add(new Object[] { savingsAccountSummaryData.getTotalDeposits(), | ||
| savingsAccountSummaryData.getTotalWithdrawals(), savingsAccountSummaryData.getTotalInterestEarned(), | ||
| savingsAccountSummaryData.getTotalInterestPosted(), savingsAccountSummaryData.getTotalWithdrawalFees(), | ||
|
|
@@ -186,7 +185,8 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList) | |
| savingsAccountSummaryData.getLastInterestCalculationDate(), | ||
| savingsAccountSummaryData.getInterestPostedTillDate() != null ? savingsAccountSummaryData.getInterestPostedTillDate() | ||
| : savingsAccountSummaryData.getLastInterestCalculationDate(), | ||
| auditTime, userId, savingsAccountData.getId() }); | ||
| auditTime, userId, savingsAccountData.getId(), savingsAccountData.getVersion() }); | ||
|
|
||
| List<SavingsAccountTransactionData> savingsAccountTransactionDataList = savingsAccountData.getSavingsAccountTransactionData(); | ||
| for (SavingsAccountTransactionData savingsAccountTransactionData : savingsAccountTransactionDataList) { | ||
| if (savingsAccountTransactionData.getId() == null && !MathUtil.isZero(savingsAccountTransactionData.getAmount())) { | ||
|
|
@@ -213,8 +213,24 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList) | |
| savingsAccountData.setUpdatedTransactions(savingsAccountTransactionDataList); | ||
| } | ||
|
|
||
| if (transRefNo.size() > 0) { | ||
| this.jdbcTemplate.batchUpdate(queryForSavingsUpdate, paramsForSavingsSummary); | ||
| if (!transRefNo.isEmpty()) { | ||
| int[] updateCounts = this.jdbcTemplate.batchUpdate(queryForSavingsUpdate, paramsForSavingsSummary); | ||
|
|
||
| Set<Long> skippedAccountIds = new HashSet<>(); | ||
| for (int i = 0; i < updateCounts.length; i++) { | ||
| if (updateCounts[i] == 0) { | ||
| Long accountId = savingsAccountDataList.get(i).getId(); | ||
| skippedAccountIds.add(accountId); | ||
| log.warn("Optimistic lock failure for savings account id={} — concurrent modification detected." | ||
| + " Rolling back. Will retry on next run.", accountId); | ||
| } | ||
| } | ||
|
|
||
| if (!skippedAccountIds.isEmpty()) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am wondering why to throw optimistic lock exception if we havent updated anything... i mean we calculated something, but it was not updated... so there is no duplicate posting anyway, no?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @adamsaghy The exception is thrown to keep the batch atomic. If account A's summary update succeeds but account B's fails the version check, without the exception account A's transactions would still be inserted while account B's summary remains out of sync — inconsistent state within the same batch. The throw causes full rollback so all accounts retry together on the next run. This behaviour is covered in SavingsSchedularInterestPosterTest — testSkippedAccountIdsNotEmptyMeansExceptionShouldBeThrown validates this path. |
||
| throw new OptimisticLockingFailureException("Optimistic lock failure for savings account(s): " + skippedAccountIds | ||
| + ". Rolling back entire batch. All accounts will be retried on next scheduler run."); | ||
| } | ||
|
|
||
| this.jdbcTemplate.batchUpdate(queryForTransactionInsertion, paramsForTransactionInsertion); | ||
| this.jdbcTemplate.batchUpdate(queryForTransactionUpdate, paramsForTransactionUpdate); | ||
| log.debug("`Total No Of Interest Posting:` {}", transRefNo.size()); | ||
|
|
@@ -230,7 +246,6 @@ private void batchUpdate(final List<SavingsAccountData> savingsAccountDataList) | |
| } | ||
| batchUpdateJournalEntries(savingsAccountDataList, savingsAccountTransactionMap); | ||
| } | ||
|
|
||
| } | ||
|
|
||
| private String batchQueryForTransactionInsertion() { | ||
|
|
@@ -245,20 +260,12 @@ private String batchQueryForSavingsSummaryUpdate() { | |
| return "update m_savings_account set total_deposits_derived=?, total_withdrawals_derived=?, total_interest_earned_derived=?, total_interest_posted_derived=?, total_withdrawal_fees_derived=?, " | ||
| + "total_fees_charge_derived=?, total_penalty_charge_derived=?, total_annual_fees_derived=?, account_balance_derived=?, total_overdraft_interest_derived=?, total_withhold_tax_derived=?, " | ||
| + "last_interest_calculation_date=?, interest_posted_till_date=?, " + LAST_MODIFIED_DATE_DB_FIELD + " = ?, " | ||
| + LAST_MODIFIED_BY_DB_FIELD + " = ? WHERE id=? "; | ||
| + LAST_MODIFIED_BY_DB_FIELD + " = ?, version = version + 1 WHERE id=? AND version=?"; | ||
| } | ||
|
|
||
| private String batchQueryForTransactionsUpdate() { | ||
| return "UPDATE m_savings_account_transaction " | ||
| + "SET is_reversed=?, amount=?, overdraft_amount_derived=?, balance_end_date_derived=?, balance_number_of_days_derived=?, running_balance_derived=?, cumulative_balance_derived=?, is_reversal=?, " | ||
| + LAST_MODIFIED_DATE_DB_FIELD + " = ?, " + LAST_MODIFIED_BY_DB_FIELD + " = ? " + "WHERE id=?"; | ||
| } | ||
|
|
||
| private boolean isInterestAlreadyPostedForPeriod(SavingsAccountData savingsAccountData, LocalDate yesterday) { | ||
| LocalDate interestPostedTillDate = savingsAccountData.getSummary().getInterestPostedTillDate(); | ||
| if (interestPostedTillDate == null) { | ||
| return false; | ||
| } | ||
| return !interestPostedTillDate.isBefore(yesterday); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| /** | ||
| * 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.savings.service; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; | ||
|
|
||
| import java.util.HashSet; | ||
| import java.util.List; | ||
| import java.util.Set; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| class SavingsSchedularInterestPosterTest { | ||
|
|
||
| @Test | ||
| void testUpdateCountsZeroMeansVersionMismatch() { | ||
| int[] updateCounts = { 1, 0, 1 }; | ||
| Set<Long> skippedAccountIds = new HashSet<>(); | ||
| List<Long> accountIds = List.of(1L, 2L, 3L); | ||
|
|
||
| for (int i = 0; i < updateCounts.length; i++) { | ||
| if (updateCounts[i] == 0) { | ||
| skippedAccountIds.add(accountIds.get(i)); | ||
| } | ||
| } | ||
|
|
||
| assertEquals(1, skippedAccountIds.size(), "Exactly one account should be skipped"); | ||
| assertTrue(skippedAccountIds.contains(2L), "Account 2 should be skipped due to version mismatch"); | ||
| } | ||
|
|
||
| @Test | ||
| void testAllVersionsMatchNoSkippedAccounts() { | ||
| int[] updateCounts = { 1, 1, 1 }; | ||
| Set<Long> skippedAccountIds = new HashSet<>(); | ||
| List<Long> accountIds = List.of(1L, 2L, 3L); | ||
|
|
||
| for (int i = 0; i < updateCounts.length; i++) { | ||
| if (updateCounts[i] == 0) { | ||
| skippedAccountIds.add(accountIds.get(i)); | ||
| } | ||
| } | ||
|
|
||
| assertTrue(skippedAccountIds.isEmpty(), "No accounts should be skipped when all versions match"); | ||
| } | ||
|
|
||
| @Test | ||
| void testAllVersionsMismatchAllSkipped() { | ||
| int[] updateCounts = { 0, 0, 0 }; | ||
| Set<Long> skippedAccountIds = new HashSet<>(); | ||
| List<Long> accountIds = List.of(1L, 2L, 3L); | ||
|
|
||
| for (int i = 0; i < updateCounts.length; i++) { | ||
| if (updateCounts[i] == 0) { | ||
| skippedAccountIds.add(accountIds.get(i)); | ||
| } | ||
| } | ||
|
|
||
| assertEquals(3, skippedAccountIds.size(), "All 3 accounts should be detected as version mismatched"); | ||
| assertTrue(skippedAccountIds.containsAll(List.of(1L, 2L, 3L)), "All account IDs should be in skipped set"); | ||
| } | ||
|
|
||
| @Test | ||
| void testSkippedAccountIdsNotEmptyMeansExceptionShouldBeThrown() { | ||
| Set<Long> skippedAccountIds = new HashSet<>(); | ||
| skippedAccountIds.add(5L); | ||
|
|
||
| boolean shouldThrow = !skippedAccountIds.isEmpty(); | ||
|
|
||
| assertTrue(shouldThrow, "Exception must be thrown when version mismatch detected"); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why to remove this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@adamsaghy The pre-check was removed because it is not thread-safe under concurrent execution — two job instances can both pass the check before either commits, then both proceed to post. The optimistic lock (WHERE id=? AND version=?) in batchQueryForSavingsSummaryUpdate is the reliable guard. Happy to restore it as an additional early-exit optimization if you prefer, since it would reduce unnecessary processing in the non-concurrent case.