From f2dca80a821d89e1165faa43c77f548914a76f1b Mon Sep 17 00:00:00 2001 From: Augment Bot Date: Fri, 1 May 2026 13:29:40 +0000 Subject: [PATCH 1/3] feat(crdtagy): translate CRDTAGY credit agency batch --- .../augment/cbsa/config/CbsaAsyncConfig.java | 25 +++++ .../com/augment/cbsa/domain/CreditAgency.java | 59 ++++++++++ .../augment/cbsa/service/CrecustService.java | 11 +- .../service/CreditAgencyDelayExecutor.java | 9 ++ .../service/CreditAgencyDelayGenerator.java | 10 ++ .../service/CreditAgencyScoreGenerator.java | 10 ++ .../cbsa/service/CreditAgencyService.java | 38 ++++++- .../cbsa/web/crdtagy/CrdtagyController.java | 75 +++++++++++++ .../CreditAgencyServiceIntegrationTest.java | 41 +++++++ .../service/CreditAgencyServiceUnitTest.java | 56 +++++++++ .../crdtagy/CrdtagyControllerWebMvcTest.java | 106 ++++++++++++++++++ 11 files changed, 432 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/augment/cbsa/domain/CreditAgency.java create mode 100644 src/main/java/com/augment/cbsa/service/CreditAgencyDelayExecutor.java create mode 100644 src/main/java/com/augment/cbsa/service/CreditAgencyDelayGenerator.java create mode 100644 src/main/java/com/augment/cbsa/service/CreditAgencyScoreGenerator.java create mode 100644 src/main/java/com/augment/cbsa/web/crdtagy/CrdtagyController.java create mode 100644 src/test/java/com/augment/cbsa/service/CreditAgencyServiceIntegrationTest.java create mode 100644 src/test/java/com/augment/cbsa/service/CreditAgencyServiceUnitTest.java create mode 100644 src/test/java/com/augment/cbsa/web/crdtagy/CrdtagyControllerWebMvcTest.java diff --git a/src/main/java/com/augment/cbsa/config/CbsaAsyncConfig.java b/src/main/java/com/augment/cbsa/config/CbsaAsyncConfig.java index 1ca27ee..bbef34b 100644 --- a/src/main/java/com/augment/cbsa/config/CbsaAsyncConfig.java +++ b/src/main/java/com/augment/cbsa/config/CbsaAsyncConfig.java @@ -1,7 +1,11 @@ package com.augment.cbsa.config; +import com.augment.cbsa.service.CreditAgencyDelayExecutor; +import com.augment.cbsa.service.CreditAgencyDelayGenerator; +import com.augment.cbsa.service.CreditAgencyScoreGenerator; import java.time.Clock; import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.task.VirtualThreadTaskExecutor; @@ -16,6 +20,27 @@ VirtualThreadTaskExecutor creditAgencyExecutor() { return new VirtualThreadTaskExecutor("credit-agency-"); } + @Bean + CreditAgencyDelayGenerator creditAgencyDelayGenerator() { + return agency -> java.time.Duration.ofSeconds(ThreadLocalRandom.current().nextLong( + agency.minimumDelaySeconds(), + agency.maximumDelaySecondsExclusive() + )); + } + + @Bean + CreditAgencyScoreGenerator creditAgencyScoreGenerator() { + // Mirror the COBOL COMPUTE into an integer PIC 999 field: nextInt(1, 999) + // yields 1..998, matching the source program's effective upper bound once + // the fractional RANDOM result is truncated into the receiving integer. + return (agency, request) -> ThreadLocalRandom.current().nextInt(1, 999); + } + + @Bean + CreditAgencyDelayExecutor creditAgencyDelayExecutor() { + return duration -> Thread.sleep(duration); + } + @Bean Clock systemClock() { return Clock.systemUTC(); diff --git a/src/main/java/com/augment/cbsa/domain/CreditAgency.java b/src/main/java/com/augment/cbsa/domain/CreditAgency.java new file mode 100644 index 0000000..201e630 --- /dev/null +++ b/src/main/java/com/augment/cbsa/domain/CreditAgency.java @@ -0,0 +1,59 @@ +package com.augment.cbsa.domain; + +import java.util.Arrays; + +public enum CreditAgency { + + CRDTAGY1(1, "CRDTAGY1", "CIPA", 1, 3), + CRDTAGY2(2, "CRDTAGY2", "CIPB", 1, 3), + CRDTAGY3(3, "CRDTAGY3", "CIPC", 1, 3), + CRDTAGY4(4, "CRDTAGY4", "CIPD", 1, 3), + CRDTAGY5(5, "CRDTAGY5", "CIPE", 1, 3); + + private final int agencyNumber; + private final String programName; + private final String containerName; + private final int minimumDelaySeconds; + private final int maximumDelaySecondsExclusive; + + CreditAgency( + int agencyNumber, + String programName, + String containerName, + int minimumDelaySeconds, + int maximumDelaySecondsExclusive + ) { + this.agencyNumber = agencyNumber; + this.programName = programName; + this.containerName = containerName; + this.minimumDelaySeconds = minimumDelaySeconds; + this.maximumDelaySecondsExclusive = maximumDelaySecondsExclusive; + } + + public int agencyNumber() { + return agencyNumber; + } + + public String programName() { + return programName; + } + + public String containerName() { + return containerName; + } + + public int minimumDelaySeconds() { + return minimumDelaySeconds; + } + + public int maximumDelaySecondsExclusive() { + return maximumDelaySecondsExclusive; + } + + public static CreditAgency fromAgencyNumber(int agencyNumber) { + return Arrays.stream(values()) + .filter(agency -> agency.agencyNumber == agencyNumber) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported credit agency number: " + agencyNumber)); + } +} \ No newline at end of file diff --git a/src/main/java/com/augment/cbsa/service/CrecustService.java b/src/main/java/com/augment/cbsa/service/CrecustService.java index 58899cc..f7d3d31 100644 --- a/src/main/java/com/augment/cbsa/service/CrecustService.java +++ b/src/main/java/com/augment/cbsa/service/CrecustService.java @@ -34,7 +34,7 @@ public class CrecustService { DateTimeFormatter.ofPattern("ddMMuuuu").withResolverStyle(ResolverStyle.STRICT); private static final int CREDIT_AGENCY_COUNT = 5; private static final int REVIEW_DATE_BOUND = 20; - private static final long CREDIT_AGENCY_TIMEOUT_SECONDS = 6; + private static final long CREDIT_AGENCY_REPLY_WINDOW_SECONDS = 3; private static final List VALID_TITLES = List.of( "Professor", "Mr", "Mrs", "Miss", "Ms", "Dr", "Drs", "Lord", "Sir", "Lady", "" ); @@ -151,6 +151,7 @@ private CreditDecision evaluateCredit(CrecustRequest request, LocalDate today) { futures.add(creditAgencyService.requestCreditScore(request, agencyNumber)); } + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(CREDIT_AGENCY_REPLY_WINDOW_SECONDS); int totalScore = 0; int returnedScores = 0; boolean interrupted = false; @@ -160,7 +161,13 @@ private CreditDecision evaluateCredit(CrecustRequest request, LocalDate today) { continue; } try { - Optional maybeScore = future.get(CREDIT_AGENCY_TIMEOUT_SECONDS, TimeUnit.SECONDS); + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + future.cancel(true); + continue; + } + + Optional maybeScore = future.get(remainingNanos, TimeUnit.NANOSECONDS); if (maybeScore.isPresent()) { totalScore += maybeScore.get(); returnedScores++; diff --git a/src/main/java/com/augment/cbsa/service/CreditAgencyDelayExecutor.java b/src/main/java/com/augment/cbsa/service/CreditAgencyDelayExecutor.java new file mode 100644 index 0000000..3f7689d --- /dev/null +++ b/src/main/java/com/augment/cbsa/service/CreditAgencyDelayExecutor.java @@ -0,0 +1,9 @@ +package com.augment.cbsa.service; + +import java.time.Duration; + +@FunctionalInterface +public interface CreditAgencyDelayExecutor { + + void delay(Duration duration) throws InterruptedException; +} \ No newline at end of file diff --git a/src/main/java/com/augment/cbsa/service/CreditAgencyDelayGenerator.java b/src/main/java/com/augment/cbsa/service/CreditAgencyDelayGenerator.java new file mode 100644 index 0000000..d4f16d2 --- /dev/null +++ b/src/main/java/com/augment/cbsa/service/CreditAgencyDelayGenerator.java @@ -0,0 +1,10 @@ +package com.augment.cbsa.service; + +import com.augment.cbsa.domain.CreditAgency; +import java.time.Duration; + +@FunctionalInterface +public interface CreditAgencyDelayGenerator { + + Duration nextDelay(CreditAgency agency); +} \ No newline at end of file diff --git a/src/main/java/com/augment/cbsa/service/CreditAgencyScoreGenerator.java b/src/main/java/com/augment/cbsa/service/CreditAgencyScoreGenerator.java new file mode 100644 index 0000000..67187a8 --- /dev/null +++ b/src/main/java/com/augment/cbsa/service/CreditAgencyScoreGenerator.java @@ -0,0 +1,10 @@ +package com.augment.cbsa.service; + +import com.augment.cbsa.domain.CreditAgency; +import com.augment.cbsa.domain.CrecustRequest; + +@FunctionalInterface +public interface CreditAgencyScoreGenerator { + + int nextCreditScore(CreditAgency agency, CrecustRequest request); +} \ No newline at end of file diff --git a/src/main/java/com/augment/cbsa/service/CreditAgencyService.java b/src/main/java/com/augment/cbsa/service/CreditAgencyService.java index edf8f18..62e310e 100644 --- a/src/main/java/com/augment/cbsa/service/CreditAgencyService.java +++ b/src/main/java/com/augment/cbsa/service/CreditAgencyService.java @@ -1,6 +1,8 @@ package com.augment.cbsa.service; +import com.augment.cbsa.domain.CreditAgency; import com.augment.cbsa.domain.CrecustRequest; +import com.augment.cbsa.error.CbsaAbendException; import java.util.Objects; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -10,17 +12,41 @@ @Service public class CreditAgencyService { + private final CreditAgencyDelayGenerator delayGenerator; + private final CreditAgencyDelayExecutor delayExecutor; + private final CreditAgencyScoreGenerator scoreGenerator; + + public CreditAgencyService( + CreditAgencyDelayGenerator delayGenerator, + CreditAgencyDelayExecutor delayExecutor, + CreditAgencyScoreGenerator scoreGenerator + ) { + this.delayGenerator = Objects.requireNonNull(delayGenerator, "delayGenerator must not be null"); + this.delayExecutor = Objects.requireNonNull(delayExecutor, "delayExecutor must not be null"); + this.scoreGenerator = Objects.requireNonNull(scoreGenerator, "scoreGenerator must not be null"); + } + @Async("creditAgencyExecutor") public CompletableFuture> requestCreditScore(CrecustRequest request, int agencyNumber) { Objects.requireNonNull(request, "request must not be null"); - if (agencyNumber < 1) { - throw new IllegalArgumentException("agencyNumber must be positive"); + CreditAgency agency = CreditAgency.fromAgencyNumber(agencyNumber); + + try { + delayExecutor.delay(delayGenerator.nextDelay(agency)); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return CompletableFuture.failedFuture(new CbsaAbendException( + "PLOP", + "Credit agency processing was interrupted.", + exception + )); + } + + int score = scoreGenerator.nextCreditScore(agency, request); + if (score < 1 || score > 999) { + throw new IllegalArgumentException("credit agency score must be between 1 and 999"); } - int score = Math.floorMod( - Objects.hash(request.name().stripTrailing(), request.address().stripTrailing(), request.dateOfBirth(), agencyNumber), - 900 - ) + 100; return CompletableFuture.completedFuture(Optional.of(score)); } } diff --git a/src/main/java/com/augment/cbsa/web/crdtagy/CrdtagyController.java b/src/main/java/com/augment/cbsa/web/crdtagy/CrdtagyController.java new file mode 100644 index 0000000..7ae3d82 --- /dev/null +++ b/src/main/java/com/augment/cbsa/web/crdtagy/CrdtagyController.java @@ -0,0 +1,75 @@ +package com.augment.cbsa.web.crdtagy; + +import com.augment.cbsa.domain.CrecustRequest; +import com.augment.cbsa.service.CreditAgencyService; +import com.augment.cbsa.web.crecust.dto.CrecustCommareaResponseDto; +import com.augment.cbsa.web.crecust.dto.CrecustRequestDto; +import com.augment.cbsa.web.crecust.dto.CrecustResponseDto; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.Objects; +import java.util.concurrent.CompletionException; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Validated +@RequestMapping("/api/v1/crdtagy") +public class CrdtagyController { + + private final CreditAgencyService creditAgencyService; + + public CrdtagyController(CreditAgencyService creditAgencyService) { + this.creditAgencyService = Objects.requireNonNull(creditAgencyService, "creditAgencyService must not be null"); + } + + @PostMapping("/{agencyNumber}") + public CrecustResponseDto process( + @PathVariable @Min(1) @Max(5) int agencyNumber, + @Valid @RequestBody CrecustRequestDto requestDto + ) { + var commarea = requestDto.creCust(); + int creditScore = awaitCreditScore(new CrecustRequest( + commarea.commName(), + commarea.commAddress(), + commarea.commDateOfBirth() + ), agencyNumber); + + return new CrecustResponseDto(new CrecustCommareaResponseDto( + defaultString(commarea.commEyecatcher()), + commarea.commKey(), + commarea.commName(), + commarea.commAddress(), + commarea.commDateOfBirth(), + creditScore, + defaultInt(commarea.commCsReviewDate()), + defaultString(commarea.commSuccess()), + defaultString(commarea.commFailCode()) + )); + } + + private int awaitCreditScore(CrecustRequest request, int agencyNumber) { + try { + return creditAgencyService.requestCreditScore(request, agencyNumber).join().orElse(0); + } catch (CompletionException exception) { + Throwable cause = exception.getCause(); + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new IllegalStateException("Credit agency processing failed.", cause); + } + } + + private String defaultString(String value) { + return value == null ? "" : value; + } + + private int defaultInt(Integer value) { + return value == null ? 0 : value; + } +} \ No newline at end of file diff --git a/src/test/java/com/augment/cbsa/service/CreditAgencyServiceIntegrationTest.java b/src/test/java/com/augment/cbsa/service/CreditAgencyServiceIntegrationTest.java new file mode 100644 index 0000000..9caf892 --- /dev/null +++ b/src/test/java/com/augment/cbsa/service/CreditAgencyServiceIntegrationTest.java @@ -0,0 +1,41 @@ +package com.augment.cbsa.service; + +import com.augment.cbsa.domain.CrecustRequest; +import com.augment.cbsa.support.AbstractCockroachIntegrationTest; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@SpringBootTest +class CreditAgencyServiceIntegrationTest extends AbstractCockroachIntegrationTest { + + @Autowired + private CreditAgencyService creditAgencyService; + + @MockBean + private CreditAgencyDelayGenerator creditAgencyDelayGenerator; + + @MockBean + private CreditAgencyDelayExecutor creditAgencyDelayExecutor; + + @MockBean + private CreditAgencyScoreGenerator creditAgencyScoreGenerator; + + @Test + void returnsAgencySpecificScoresWithinSpringContext() throws Exception { + when(creditAgencyDelayGenerator.nextDelay(any())).thenReturn(Duration.ZERO); + when(creditAgencyScoreGenerator.nextCreditScore(any(), any())).thenAnswer(invocation -> 400 + invocation.getArgument(0, com.augment.cbsa.domain.CreditAgency.class).agencyNumber()); + + CrecustRequest request = new CrecustRequest("Dr Alice Example", "1 Main Street", 10_01_2000); + + assertThat(creditAgencyService.requestCreditScore(request, 1).get(1, TimeUnit.SECONDS)).contains(401); + assertThat(creditAgencyService.requestCreditScore(request, 5).get(1, TimeUnit.SECONDS)).contains(405); + } +} \ No newline at end of file diff --git a/src/test/java/com/augment/cbsa/service/CreditAgencyServiceUnitTest.java b/src/test/java/com/augment/cbsa/service/CreditAgencyServiceUnitTest.java new file mode 100644 index 0000000..240dec6 --- /dev/null +++ b/src/test/java/com/augment/cbsa/service/CreditAgencyServiceUnitTest.java @@ -0,0 +1,56 @@ +package com.augment.cbsa.service; + +import com.augment.cbsa.domain.CrecustRequest; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CreditAgencyServiceUnitTest { + + private static final CrecustRequest REQUEST = new CrecustRequest("Dr Alice Example", "1 Main Street", 10_01_2000); + + @Test + void mapsEveryAgencyThroughInjectedDelayAndScoreComponents() { + List observedDelays = new ArrayList<>(); + CreditAgencyService service = new CreditAgencyService( + agency -> Duration.ofMillis(agency.agencyNumber() * 10L), + observedDelays::add, + (agency, request) -> 300 + agency.agencyNumber() + ); + + for (int agencyNumber = 1; agencyNumber <= 5; agencyNumber++) { + assertThat(service.requestCreditScore(REQUEST, agencyNumber).join()) + .contains(300 + agencyNumber); + } + + assertThat(observedDelays).containsExactly( + Duration.ofMillis(10), + Duration.ofMillis(20), + Duration.ofMillis(30), + Duration.ofMillis(40), + Duration.ofMillis(50) + ); + } + + @Test + void rejectsUnknownAgencyNumbers() { + CreditAgencyService service = new CreditAgencyService(agency -> Duration.ZERO, duration -> { }, (agency, request) -> 500); + + assertThatThrownBy(() -> service.requestCreditScore(REQUEST, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported credit agency number"); + } + + @Test + void rejectsScoresOutsideCobolRange() { + CreditAgencyService service = new CreditAgencyService(agency -> Duration.ZERO, duration -> { }, (agency, request) -> 1_000); + + assertThatThrownBy(() -> service.requestCreditScore(REQUEST, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("between 1 and 999"); + } +} \ No newline at end of file diff --git a/src/test/java/com/augment/cbsa/web/crdtagy/CrdtagyControllerWebMvcTest.java b/src/test/java/com/augment/cbsa/web/crdtagy/CrdtagyControllerWebMvcTest.java new file mode 100644 index 0000000..7a0202a --- /dev/null +++ b/src/test/java/com/augment/cbsa/web/crdtagy/CrdtagyControllerWebMvcTest.java @@ -0,0 +1,106 @@ +package com.augment.cbsa.web.crdtagy; + +import com.augment.cbsa.domain.CrecustRequest; +import com.augment.cbsa.error.CbsaAbendException; +import com.augment.cbsa.error.CbsaExceptionHandler; +import com.augment.cbsa.service.CreditAgencyService; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(CrdtagyController.class) +@Import(CbsaExceptionHandler.class) +class CrdtagyControllerWebMvcTest { + + private static final CrecustRequest REQUEST = new CrecustRequest("Dr Alice Example", "1 Main Street", 10_01_2000); + + @Autowired + private MockMvc mockMvc; + + @MockBean + private CreditAgencyService creditAgencyService; + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3, 4, 5}) + void returnsSuccessfulResponseForEveryAgencyRoute(int agencyNumber) throws Exception { + when(creditAgencyService.requestCreditScore(eq(REQUEST), eq(agencyNumber))) + .thenReturn(CompletableFuture.completedFuture(Optional.of(450 + agencyNumber))); + + mockMvc.perform(post("/api/v1/crdtagy/{agencyNumber}", agencyNumber) + .contentType(APPLICATION_JSON) + .content(requestJson())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.CreCust.CommEyecatcher").value("CUST")) + .andExpect(jsonPath("$.CreCust.CommKey.CommSortcode").value(987654)) + .andExpect(jsonPath("$.CreCust.CommCreditScore").value(450 + agencyNumber)); + } + + @Test + void rejectsOutOfRangeAgencyNumbers() throws Exception { + mockMvc.perform(post("/api/v1/crdtagy/{agencyNumber}", 6) + .contentType(APPLICATION_JSON) + .content(requestJson())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.title").value("Validation failed")); + } + + @Test + void requestValidationFailuresRemainProblemDetails() throws Exception { + mockMvc.perform(post("/api/v1/crdtagy/{agencyNumber}", 1) + .contentType(APPLICATION_JSON) + .content(""" + {"CreCust":{"CommKey":{"CommSortcode":1000000,"CommNumber":0},"CommName":"Dr Alice Example","CommAddress":"1 Main Street","CommDateOfBirth":10012000}} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.title").value("Validation failed")); + } + + @Test + void redactsAbendExceptionMessageFromAsyncFailures() throws Exception { + when(creditAgencyService.requestCreditScore(eq(REQUEST), eq(3))) + .thenReturn(CompletableFuture.failedFuture(new CbsaAbendException("PLOP", "sensitive delay failure"))); + + mockMvc.perform(post("/api/v1/crdtagy/{agencyNumber}", 3) + .contentType(APPLICATION_JSON) + .content(requestJson())) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.detail").value("Service abend")) + .andExpect(jsonPath("$.abendCode").value("PLOP")) + .andExpect(content().string(not(containsString("sensitive delay failure")))); + } + + private String requestJson() { + return """ + { + "CreCust": { + "CommEyecatcher": "CUST", + "CommKey": {"CommSortcode": 987654, "CommNumber": 42}, + "CommName": "Dr Alice Example", + "CommAddress": "1 Main Street", + "CommDateOfBirth": 10012000, + "CommCreditScore": 0, + "CommCsReviewDate": 0, + "CommSuccess": " ", + "CommFailCode": " " + } + } + """; + } +} \ No newline at end of file From f7ca29d4cb2dc2940261d04700700f47cbcf48f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Chang?= Date: Fri, 1 May 2026 13:43:20 +0000 Subject: [PATCH 2/3] fix(crdtagy): overall reply window via allOf; bounded controller timeout; align score bound with COBOL [1..998] --- .../augment/cbsa/service/CrecustService.java | 52 ++++++++++--------- .../cbsa/service/CreditAgencyService.java | 4 +- .../cbsa/web/crdtagy/CrdtagyController.java | 20 ++++++- .../service/CreditAgencyServiceUnitTest.java | 4 +- 4 files changed, 50 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/augment/cbsa/service/CrecustService.java b/src/main/java/com/augment/cbsa/service/CrecustService.java index f7d3d31..83e2cbd 100644 --- a/src/main/java/com/augment/cbsa/service/CrecustService.java +++ b/src/main/java/com/augment/cbsa/service/CrecustService.java @@ -151,44 +151,48 @@ private CreditDecision evaluateCredit(CrecustRequest request, LocalDate today) { futures.add(creditAgencyService.requestCreditScore(request, agencyNumber)); } - long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(CREDIT_AGENCY_REPLY_WINDOW_SECONDS); + // Single overall reply window across all agencies, mirroring the COBOL + // DELAY FOR SECONDS(3) + FETCH ANY NOSUSPEND flow. Wait for whichever + // agencies finish before the deadline and ignore the rest, so one slow + // agency cannot starve replies that already completed. + try { + CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)) + .get(CREDIT_AGENCY_REPLY_WINDOW_SECONDS, TimeUnit.SECONDS); + } catch (TimeoutException ignored) { + // Some agencies did not reply within the overall window; fall through + // and harvest whichever did complete. + } catch (ExecutionException | CompletionException ignored) { + // Ignore individual credit-agency failures and average only successful replies. + } catch (InterruptedException exception) { + // Treat interruption as an immediate overall credit-check failure + // so we do not persist a customer based on partially collected + // scores while cancellation is being signaled. + Thread.currentThread().interrupt(); + for (CompletableFuture> future : futures) { + future.cancel(true); + } + return null; + } + int totalScore = 0; int returnedScores = 0; - boolean interrupted = false; for (CompletableFuture> future : futures) { - if (interrupted) { + if (!future.isDone() || future.isCompletedExceptionally() || future.isCancelled()) { future.cancel(true); continue; } try { - long remainingNanos = deadlineNanos - System.nanoTime(); - if (remainingNanos <= 0) { - future.cancel(true); - continue; - } - - Optional maybeScore = future.get(remainingNanos, TimeUnit.NANOSECONDS); + Optional maybeScore = future.getNow(Optional.empty()); if (maybeScore.isPresent()) { totalScore += maybeScore.get(); returnedScores++; } - } catch (TimeoutException exception) { - // Bound the wait per agency so a hung credit-agency call cannot - // block the request indefinitely; treat as fail code G fodder. - future.cancel(true); - } catch (ExecutionException | CompletionException exception) { - // Ignore individual credit-agency failures and average only successful replies. - } catch (InterruptedException exception) { - // Treat interruption as an immediate overall credit-check failure - // so we do not persist a customer based on partially collected - // scores while cancellation is being signaled. - Thread.currentThread().interrupt(); - future.cancel(true); - interrupted = true; + } catch (CompletionException ignored) { + // Individual agency failure; average only successful replies. } } - if (interrupted || returnedScores == 0) { + if (returnedScores == 0) { return null; } diff --git a/src/main/java/com/augment/cbsa/service/CreditAgencyService.java b/src/main/java/com/augment/cbsa/service/CreditAgencyService.java index 62e310e..e5f1e62 100644 --- a/src/main/java/com/augment/cbsa/service/CreditAgencyService.java +++ b/src/main/java/com/augment/cbsa/service/CreditAgencyService.java @@ -43,8 +43,8 @@ public CompletableFuture> requestCreditScore(CrecustRequest re } int score = scoreGenerator.nextCreditScore(agency, request); - if (score < 1 || score > 999) { - throw new IllegalArgumentException("credit agency score must be between 1 and 999"); + if (score < 1 || score > 998) { + throw new IllegalArgumentException("credit agency score must be between 1 and 998"); } return CompletableFuture.completedFuture(Optional.of(score)); diff --git a/src/main/java/com/augment/cbsa/web/crdtagy/CrdtagyController.java b/src/main/java/com/augment/cbsa/web/crdtagy/CrdtagyController.java index 7ae3d82..7b14102 100644 --- a/src/main/java/com/augment/cbsa/web/crdtagy/CrdtagyController.java +++ b/src/main/java/com/augment/cbsa/web/crdtagy/CrdtagyController.java @@ -1,6 +1,7 @@ package com.augment.cbsa.web.crdtagy; import com.augment.cbsa.domain.CrecustRequest; +import com.augment.cbsa.error.CbsaAbendException; import com.augment.cbsa.service.CreditAgencyService; import com.augment.cbsa.web.crecust.dto.CrecustCommareaResponseDto; import com.augment.cbsa.web.crecust.dto.CrecustRequestDto; @@ -9,7 +10,11 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import java.util.Objects; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -22,6 +27,8 @@ @RequestMapping("/api/v1/crdtagy") public class CrdtagyController { + private static final long CREDIT_AGENCY_TIMEOUT_SECONDS = 5; + private final CreditAgencyService creditAgencyService; public CrdtagyController(CreditAgencyService creditAgencyService) { @@ -54,14 +61,23 @@ public CrecustResponseDto process( } private int awaitCreditScore(CrecustRequest request, int agencyNumber) { + CompletableFuture> future = + creditAgencyService.requestCreditScore(request, agencyNumber); try { - return creditAgencyService.requestCreditScore(request, agencyNumber).join().orElse(0); - } catch (CompletionException exception) { + return future.get(CREDIT_AGENCY_TIMEOUT_SECONDS, TimeUnit.SECONDS).orElse(0); + } catch (TimeoutException exception) { + future.cancel(true); + throw new CbsaAbendException("PLOP", "Credit agency processing timed out.", exception); + } catch (ExecutionException | CompletionException exception) { Throwable cause = exception.getCause(); if (cause instanceof RuntimeException runtimeException) { throw runtimeException; } throw new IllegalStateException("Credit agency processing failed.", cause); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + future.cancel(true); + throw new CbsaAbendException("PLOP", "Credit agency processing was interrupted.", exception); } } diff --git a/src/test/java/com/augment/cbsa/service/CreditAgencyServiceUnitTest.java b/src/test/java/com/augment/cbsa/service/CreditAgencyServiceUnitTest.java index 240dec6..ffb27cd 100644 --- a/src/test/java/com/augment/cbsa/service/CreditAgencyServiceUnitTest.java +++ b/src/test/java/com/augment/cbsa/service/CreditAgencyServiceUnitTest.java @@ -47,10 +47,10 @@ void rejectsUnknownAgencyNumbers() { @Test void rejectsScoresOutsideCobolRange() { - CreditAgencyService service = new CreditAgencyService(agency -> Duration.ZERO, duration -> { }, (agency, request) -> 1_000); + CreditAgencyService service = new CreditAgencyService(agency -> Duration.ZERO, duration -> { }, (agency, request) -> 999); assertThatThrownBy(() -> service.requestCreditScore(REQUEST, 1)) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("between 1 and 999"); + .hasMessageContaining("between 1 and 998"); } } \ No newline at end of file From d3a056169d2e48cef9a998843febfd81b1180e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Chang?= Date: Fri, 1 May 2026 13:51:25 +0000 Subject: [PATCH 3/3] fix(crecust): harvest replies that complete during cancel() race in evaluateCredit --- .../java/com/augment/cbsa/service/CrecustService.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/augment/cbsa/service/CrecustService.java b/src/main/java/com/augment/cbsa/service/CrecustService.java index 83e2cbd..1a98039 100644 --- a/src/main/java/com/augment/cbsa/service/CrecustService.java +++ b/src/main/java/com/augment/cbsa/service/CrecustService.java @@ -177,8 +177,13 @@ private CreditDecision evaluateCredit(CrecustRequest request, LocalDate today) { int totalScore = 0; int returnedScores = 0; for (CompletableFuture> future : futures) { - if (!future.isDone() || future.isCompletedExceptionally() || future.isCancelled()) { - future.cancel(true); + if (future.isCompletedExceptionally() || future.isCancelled()) { + continue; + } + // cancel(true) returns false if the future already completed + // normally, in which case we still harvest its score; this avoids + // dropping replies that completed between isDone() and cancel(). + if (!future.isDone() && future.cancel(true)) { continue; } try {