From 6d3e19cf25967cdc6e98d2e6705af80c00a968ab Mon Sep 17 00:00:00 2001 From: budaidev Date: Tue, 22 Apr 2025 21:50:35 +0200 Subject: [PATCH] FINERACT-2234: add extra caches to frequent database queries during the interest recalculation --- .../domain/ApplicationCurrencyRepository.java | 48 +++ .../domain/CalendarInstanceRepository.java | 48 ++- .../service/LoanScheduleAssembler.java | 2 +- .../LoanApplicationValidator.java | 2 +- .../LoanTransactionValidator.java | 2 +- .../loanaccount/service/LoanUtilService.java | 10 +- .../LoanCOBPerformanceRestTest.java | 280 ++++++++++++++++++ 7 files changed, 382 insertions(+), 10 deletions(-) create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanCOBPerformanceRestTest.java diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/ApplicationCurrencyRepository.java b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/ApplicationCurrencyRepository.java index 4c50c37d7..0128bd515 100644 --- a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/ApplicationCurrencyRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/ApplicationCurrencyRepository.java @@ -20,6 +20,9 @@ package org.apache.fineract.organisation.monetary.domain; import java.util.List; import org.apache.fineract.organisation.monetary.data.CurrencyData; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -28,16 +31,61 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository +@CacheConfig(cacheNames = "currencies") public interface ApplicationCurrencyRepository extends JpaRepository, JpaSpecificationExecutor { String FIND_CURRENCY_DETAILS = "SELECT new org.apache.fineract.organisation.monetary.data.CurrencyData(ac.code, ac.name, ac.decimalPlaces, ac.inMultiplesOf, ac.displaySymbol, ac.nameCode) FROM ApplicationCurrency ac "; + @Cacheable(key = "'entity_' + #currencyCode") ApplicationCurrency findOneByCode(String currencyCode); + @Cacheable(key = "'data_' + #currencyCode") @Query(FIND_CURRENCY_DETAILS + " WHERE ac.code = :code") CurrencyData findCurrencyDataByCode(@Param("code") String currencyCode); + @Cacheable @Query(FIND_CURRENCY_DETAILS) List findAllSorted(Sort sort); + + /** + * Override save method with cache eviction + */ + @Override + @CacheEvict(allEntries = true) + S save(S entity); + + /** + * Override saveAll method with cache eviction + */ + @Override + @CacheEvict(allEntries = true) + List saveAll(Iterable entities); + + /** + * Override delete methods with cache eviction + */ + @Override + @CacheEvict(allEntries = true) + void delete(ApplicationCurrency entity); + + @Override + @CacheEvict(allEntries = true) + void deleteAll(); + + @Override + @CacheEvict(allEntries = true) + void deleteAll(Iterable entities); + + @Override + @CacheEvict(allEntries = true) + void deleteById(Long id); + + @Override + @CacheEvict(allEntries = true) + void deleteAllById(Iterable ids); + + @Override + @CacheEvict(allEntries = true) + S saveAndFlush(S entity); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/calendar/domain/CalendarInstanceRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/calendar/domain/CalendarInstanceRepository.java index 2ec0fae95..1250c7509 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/calendar/domain/CalendarInstanceRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/calendar/domain/CalendarInstanceRepository.java @@ -25,15 +25,23 @@ import org.apache.fineract.portfolio.group.domain.Group; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +@Repository +@CacheConfig(cacheNames = "calendarInstances") public interface CalendarInstanceRepository extends JpaRepository, JpaSpecificationExecutor { + @Cacheable(key = "'calId_' + #calendarId + '_entityId_' + #entityId + '_entityTypeId_' + #entityTypeId") CalendarInstance findByCalendarIdAndEntityIdAndEntityTypeId(Long calendarId, Long entityId, Integer entityTypeId); + @Cacheable(key = "'entityId_' + #entityId + '_entityTypeId_' + #entityTypeId") Collection findByEntityIdAndEntityTypeId(Long entityId, Integer entityTypeId); /** @@ -45,14 +53,18 @@ public interface CalendarInstanceRepository extends JpaRepository findByCalendarIdAndEntityTypeId(Long calendarId, Integer entityTypeId); /** Should use in clause, can I do it without creating a new class? **/ + @Cacheable(key = "'groupId_' + #groupId + '_clientId_' + #clientId + '_statuses_' + T(org.springframework.util.StringUtils).collectionToCommaDelimitedString(#loanStatuses)") @Query("select ci from CalendarInstance ci where ci.entityId in (select loan.id from Loan loan where loan.client.id = :clientId and loan.group.id = :groupId and loan.loanStatus in :loanStatuses) and ci.entityTypeId = 3") List findCalendarInstancesForLoansByGroupIdAndClientIdAndStatuses(@Param("groupId") Long groupId, @Param("clientId") Long clientId, @Param("loanStatuses") Collection loanStatuses); @@ -60,9 +72,41 @@ public interface CalendarInstanceRepository extends JpaRepository loanStatuses); + // Override JpaRepository methods to add cache eviction + @Override + @CacheEvict(allEntries = true) + S save(S entity); + + @Override + @CacheEvict(allEntries = true) + List saveAll(Iterable entities); + + @Override + @CacheEvict(allEntries = true) + void delete(CalendarInstance entity); + + @Override + @CacheEvict(allEntries = true) + void deleteById(Long id); + + @Override + @CacheEvict(allEntries = true) + void deleteAll(); + + @Override + @CacheEvict(allEntries = true) + void deleteAll(Iterable entities); + + @Override + @CacheEvict(allEntries = true) + void deleteAllById(Iterable ids); + + @Override + @CacheEvict(allEntries = true) + S saveAndFlush(S entity); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java index 2db86adb9..07db84d81 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java @@ -916,7 +916,7 @@ public class LoanScheduleAssembler { overlappings); } LoanProductVariableInstallmentConfig installmentConfig = loan.loanProduct().loanProductVariableInstallmentConfig(); - final CalendarInstance loanCalendarInstance = calendarInstanceRepository.findCalendarInstaneByEntityId(loan.getId(), + final CalendarInstance loanCalendarInstance = calendarInstanceRepository.findCalendarInstanceByEntityId(loan.getId(), CalendarEntityType.LOANS.getValue()); Calendar loanCalendar = null; if (loanCalendarInstance != null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java index 9e74e10db..5ef430982 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java @@ -2164,7 +2164,7 @@ public final class LoanApplicationValidator { } private Calendar getCalendarInstance(Loan loan) { - CalendarInstance calendarInstance = calendarInstanceRepository.findCalendarInstaneByEntityId(loan.getId(), + CalendarInstance calendarInstance = calendarInstanceRepository.findCalendarInstanceByEntityId(loan.getId(), CalendarEntityType.LOANS.getValue()); return calendarInstance != null ? calendarInstance.getCalendar() : null; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidator.java index ac65ac45f..80c7a7a47 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidator.java @@ -195,7 +195,7 @@ public final class LoanTransactionValidator { StatusEnum.DISBURSE.getValue(), EntityTables.LOAN.getForeignKeyColumnNameOnDatatable(), loan.productId()); ScheduleGeneratorDTO scheduleGeneratorDTO = this.loanUtilService.buildScheduleGeneratorDTO(loan, null); - final CalendarInstance calendarInstance = this.calendarInstanceRepository.findCalendarInstaneByEntityId(loan.getId(), + final CalendarInstance calendarInstance = this.calendarInstanceRepository.findCalendarInstanceByEntityId(loan.getId(), CalendarEntityType.LOANS.getValue()); if (loan.isSyncDisbursementWithMeeting()) { validateDisbursementDateWithMeetingDate(actualDisbursementDate, calendarInstance, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanUtilService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanUtilService.java index 7a0c5f953..6f25497d2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanUtilService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanUtilService.java @@ -78,7 +78,7 @@ public class LoanUtilService { } final MonetaryCurrency currency = loan.getCurrency(); ApplicationCurrency applicationCurrency = this.applicationCurrencyRepository.findOneWithNotFoundDetection(currency); - final CalendarInstance calendarInstance = this.calendarInstanceRepository.findCalendarInstaneByEntityId(loan.getId(), + final CalendarInstance calendarInstance = this.calendarInstanceRepository.findCalendarInstanceByEntityId(loan.getId(), CalendarEntityType.LOANS.getValue()); Calendar calendar = null; CalendarHistoryDataWrapper calendarHistoryDataWrapper = null; @@ -93,10 +93,10 @@ public class LoanUtilService { CalendarInstance compoundingCalendarInstance = null; Long overdurPenaltyWaitPeriod = null; if (loan.isInterestBearingAndInterestRecalculationEnabled()) { - restCalendarInstance = calendarInstanceRepository.findCalendarInstaneByEntityId(loan.loanInterestRecalculationDetailId(), + restCalendarInstance = calendarInstanceRepository.findCalendarInstanceByEntityId(loan.loanInterestRecalculationDetailId(), CalendarEntityType.LOAN_RECALCULATION_REST_DETAIL.getValue()); - compoundingCalendarInstance = calendarInstanceRepository.findCalendarInstaneByEntityId(loan.loanInterestRecalculationDetailId(), - CalendarEntityType.LOAN_RECALCULATION_COMPOUNDING_DETAIL.getValue()); + compoundingCalendarInstance = calendarInstanceRepository.findCalendarInstanceByEntityId( + loan.loanInterestRecalculationDetailId(), CalendarEntityType.LOAN_RECALCULATION_COMPOUNDING_DETAIL.getValue()); overdurPenaltyWaitPeriod = this.configurationDomainService.retrievePenaltyWaitPeriod(); } final Boolean isInterestChargedFromDateAsDisbursementDateEnabled = this.configurationDomainService @@ -156,7 +156,7 @@ public class LoanUtilService { } public LocalDate getCalculatedRepaymentsStartingFromDate(final Loan loan) { - final CalendarInstance calendarInstance = this.calendarInstanceRepository.findCalendarInstaneByEntityId(loan.getId(), + final CalendarInstance calendarInstance = this.calendarInstanceRepository.findCalendarInstanceByEntityId(loan.getId(), CalendarEntityType.LOANS.getValue()); final CalendarHistoryDataWrapper calendarHistoryDataWrapper = null; return this.getCalculatedRepaymentsStartingFromDate(loan.getDisbursementDate(), loan, calendarInstance, calendarHistoryDataWrapper); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanCOBPerformanceRestTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanCOBPerformanceRestTest.java new file mode 100644 index 000000000..919e9f0f8 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanCOBPerformanceRestTest.java @@ -0,0 +1,280 @@ +/** + * 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.integrationtests; + +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.builder.ResponseSpecBuilder; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import io.restassured.specification.ResponseSpecification; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.fineract.client.models.PostClientsResponse; +import org.apache.fineract.client.models.PostLoanProductsResponse; +import org.apache.fineract.integrationtests.common.BusinessStepHelper; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.inlinecob.InlineLoanCOBHelper; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/*** + * This test class is designed to measure the performance of the Loan Close of Business (COB) process in Fineract. It + * creates a specified number of loans, runs the COB process, and measures the time taken for each step. The results are + * printed in a consolidated report at the end of all tests. + ***/ +@Disabled("This test is disabled by default. To run it, please remove the @Disabled annotation.") +@TestInstance(Lifecycle.PER_CLASS) +public class LoanCOBPerformanceRestTest extends BaseLoanIntegrationTest { + + private static final Logger LOG = LoggerFactory.getLogger(LoanCOBPerformanceRestTest.class); + + private static ResponseSpecification responseSpec; + private static RequestSpecification requestSpec; + private static PostClientsResponse client; + private static InlineLoanCOBHelper inlineLoanCOBHelper; + private static BusinessStepHelper businessStepHelper; + private Random random = new Random(); + + // Store metrics for all test runs + private Map> allTestMetrics = new LinkedHashMap<>(); + + @BeforeAll + public static void setup() { + Utils.initializeRESTAssured(); + requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + requestSpec.header("Fineract-Platform-TenantId", Utils.DEFAULT_TENANT); + responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); + ClientHelper clientHelper = new ClientHelper(requestSpec, responseSpec); + client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()); + inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec); + businessStepHelper = new BusinessStepHelper(); + // setup COB Business Steps to prevent test failing due other integration test configurations + businessStepHelper.updateSteps("LOAN_CLOSE_OF_BUSINESS", "APPLY_CHARGE_TO_OVERDUE_LOANS", "LOAN_DELINQUENCY_CLASSIFICATION", + "CHECK_LOAN_REPAYMENT_DUE", "CHECK_LOAN_REPAYMENT_OVERDUE", "UPDATE_LOAN_ARREARS_AGING", "ADD_PERIODIC_ACCRUAL_ENTRIES", + "EXTERNAL_ASSET_OWNER_TRANSFER", "CHECK_DUE_INSTALLMENTS", "ACCRUAL_ACTIVITY_POSTING", "LOAN_INTEREST_RECALCULATION"); + } + + @AfterAll + public void printConsolidatedReport() { + LOG.info("\n\n"); + LOG.info("========================================================================"); + LOG.info(" CONSOLIDATED PERFORMANCE REPORT "); + LOG.info("========================================================================"); + + // Table header + LOG.info(String.format("%-25s | %-20s | %-20s | %-20s", "Test Configuration", "Loan Creation Time", "First COB Run Time", + "Second COB Run Time")); + LOG.info("-------------------------------------------------------------------------"); + + // Table rows + for (Map.Entry> entry : allTestMetrics.entrySet()) { + String testName = entry.getKey(); + Map metrics = entry.getValue(); + + int loanCount = (int) metrics.get("loanCount"); + long createTime = (long) metrics.get("loanCreationTimeMs"); + long firstCobTime = (long) metrics.get("firstCOBTimeMs"); + long secondCobTime = (long) metrics.get("secondCOBTimeMs"); + + LOG.info(String.format("%-25s | %-20s | %-20s | %-20s", testName + " (" + loanCount + " loans)", + createTime + " ms (" + (createTime / loanCount) + " ms/loan)", + firstCobTime + " ms (" + (firstCobTime / loanCount) + " ms/loan)", + secondCobTime + " ms (" + (secondCobTime / loanCount) + " ms/loan)")); + } + + LOG.info("========================================================================"); + + // Add scaled performance analysis if there are multiple tests + if (allTestMetrics.size() > 1) { + LOG.info("\n"); + LOG.info("SCALING ANALYSIS"); + LOG.info("========================================================================"); + LOG.info("This analysis shows how performance scales with increasing loan counts"); + + // Find the smallest and largest loan count tests + int minLoans = Integer.MAX_VALUE; + int maxLoans = 0; + String minTestName = ""; + String maxTestName = ""; + + for (Map.Entry> entry : allTestMetrics.entrySet()) { + int loanCount = (int) entry.getValue().get("loanCount"); + if (loanCount < minLoans) { + minLoans = loanCount; + minTestName = entry.getKey(); + } + if (loanCount > maxLoans) { + maxLoans = loanCount; + maxTestName = entry.getKey(); + } + } + + if (!minTestName.equals(maxTestName)) { + Map minMetrics = allTestMetrics.get(minTestName); + Map maxMetrics = allTestMetrics.get(maxTestName); + + double loanCountRatio = (double) maxLoans / minLoans; + + double createTimeRatio = (double) ((long) maxMetrics.get("loanCreationTimeMs")) + / ((long) minMetrics.get("loanCreationTimeMs")); + + double firstCOBRatio = (double) ((long) maxMetrics.get("firstCOBTimeMs")) / ((long) minMetrics.get("firstCOBTimeMs")); + + double secondCOBRatio = (double) ((long) maxMetrics.get("secondCOBTimeMs")) / ((long) minMetrics.get("secondCOBTimeMs")); + + LOG.info(String.format("Loan count increased by a factor of %.2f (from %d to %d)", loanCountRatio, minLoans, maxLoans)); + LOG.info(String.format("Loan creation time increased by a factor of %.2f (scaling efficiency: %.2f%%)", createTimeRatio, + (loanCountRatio / createTimeRatio) * 100)); + LOG.info(String.format("First COB run time increased by a factor of %.2f (scaling efficiency: %.2f%%)", firstCOBRatio, + (loanCountRatio / firstCOBRatio) * 100)); + LOG.info(String.format("Second COB run time increased by a factor of %.2f (scaling efficiency: %.2f%%)", secondCOBRatio, + (loanCountRatio / secondCOBRatio) * 100)); + LOG.info("------------------------------------------------------------------------"); + LOG.info("Note: Scaling efficiency > 100% indicates better than linear scaling"); + LOG.info(" Scaling efficiency < 100% indicates worse than linear scaling"); + } + + LOG.info("========================================================================"); + } + } + + private Long createLoanProduct(String disbursementDate, Double amount, Double interestRate, Integer numberOfInstallments) { + PostLoanProductsResponse loanProduct = loanProductHelper + .createLoanProduct(create4IProgressive().recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)); + + Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), disbursementDate, amount, + interestRate, numberOfInstallments, null); + disburseLoan(loanId, BigDecimal.valueOf(amount), disbursementDate); + return loanId; + } + + public List createLoans(int numberOfLoans, String disbursementDate, Double amount, Double interestRate, + Integer numberOfInstallments) { + LOG.info("Creating {} loans...", numberOfLoans); + long startTime = System.nanoTime(); + + List loanIds = new ArrayList<>(); + for (int i = 0; i < numberOfLoans; i++) { + Long loanId = createLoanProduct(disbursementDate, amount != null ? amount : getRandomAmount(), + interestRate != null ? interestRate : getRandomInterestRate(), + numberOfInstallments != null ? numberOfInstallments : getRandomNumberOfInstallments()); + loanIds.add(loanId); + + if ((i + 1) % 10 == 0) { + LOG.info("Created {} loans so far...", (i + 1)); + } + } + + long endTime = System.nanoTime(); + long durationMs = TimeUnit.NANOSECONDS.toMillis(endTime - startTime); + LOG.info("Loan creation completed in {} ms ({} ms per loan)", durationMs, durationMs / numberOfLoans); + + return loanIds; + } + + // random number from 3,4,6,12 + private Integer getRandomNumberOfInstallments() { + int[] possibleValues = { 3, 4, 6, 12 }; + return possibleValues[random.nextInt(possibleValues.length)]; + } + + private Double getRandomAmount() { + return random.nextInt(1, 100) * 100.0; + } + + private Double getRandomInterestRate() { + return (double) random.nextInt(1, 15); + } + + @ParameterizedTest + @ValueSource(ints = { 10, 50, 100 }) + public void testLoanCOBPerformanceWithDifferentLoansCount(int loanCount, TestInfo testInfo) { + String testName = testInfo.getDisplayName(); + LOG.info("Starting test: {} with {} loans", testName, loanCount); + + AtomicReference> loanIds = new AtomicReference<>(new ArrayList<>()); + final Map metrics = new HashMap<>(); + metrics.put("loanCount", loanCount); + + // Create loans + runAt("1 January 2023", () -> { + long startTime = System.nanoTime(); + loanIds.set(createLoans(loanCount, "1 January 2023", null, null, null)); + long endTime = System.nanoTime(); + long duration = TimeUnit.NANOSECONDS.toMillis(endTime - startTime); + metrics.put("loanCreationTimeMs", duration); + LOG.info("Created {} loans in {} ms", loanCount, duration); + }); + + // First COB run - January to February + runAt("1 February 2023", () -> { + LOG.info("Running first COB for {} loans...", loanCount); + long startTime = System.nanoTime(); + inlineLoanCOBHelper.executeInlineCOB(loanIds.get()); + long endTime = System.nanoTime(); + long duration = TimeUnit.NANOSECONDS.toMillis(endTime - startTime); + metrics.put("firstCOBTimeMs", duration); + LOG.info("First COB completed in {} ms ({} ms per loan)", duration, duration / loanCount); + }); + + // Second COB run - February to March + runAt("1 March 2023", () -> { + LOG.info("Running second COB for {} loans...", loanCount); + long startTime = System.nanoTime(); + inlineLoanCOBHelper.executeInlineCOB(loanIds.get()); + long endTime = System.nanoTime(); + long duration = TimeUnit.NANOSECONDS.toMillis(endTime - startTime); + metrics.put("secondCOBTimeMs", duration); + LOG.info("Second COB completed in {} ms ({} ms per loan)", duration, duration / loanCount); + }); + + // Add metrics to the consolidated collection + allTestMetrics.put(testName, metrics); + + // Print individual test summary + LOG.info("Individual test complete. Summary for {} loans:", loanCount); + LOG.info("----------------------------------------------------"); + LOG.info("Loan Creation Time: {} ms ({} ms/loan)", metrics.get("loanCreationTimeMs"), + ((Long) metrics.get("loanCreationTimeMs")) / loanCount); + LOG.info("First COB Run Time: {} ms ({} ms/loan)", metrics.get("firstCOBTimeMs"), + ((Long) metrics.get("firstCOBTimeMs")) / loanCount); + LOG.info("Second COB Run Time: {} ms ({} ms/loan)", metrics.get("secondCOBTimeMs"), + ((Long) metrics.get("secondCOBTimeMs")) / loanCount); + LOG.info("----------------------------------------------------\n"); + + LOG.info("Full consolidated report will be printed after all tests complete."); + } +}