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
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.application.outbox
package com.fincore.eventbus.outbox

import java.util.UUID

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.eventbus.outbox

import java.time.Duration

data class OutboxDispatchSettings(
val batchSize: Int = DEFAULT_BATCH_SIZE,
val maxAttempts: Int = DEFAULT_MAX_ATTEMPTS,
val leaseTimeout: Duration = defaultLeaseTimeout,
val sendTimeout: Duration = defaultSendTimeout,
val topicPrefix: String = DEFAULT_TOPIC_PREFIX,
) {
private companion object {
const val DEFAULT_BATCH_SIZE = 100
const val DEFAULT_MAX_ATTEMPTS = 10
const val DEFAULT_TOPIC_PREFIX = "fincore"
val defaultLeaseTimeout: Duration = Duration.ofMinutes(5)
val defaultSendTimeout: Duration = Duration.ofSeconds(10)
}
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.application.outbox
package com.fincore.eventbus.outbox

import com.fincore.ledger.config.OutboxDispatcherProperties
import org.slf4j.LoggerFactory
import org.springframework.kafka.core.KafkaTemplate
import java.util.concurrent.TimeUnit

/**
* Relays claimed outbox events to the broker. Orchestration only - not transactional. Each event is
* published outside any transaction; settling its outcome is delegated to [OutboxClaimStore].
* published outside any transaction; claiming and settling its outcome are delegated to [OutboxStore].
*/
class OutboxDispatcher(
private val claimStore: OutboxClaimStore,
private val store: OutboxStore,
private val kafkaTemplate: KafkaTemplate<String, String>,
private val properties: OutboxDispatcherProperties,
private val settings: OutboxDispatchSettings,
) {
private val log = LoggerFactory.getLogger(javaClass)

fun dispatch(): DispatchSummary {
val claimed = claimStore.claim(properties.maxAttempts, properties.leaseTimeout, properties.batchSize)
val claimed = store.claim(settings.maxAttempts, settings.leaseTimeout, settings.batchSize)
var published = 0
for (event in claimed) {
if (publish(event)) published++
Expand All @@ -39,14 +38,14 @@ class OutboxDispatcher(
@Suppress("TooGenericExceptionCaught")
private fun publish(event: ClaimedEvent): Boolean =
try {
val topic = "${properties.topicPrefix}.${event.aggregateType.lowercase()}"
val topic = "${settings.topicPrefix}.${event.aggregateType.lowercase()}"
kafkaTemplate
.send(topic, event.aggregateId, event.payload)
.get(properties.sendTimeout.toMillis(), TimeUnit.MILLISECONDS)
claimStore.markPublished(event.id)
.get(settings.sendTimeout.toMillis(), TimeUnit.MILLISECONDS)
store.markPublished(event.id)
true
} catch (ex: Exception) {
claimStore.markFailed(event.id, event.attempts + 1, properties.maxAttempts, ex.message)
store.markFailed(event.id, event.attempts + 1, settings.maxAttempts, ex.message)
false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.eventbus.outbox

import java.time.Duration
import java.util.UUID

/**
* Transactional boundary for the dispatcher. Each method is a separate short transaction so the broker
* publish in [OutboxDispatcher] never runs inside a database transaction. Each service provides its own
* implementation over its own outbox table.
*/
interface OutboxStore {
fun claim(
maxAttempts: Int,
leaseTimeout: Duration,
batchSize: Int,
): List<ClaimedEvent>

fun markPublished(id: UUID)

fun markFailed(
id: UUID,
attempts: Int,
maxAttempts: Int,
error: String?,
)
}
Original file line number Diff line number Diff line change
@@ -1,47 +1,43 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.application.outbox
package com.fincore.eventbus.outbox

import com.fincore.ledger.config.OutboxDispatcherProperties
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import org.springframework.boot.autoconfigure.AutoConfigurations
import org.springframework.boot.test.context.runner.ApplicationContextRunner
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.support.SendResult
import java.time.Duration
import java.util.UUID
import java.util.concurrent.CompletableFuture

class OutboxDispatcherTest {
private val claimStore = mockk<OutboxClaimStore>(relaxed = true)
private val store = mockk<OutboxStore>(relaxed = true)
private val kafkaTemplate = mockk<KafkaTemplate<String, String>>()
private val properties =
OutboxDispatcherProperties(
enabled = true,
private val settings =
OutboxDispatchSettings(
batchSize = 10,
maxAttempts = 5,
sendTimeout = Duration.ofSeconds(2),
topicPrefix = "fincore",
)
private val dispatcher = OutboxDispatcher(claimStore, kafkaTemplate, properties)
private val dispatcher = OutboxDispatcher(store, kafkaTemplate, settings)

@Test
fun `should publish each event to the prefixed aggregate topic keyed by aggregate id`() {
val id = UUID.randomUUID()
val event = ClaimedEvent(id, "Transaction", "tx_7", "type", "{\"id\":\"e1\"}", attempts = 0)
every { claimStore.claim(any(), any(), any()) } returns listOf(event)
every { store.claim(any(), any(), any()) } returns listOf(event)
every { kafkaTemplate.send("fincore.transaction", "tx_7", "{\"id\":\"e1\"}") } returns completed()

val summary = dispatcher.dispatch()

summary shouldBe DispatchSummary(published = 1, failed = 0)
verify { kafkaTemplate.send("fincore.transaction", "tx_7", "{\"id\":\"e1\"}") }
verify { claimStore.markPublished(id) }
verify { store.markPublished(id) }
}

@Test
Expand All @@ -50,26 +46,16 @@ class OutboxDispatcherTest {
val okId = UUID.randomUUID()
val bad = ClaimedEvent(badId, "Transaction", "tx_bad", "type", "{}", attempts = 0)
val ok = ClaimedEvent(okId, "Transaction", "tx_ok", "type", "{}", attempts = 0)
every { claimStore.claim(any(), any(), any()) } returns listOf(bad, ok)
every { store.claim(any(), any(), any()) } returns listOf(bad, ok)
every { kafkaTemplate.send("fincore.transaction", "tx_bad", any()) } returns
CompletableFuture.failedFuture(RuntimeException("broker down"))
every { kafkaTemplate.send("fincore.transaction", "tx_ok", any()) } returns completed()

val summary = dispatcher.dispatch()

summary shouldBe DispatchSummary(published = 1, failed = 1)
verify { claimStore.markPublished(okId) }
verify { claimStore.markFailed(badId, 1, 5, any()) }
}

@Test
fun `should not register a dispatcher bean when the dispatcher is disabled`() {
ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of())
.withUserConfiguration(com.fincore.ledger.config.OutboxDispatchConfig::class.java)
.run { context ->
context.getBeanNamesForType(OutboxDispatcher::class.java).size shouldBe 0
}
verify { store.markPublished(okId) }
verify { store.markFailed(badId, 1, 5, any()) }
}

private fun completed(): CompletableFuture<SendResult<String, String>> = CompletableFuture.completedFuture(mockk(relaxed = true))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
package com.fincore.ledger.application.outbox

import com.fincore.eventbus.EventBusAutoConfiguration
import com.fincore.eventbus.outbox.DispatchSummary
import com.fincore.eventbus.outbox.OutboxDispatchSettings
import com.fincore.eventbus.outbox.OutboxDispatcher
import com.fincore.events.OutboxStatus
import com.fincore.ledger.config.OutboxDispatcherProperties
import com.fincore.ledger.infrastructure.persistence.OutboxEventEntity
import com.fincore.ledger.infrastructure.persistence.OutboxEventRepository
import com.fincore.test.containers.PostgresContainerExtension
Expand Down Expand Up @@ -155,9 +157,8 @@ class OutboxDispatcherFailureIT(
const val POLL_TIMEOUT_SECONDS = 15L
const val UNREACHABLE_BOOTSTRAP = "localhost:65000"

fun props(maxAttempts: Int): OutboxDispatcherProperties =
OutboxDispatcherProperties(
enabled = true,
fun props(maxAttempts: Int): OutboxDispatchSettings =
OutboxDispatchSettings(
maxAttempts = maxAttempts,
sendTimeout = Duration.ofSeconds(SEND_TIMEOUT_SECONDS),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ package com.fincore.ledger.application.outbox

import com.fasterxml.jackson.databind.ObjectMapper
import com.fincore.eventbus.EventBusAutoConfiguration
import com.fincore.eventbus.outbox.DispatchSummary
import com.fincore.eventbus.outbox.OutboxDispatchSettings
import com.fincore.eventbus.outbox.OutboxDispatcher
import com.fincore.events.OutboxStatus
import com.fincore.ledger.config.OutboxDispatcherProperties
import com.fincore.ledger.infrastructure.persistence.OutboxEventEntity
import com.fincore.ledger.infrastructure.persistence.OutboxEventRepository
import com.fincore.test.containers.PostgresContainerExtension
Expand Down Expand Up @@ -46,7 +48,7 @@ class OutboxDispatcherIT(
OutboxDispatcher(
claimStore,
kafkaTemplate,
OutboxDispatcherProperties(enabled = true, sendTimeout = Duration.ofSeconds(SEND_TIMEOUT_SECONDS)),
OutboxDispatchSettings(sendTimeout = Duration.ofSeconds(SEND_TIMEOUT_SECONDS)),
)

@AfterEach
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

package com.fincore.ledger.application.outbox

import com.fincore.eventbus.outbox.ClaimedEvent
import com.fincore.eventbus.outbox.OutboxStore
import com.fincore.events.OutboxStatus
import com.fincore.ledger.infrastructure.persistence.OutboxEventRepository
import org.springframework.stereotype.Component
Expand All @@ -12,15 +14,15 @@ import java.time.Instant
import java.util.UUID

/**
* Transactional boundary for the dispatcher. Claim and settle are separate short transactions so the
* broker publish (in [OutboxDispatcher]) never runs inside a DB transaction (CLAUDE.md 8.10).
* Ledger-backed [OutboxStore]. Claim and settle are separate short transactions so the broker publish
* (in the dispatcher) never runs inside a DB transaction.
*/
@Component
class OutboxClaimStore(
private val repository: OutboxEventRepository,
) {
) : OutboxStore {
@Transactional
fun claim(
override fun claim(
maxAttempts: Int,
leaseTimeout: Duration,
batchSize: Int,
Expand All @@ -42,12 +44,12 @@ class OutboxClaimStore(
}

@Transactional
fun markPublished(id: UUID) {
override fun markPublished(id: UUID) {
repository.markPublished(id, OutboxStatus.PUBLISHED, Instant.now())
}

@Transactional
fun markFailed(
override fun markFailed(
id: UUID,
attempts: Int,
maxAttempts: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@

package com.fincore.ledger.config

import com.fincore.eventbus.outbox.OutboxDispatchSettings
import com.fincore.eventbus.outbox.OutboxDispatcher
import com.fincore.ledger.application.outbox.OutboxClaimStore
import com.fincore.ledger.application.outbox.OutboxDispatcher
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
Expand All @@ -25,7 +26,18 @@ class OutboxDispatchConfig(
kafkaTemplate: KafkaTemplate<String, String>,
properties: OutboxDispatcherProperties,
) {
private val dispatcher = OutboxDispatcher(claimStore, kafkaTemplate, properties)
private val dispatcher =
OutboxDispatcher(
claimStore,
kafkaTemplate,
OutboxDispatchSettings(
batchSize = properties.batchSize,
maxAttempts = properties.maxAttempts,
leaseTimeout = properties.leaseTimeout,
sendTimeout = properties.sendTimeout,
topicPrefix = properties.topicPrefix,
),
)

@Bean
fun outboxDispatcher(): OutboxDispatcher = dispatcher
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.config

import com.fincore.eventbus.outbox.OutboxDispatcher
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import org.springframework.boot.autoconfigure.AutoConfigurations
import org.springframework.boot.test.context.runner.ApplicationContextRunner

class OutboxDispatchConfigTest {
@Test
fun `should not register a dispatcher bean when the dispatcher is disabled`() {
ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of())
.withUserConfiguration(OutboxDispatchConfig::class.java)
.run { context ->
context.getBeanNamesForType(OutboxDispatcher::class.java).size shouldBe 0
}
}
}
Loading