Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
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

@Service
class IdempotencyServiceImpl(
private val store: IdempotencyStore,
private val properties: IdempotencyProperties,
) : IdempotencyService {
override fun execute(
key: IdempotencyKey,
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
13 changes: 13 additions & 0 deletions services/ledger/src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
@@ -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}
13 changes: 13 additions & 0 deletions services/ledger/src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
@@ -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}
9 changes: 9 additions & 0 deletions services/ledger/src/main/resources/application-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
spring:
jpa:
hibernate:
ddl-auto: none
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost/realms/test
11 changes: 11 additions & 0 deletions services/ledger/src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,7 +17,7 @@ import org.springframework.dao.OptimisticLockingFailureException

class IdempotencyServiceImplTest {
private val store = mockk<IdempotencyStore>()
private val service = IdempotencyServiceImpl(store)
private val service = IdempotencyServiceImpl(store, IdempotencyProperties(maxAttempts = DEFAULT_ATTEMPTS))
private val key = IdempotencyKey.of("a".repeat(40))

@Test
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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<ConcurrencyConflictException> {
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
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Loading