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 @@ -37,8 +37,7 @@ class EventBusTopicProvisioningIT {
runner.run { context ->
partitionCount(context.getBean(KafkaAdmin::class.java)) shouldBe PARTITIONS
}
// Second context start re-runs provisioning against the same broker and topic: KafkaAdmin
// treats the existing same-partition topic as a no-op (create-only, idempotent, INV-1).
// re-provisioning an existing same-partition topic is a no-op (create-only, idempotent)
runner.run { context ->
partitionCount(context.getBean(KafkaAdmin::class.java)) shouldBe PARTITIONS
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,7 @@ class EventBusAutoConfiguration {
fun eventBusTopics(properties: EventBusProperties): KafkaAdmin.NewTopics = KafkaAdmin.NewTopics(*newTopics(properties).toTypedArray())

companion object {
// Durability is a correctness invariant for ledger events, not a deployment knob:
// acks=all + enable.idempotence=true are fixed so the producer can never be silently
// weakened into dropping or duplicating events. Connection/security stay configurable.
// fixed durability invariant: acks=all is not a deployment knob, never weaken to allow drops/dupes
private const val DURABLE_ACKS = "all"

fun newTopics(properties: EventBusProperties): List<NewTopic> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@ package com.fincore.eventbus.consumer

import java.util.UUID

/**
* Runs a handler exactly once per (envelope id, consumer group). Claim-then-handle: the dedup claim
* is taken first, then the handler runs. Call this inside the consumer's transaction so a thrown
* handler rolls back the claim and the event is retried (at-least-once, exactly-once effect).
*/
// Call within the consumer's transaction so a thrown handler rolls back the claim and the event is retried.
class IdempotentEventProcessor(
private val store: ProcessedEventStore,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@ package com.fincore.eventbus.consumer
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap

/**
* Non-persistent dedup store for tests and local development. State is lost on restart, so it gives
* no exactly-once effect across restarts - a persistent store (see [JdbcProcessedEventStore]) is
* required in production.
*/
// Non-persistent (state lost on restart): tests/dev only, use JdbcProcessedEventStore in production.
class InMemoryProcessedEventStore : ProcessedEventStore {
private val seen: MutableSet<Pair<UUID, String>> = ConcurrentHashMap.newKeySet()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ import java.sql.Timestamp
import java.time.Instant
import java.util.UUID

/**
* Persistent dedup store over a `processed_events` table (see db/processed-events.sql). The claim is
* an INSERT ... ON CONFLICT DO NOTHING that participates in the ambient transaction, so a failing
* handler rolls it back and the event is retried.
*/
// Claim = INSERT ... ON CONFLICT DO NOTHING in the ambient transaction, so it rolls back with a failing handler.
class JdbcProcessedEventStore(
private val jdbcTemplate: JdbcTemplate,
private val tableName: String = "processed_events",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,8 @@ package com.fincore.eventbus.consumer

import java.util.UUID

/**
* Dedup store for at-least-once consumers. Keyed by (envelope id, consumer group) so independent
* consumers each process an event exactly once.
*/
interface ProcessedEventStore {
/**
* Atomically claims an envelope for a consumer group. Returns true the first time the pair is
* seen (the caller should process it) and false on every subsequent call (a duplicate to skip).
*/
/** Returns true the first time (envelopeId, consumerGroup) is claimed, false for a duplicate. */
fun markIfFirstSeen(
envelopeId: UUID,
consumerGroup: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,10 @@ import org.apache.kafka.clients.consumer.Consumer
import org.springframework.kafka.core.KafkaTemplate
import java.time.Duration

/**
* Re-drives dead-lettered records back to a target topic. Polls a caller-supplied consumer (already
* subscribed to the dead-letter topic, so offset and commit policy stay with the caller) once and
* re-publishes each record preserving its key. Non-destructive: the dead-letter topic is not mutated.
*/
class DeadLetterReplayer(
private val kafkaTemplate: KafkaTemplate<String, String>,
) {
/** Returns the number of records confirmed re-published. Throws if any send fails, so an
* incident-recovery caller never sees a success count for records that did not land. */
// awaits each send so the returned count is confirmed (throws if any re-publish fails)
fun replay(
consumer: Consumer<String, String>,
targetTopic: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ import org.springframework.kafka.listener.DeadLetterPublishingRecoverer
import org.springframework.kafka.listener.DefaultErrorHandler
import org.springframework.util.backoff.ExponentialBackOff

/**
* Consumer error topology: bounded exponential-backoff retry, then terminal routing of an exhausted
* record to its dead-letter topic. A consumer attaches [kafkaErrorHandler] to its listener container
* factory. Loaded only when the event bus is configured (imported by EventBusAutoConfiguration).
*/
@Configuration
class RetryDlqConfiguration {
@Bean
Expand All @@ -31,8 +26,7 @@ class RetryDlqConfiguration {
naming: RetryTopicNaming,
): DeadLetterPublishingRecoverer =
DeadLetterPublishingRecoverer(kafkaTemplate) { record, _ ->
// partition -1 lets the producer place the record by key, preserving per-key affinity
// without assuming the dead-letter topic has the same partition count as the source.
// -1: producer places by key, preserving affinity without matching source partition count
TopicPartition(naming.deadLetterTopic(record.topic()), PARTITION_BY_KEY)
}

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

package com.fincore.eventbus.retry

/**
* Suffix-based naming for the retry and dead-letter topics of a base topic. Generic - the caller
* supplies the base topic; no business names are encoded here.
*/
class RetryTopicNaming(
private val retrySuffix: String,
private val deadLetterSuffix: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,7 @@ class IdempotentEventProcessorTest {
processor.process(id, "group", { error("handler failed") })
}

// The in-memory store has no transaction, so the claim survives a thrown handler: the
// re-process is skipped. Rollback of the claim on handler failure is a property of a
// transactional store (JdbcProcessedEventStore), proven by JdbcProcessedEventStoreIT.
// in-memory has no tx so the claim survives a thrown handler; rollback is a JDBC property (see the IT)
processor.process(id, "group", { }) shouldBe EventProcessingOutcome.DUPLICATE_SKIPPED
}
}
Loading