From c80438beaac73d1861941578070341167b261325 Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Mon, 15 Jun 2026 23:14:32 -0300 Subject: [PATCH] feat(ledger): add config profiles and configurable retry ceiling Restructure application.yml into a baseline plus dev, test, and prod overlays. The dev profile targets local services, test stays Testcontainers-compatible (no hardcoded datasource), and prod references every sensitive value through an environment-variable placeholder with no secret literals. Bind the optimistic-lock retry ceiling to fincore.ledger.idempotency.max-attempts (default 3, validated >= 1) via a typed IdempotencyProperties, replacing the hardcoded constant so the count changes through configuration, not code. #62 AC-5 (live docker-compose boot) is verified by #66, which delivers the compose file and image. Closes #58 Closes #62 --- .../AccountIdempotencyServiceIT.kt | 3 ++ .../application/AuditRetryTopologyIT.kt | 3 ++ .../ledger/application/AuditWritePathIT.kt | 3 ++ .../com/fincore/ledger/LedgerApplication.kt | 3 ++ .../application/IdempotencyServiceImpl.kt | 8 ++-- .../ledger/config/IdempotencyProperties.kt | 14 ++++++ .../src/main/resources/application-dev.yml | 13 ++++++ .../src/main/resources/application-prod.yml | 13 ++++++ .../src/main/resources/application-test.yml | 9 ++++ .../ledger/src/main/resources/application.yml | 11 +++++ .../application/IdempotencyServiceImplTest.kt | 25 ++++++++--- .../config/IdempotencyPropertiesTest.kt | 43 +++++++++++++++++++ 12 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/config/IdempotencyProperties.kt create mode 100644 services/ledger/src/main/resources/application-dev.yml create mode 100644 services/ledger/src/main/resources/application-prod.yml create mode 100644 services/ledger/src/main/resources/application-test.yml create mode 100644 services/ledger/src/test/kotlin/com/fincore/ledger/config/IdempotencyPropertiesTest.kt diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountIdempotencyServiceIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountIdempotencyServiceIT.kt index ab1d9e4..39a2836 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountIdempotencyServiceIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountIdempotencyServiceIT.kt @@ -8,6 +8,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fincore.core.Currency import com.fincore.core.IdempotencyKey +import com.fincore.ledger.config.IdempotencyProperties import com.fincore.ledger.domain.enum.AccountStatus import com.fincore.ledger.domain.enum.AccountType import com.fincore.ledger.domain.exception.IdempotencyConflictException @@ -22,6 +23,7 @@ import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.test.context.TestConfiguration @@ -35,6 +37,7 @@ import java.time.Instant @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ExtendWith(PostgresContainerExtension::class) +@EnableConfigurationProperties(IdempotencyProperties::class) @Import( AccountServiceImpl::class, AccountPersistenceAdapter::class, diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AuditRetryTopologyIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AuditRetryTopologyIT.kt index 1e921f8..6c1c720 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AuditRetryTopologyIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AuditRetryTopologyIT.kt @@ -8,6 +8,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fincore.core.Currency import com.fincore.core.IdempotencyKey +import com.fincore.ledger.config.IdempotencyProperties import com.fincore.ledger.domain.enum.AccountType import com.fincore.ledger.domain.enum.AuditAction import com.fincore.ledger.domain.enum.EntryDirection @@ -28,6 +29,7 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.test.context.TestConfiguration @@ -61,6 +63,7 @@ import java.util.concurrent.atomic.AtomicInteger @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @Transactional(propagation = Propagation.NOT_SUPPORTED) @ExtendWith(PostgresContainerExtension::class) +@EnableConfigurationProperties(IdempotencyProperties::class) @Import( AccountServiceImpl::class, AccountPersistenceAdapter::class, diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AuditWritePathIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AuditWritePathIT.kt index 6a314a0..f5eea02 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AuditWritePathIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AuditWritePathIT.kt @@ -10,6 +10,7 @@ import com.fincore.core.AccountId import com.fincore.core.Currency import com.fincore.core.IdempotencyKey import com.fincore.ledger.api.observability.CorrelationIdAttributes +import com.fincore.ledger.config.IdempotencyProperties import com.fincore.ledger.domain.enum.AccountStatus import com.fincore.ledger.domain.enum.AccountType import com.fincore.ledger.domain.enum.AuditAction @@ -34,6 +35,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.slf4j.MDC import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.test.context.TestConfiguration @@ -47,6 +49,7 @@ import java.security.MessageDigest @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ExtendWith(PostgresContainerExtension::class) +@EnableConfigurationProperties(IdempotencyProperties::class) @Import( AccountServiceImpl::class, AccountPersistenceAdapter::class, diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/LedgerApplication.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/LedgerApplication.kt index a85a741..44d665a 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/LedgerApplication.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/LedgerApplication.kt @@ -3,10 +3,13 @@ package com.fincore.ledger +import com.fincore.ledger.config.IdempotencyProperties import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication @SpringBootApplication +@EnableConfigurationProperties(IdempotencyProperties::class) class LedgerApplication fun main(args: Array) { diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt index 041a2ba..a7f8f96 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt @@ -4,6 +4,7 @@ package com.fincore.ledger.application import com.fincore.core.IdempotencyKey +import com.fincore.ledger.config.IdempotencyProperties import com.fincore.ledger.domain.exception.ConcurrencyConflictException import org.springframework.dao.OptimisticLockingFailureException import org.springframework.stereotype.Service @@ -11,6 +12,7 @@ import org.springframework.stereotype.Service @Service class IdempotencyServiceImpl( private val store: IdempotencyStore, + private val properties: IdempotencyProperties, ) : IdempotencyService { override fun execute( key: IdempotencyKey, @@ -34,14 +36,10 @@ class IdempotencyServiceImpl( return store.runOrReplay(keyHash, requestHash, action) } catch (lock: OptimisticLockingFailureException) { attempt++ - if (attempt >= MAX_ATTEMPTS) throw ConcurrencyConflictException(lock) + if (attempt >= properties.maxAttempts) throw ConcurrencyConflictException(lock) } catch (race: IdempotencyRaceException) { return store.runOrReplay(keyHash, requestHash, action) } } } - - companion object { - const val MAX_ATTEMPTS = 3 - } } diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/config/IdempotencyProperties.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/config/IdempotencyProperties.kt new file mode 100644 index 0000000..e796e9f --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/config/IdempotencyProperties.kt @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.config + +import jakarta.validation.constraints.Min +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@Validated +@ConfigurationProperties(prefix = "fincore.ledger.idempotency") +data class IdempotencyProperties( + @field:Min(1) val maxAttempts: Int = 3, +) diff --git a/services/ledger/src/main/resources/application-dev.yml b/services/ledger/src/main/resources/application-dev.yml new file mode 100644 index 0000000..fdc64f3 --- /dev/null +++ b/services/ledger/src/main/resources/application-dev.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5432/ledger + username: ledger + password: ${DB_PASSWORD:ledger} + jpa: + hibernate: + ddl-auto: none + security: + oauth2: + resourceserver: + jwt: + issuer-uri: ${KEYCLOAK_ISSUER_URI:http://localhost:8081/realms/fincore} diff --git a/services/ledger/src/main/resources/application-prod.yml b/services/ledger/src/main/resources/application-prod.yml new file mode 100644 index 0000000..efb8893 --- /dev/null +++ b/services/ledger/src/main/resources/application-prod.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + jpa: + hibernate: + ddl-auto: validate + security: + oauth2: + resourceserver: + jwt: + issuer-uri: ${KEYCLOAK_ISSUER_URI} diff --git a/services/ledger/src/main/resources/application-test.yml b/services/ledger/src/main/resources/application-test.yml new file mode 100644 index 0000000..27ac4ca --- /dev/null +++ b/services/ledger/src/main/resources/application-test.yml @@ -0,0 +1,9 @@ +spring: + jpa: + hibernate: + ddl-auto: none + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost/realms/test diff --git a/services/ledger/src/main/resources/application.yml b/services/ledger/src/main/resources/application.yml index 740a1ad..a904ce0 100644 --- a/services/ledger/src/main/resources/application.yml +++ b/services/ledger/src/main/resources/application.yml @@ -1,11 +1,22 @@ +server: + port: 8080 spring: application: name: ledger-service + jpa: + hibernate: + ddl-auto: none + liquibase: + change-log: classpath:db/changelog/db.changelog-master.yaml security: oauth2: resourceserver: jwt: issuer-uri: ${KEYCLOAK_ISSUER_URI} +fincore: + ledger: + idempotency: + max-attempts: 3 springdoc: api-docs: path: /v3/api-docs diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/application/IdempotencyServiceImplTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/application/IdempotencyServiceImplTest.kt index 48a60e4..1b923fa 100644 --- a/services/ledger/src/test/kotlin/com/fincore/ledger/application/IdempotencyServiceImplTest.kt +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/application/IdempotencyServiceImplTest.kt @@ -4,6 +4,7 @@ package com.fincore.ledger.application import com.fincore.core.IdempotencyKey +import com.fincore.ledger.config.IdempotencyProperties import com.fincore.ledger.domain.exception.ConcurrencyConflictException import com.fincore.ledger.domain.exception.IdempotencyConflictException import io.kotest.assertions.throwables.shouldThrow @@ -16,7 +17,7 @@ import org.springframework.dao.OptimisticLockingFailureException class IdempotencyServiceImplTest { private val store = mockk() - private val service = IdempotencyServiceImpl(store) + private val service = IdempotencyServiceImpl(store, IdempotencyProperties(maxAttempts = DEFAULT_ATTEMPTS)) private val key = IdempotencyKey.of("a".repeat(40)) @Test @@ -52,7 +53,6 @@ class IdempotencyServiceImplTest { } } - // AC-7 / FR-1: retry loop now lives in IdempotencyServiceImpl.execute @Test fun `should retry in a fresh transaction when the action hits an optimistic lock then succeed`() { var calls = 0 @@ -69,7 +69,6 @@ class IdempotencyServiceImplTest { verify(exactly = 2) { store.runOrReplay(any(), any(), any()) } } - // AC-7 / FR-2 / INV-4: exactly 3 total invocations (not 4) on full failure @Test fun `should fail with a concurrency conflict after exhausting optimistic retries`() { every { store.runOrReplay(any(), any(), any()) } throws OptimisticLockingFailureException("version conflict") @@ -81,7 +80,19 @@ class IdempotencyServiceImplTest { verify(exactly = 3) { store.runOrReplay(any(), any(), any()) } } - // OQ-4: IdempotencyRaceException does not consume an OLF attempt; race is single-shot + @Test + fun `should honor a configured retry ceiling instead of a hardcoded count`() { + val ceiling = 5 + val configured = IdempotencyServiceImpl(store, IdempotencyProperties(maxAttempts = ceiling)) + every { store.runOrReplay(any(), any(), any()) } throws OptimisticLockingFailureException("version conflict") + + shouldThrow { + configured.execute(key, "{}") { StoredResponse(201, "{}") } + } + + verify(exactly = ceiling) { store.runOrReplay(any(), any(), any()) } + } + @Test fun `should not consume an optimistic attempt when an idempotency race is resolved`() { var calls = 0 @@ -94,7 +105,11 @@ class IdempotencyServiceImplTest { val result = service.execute(key, "{}") { StoredResponse(200, "{}") } result.replayed shouldBe true - // exactly 2 calls: original + race-replay; the race does not count toward MAX_ATTEMPTS + // exactly 2 calls: original + race-replay; the race does not count toward the retry ceiling verify(exactly = 2) { store.runOrReplay(any(), any(), any()) } } + + private companion object { + const val DEFAULT_ATTEMPTS = 3 + } } diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/config/IdempotencyPropertiesTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/config/IdempotencyPropertiesTest.kt new file mode 100644 index 0000000..ec02ba1 --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/config/IdempotencyPropertiesTest.kt @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.config + +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.boot.test.context.runner.ApplicationContextRunner + +class IdempotencyPropertiesTest { + private val runner = + ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ValidationAutoConfiguration::class.java)) + .withUserConfiguration(EnablingConfig::class.java) + + @EnableConfigurationProperties(IdempotencyProperties::class) + class EnablingConfig + + @Test + fun `should bind the default retry ceiling to three`() { + runner.run { context -> + context.getBean(IdempotencyProperties::class.java).maxAttempts shouldBe 3 + } + } + + @Test + fun `should bind an overridden retry ceiling from configuration`() { + runner.withPropertyValues("fincore.ledger.idempotency.max-attempts=7").run { context -> + context.getBean(IdempotencyProperties::class.java).maxAttempts shouldBe 7 + } + } + + @Test + fun `should reject a retry ceiling below one`() { + runner.withPropertyValues("fincore.ledger.idempotency.max-attempts=0").run { context -> + context.startupFailure.shouldNotBeNull() + } + } +}