refactor: Migrate from monolithic to modular architecture

1. **Dokumentation der Architektur:**
    - Vervollständigen Sie die C4-Diagramme im docs-Verzeichnis
    - Dokumentieren Sie die wichtigsten Architekturentscheidungen in ADRs

2. **Redis-Integration finalisieren:**
    - Implementieren Sie die verteilte Cache-Lösung für die Offline-Fähigkeit
    - Nutzen Sie Redis Streams für das Event-Sourcing
This commit is contained in:
stefan
2025-07-23 14:29:40 +02:00
parent a256622f37
commit 9282dd0eb4
52 changed files with 5648 additions and 3 deletions
@@ -0,0 +1,119 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.DomainEvent
import at.mocode.infrastructure.eventstore.api.EventSerializer
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.fasterxml.jackson.module.kotlin.readValue
import org.slf4j.LoggerFactory
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* Jackson-based implementation of EventSerializer.
*/
class JacksonEventSerializer : EventSerializer {
private val logger = LoggerFactory.getLogger(JacksonEventSerializer::class.java)
private val objectMapper: ObjectMapper = ObjectMapper().apply {
registerModule(KotlinModule.Builder().build())
registerModule(JavaTimeModule())
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
}
// Maps from event type to event class
private val eventTypeToClass = ConcurrentHashMap<String, Class<out DomainEvent>>()
// Maps from event class to event type
private val eventClassToType = ConcurrentHashMap<Class<out DomainEvent>, String>()
// Standard field names in serialized events
companion object {
const val EVENT_TYPE_FIELD = "eventType"
const val EVENT_ID_FIELD = "eventId"
const val AGGREGATE_ID_FIELD = "aggregateId"
const val VERSION_FIELD = "version"
const val TIMESTAMP_FIELD = "timestamp"
const val EVENT_DATA_FIELD = "eventData"
}
override fun serialize(event: DomainEvent): Map<String, String> {
val eventType = getEventType(event)
// Register the event type if not already registered
if (!eventClassToType.containsKey(event.javaClass)) {
registerEventType(event.javaClass, eventType)
}
// Serialize the event data
val eventData = objectMapper.writeValueAsString(event)
// Create a map with the event metadata and data
return mapOf(
EVENT_TYPE_FIELD to eventType,
EVENT_ID_FIELD to event.eventId.toString(),
AGGREGATE_ID_FIELD to event.aggregateId.toString(),
VERSION_FIELD to event.version.toString(),
TIMESTAMP_FIELD to event.timestamp.toString(),
EVENT_DATA_FIELD to eventData
)
}
override fun deserialize(data: Map<String, String>): DomainEvent {
val eventType = getEventType(data)
val eventClass = eventTypeToClass[eventType]
?: throw IllegalArgumentException("Unknown event type: $eventType")
val eventData = data[EVENT_DATA_FIELD]
?: throw IllegalArgumentException("Event data is missing")
return objectMapper.readValue(eventData, eventClass)
}
override fun getEventType(event: DomainEvent): String {
// Use the registered type if available
val registeredType = eventClassToType[event.javaClass]
if (registeredType != null) {
return registeredType
}
// Otherwise, use the simple class name
val type = event.javaClass.simpleName
registerEventType(event.javaClass, type)
return type
}
override fun getEventType(data: Map<String, String>): String {
return data[EVENT_TYPE_FIELD]
?: throw IllegalArgumentException("Event type is missing")
}
override fun registerEventType(eventClass: Class<out DomainEvent>, eventType: String) {
eventTypeToClass[eventType] = eventClass
eventClassToType[eventClass] = eventType
logger.debug("Registered event type: $eventType for class: ${eventClass.name}")
}
override fun getAggregateId(data: Map<String, String>): UUID {
val aggregateIdStr = data[AGGREGATE_ID_FIELD]
?: throw IllegalArgumentException("Aggregate ID is missing")
return UUID.fromString(aggregateIdStr)
}
override fun getEventId(data: Map<String, String>): UUID {
val eventIdStr = data[EVENT_ID_FIELD]
?: throw IllegalArgumentException("Event ID is missing")
return UUID.fromString(eventIdStr)
}
override fun getVersion(data: Map<String, String>): Long {
val versionStr = data[VERSION_FIELD]
?: throw IllegalArgumentException("Version is missing")
return versionStr.toLong()
}
}
@@ -0,0 +1,314 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.DomainEvent
import at.mocode.infrastructure.eventstore.api.EventSerializer
import org.slf4j.LoggerFactory
import org.springframework.data.domain.Range
import org.springframework.data.redis.connection.stream.Consumer
import org.springframework.data.redis.connection.stream.MapRecord
import org.springframework.data.redis.connection.stream.ReadOffset
import org.springframework.data.redis.connection.stream.StreamOffset
import org.springframework.data.redis.connection.stream.StreamReadOptions
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.scheduling.annotation.Scheduled
import java.time.Duration
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import javax.annotation.PostConstruct
import javax.annotation.PreDestroy
/**
* Consumer for Redis Streams that processes events using consumer groups.
*/
class RedisEventConsumer(
private val redisTemplate: StringRedisTemplate,
private val serializer: EventSerializer,
private val properties: RedisEventStoreProperties
) {
private val logger = LoggerFactory.getLogger(RedisEventConsumer::class.java)
// Event handlers registered for specific event types
private val eventTypeHandlers = ConcurrentHashMap<String, CopyOnWriteArrayList<(DomainEvent) -> Unit>>()
// Event handlers registered for all events
private val allEventHandlers = CopyOnWriteArrayList<(DomainEvent) -> Unit>()
// Flag to indicate if the consumer is running
private var running = false
/**
* Initializes the consumer.
*/
@PostConstruct
fun init() {
if (properties.createConsumerGroupIfNotExists) {
createConsumerGroupsIfNotExist()
}
}
/**
* Stops the consumer.
*/
@PreDestroy
fun shutdown() {
running = false
}
/**
* Registers a handler for a specific event type.
*
* @param eventType The type of event to handle
* @param handler The handler to call when an event of the specified type is received
*/
fun registerEventHandler(eventType: String, handler: (DomainEvent) -> Unit) {
eventTypeHandlers.computeIfAbsent(eventType) { CopyOnWriteArrayList() }.add(handler)
logger.debug("Registered handler for event type: $eventType")
}
/**
* Registers a handler for all events.
*
* @param handler The handler to call when any event is received
*/
fun registerAllEventsHandler(handler: (DomainEvent) -> Unit) {
allEventHandlers.add(handler)
logger.debug("Registered handler for all events")
}
/**
* Unregisters a handler for a specific event type.
*
* @param eventType The type of event
* @param handler The handler to unregister
*/
fun unregisterEventHandler(eventType: String, handler: (DomainEvent) -> Unit) {
eventTypeHandlers[eventType]?.remove(handler)
logger.debug("Unregistered handler for event type: $eventType")
}
/**
* Unregisters a handler for all events.
*
* @param handler The handler to unregister
*/
fun unregisterAllEventsHandler(handler: (DomainEvent) -> Unit) {
allEventHandlers.remove(handler)
logger.debug("Unregistered handler for all events")
}
/**
* Creates consumer groups for all streams if they don't exist.
*/
private fun createConsumerGroupsIfNotExist() {
try {
// Create consumer group for the all events stream
val allEventsStreamKey = getAllEventsStreamKey()
createConsumerGroupIfNotExists(allEventsStreamKey)
// Get all stream keys
val streamKeys = redisTemplate.keys("${properties.streamPrefix}*")
// Create consumer groups for all streams
for (streamKey in streamKeys) {
if (streamKey != allEventsStreamKey) {
createConsumerGroupIfNotExists(streamKey)
}
}
} catch (e: Exception) {
logger.error("Error creating consumer groups: ${e.message}", e)
}
}
/**
* Creates a consumer group for a stream if it doesn't exist.
*
* @param streamKey The key of the stream
*/
private fun createConsumerGroupIfNotExists(streamKey: String) {
try {
// Check if the stream exists
if (!redisTemplate.hasKey(streamKey)) {
// Create the stream with an empty message
redisTemplate.opsForStream<String, String>()
.add(streamKey, mapOf("init" to "init"))
logger.debug("Created stream: $streamKey")
}
// Create the consumer group
redisTemplate.opsForStream<String, String>()
.createGroup(streamKey, properties.consumerGroup)
logger.debug("Created consumer group ${properties.consumerGroup} for stream: $streamKey")
} catch (e: Exception) {
// Ignore if the consumer group already exists
val message = e.message
if (message == null || !message.contains("BUSYGROUP")) {
logger.error("Error creating consumer group for stream $streamKey: ${e.message}", e)
}
}
}
/**
* Periodically polls for new events from all streams.
*/
@Scheduled(fixedDelayString = "\${redis.event-store.poll-interval:100}")
fun pollEvents() {
if (!running) {
running = true
}
try {
// Poll the all events stream
pollStream(getAllEventsStreamKey())
// Poll individual streams
val streamKeys = redisTemplate.keys("${properties.streamPrefix}*")
for (streamKey in streamKeys) {
if (streamKey != getAllEventsStreamKey()) {
pollStream(streamKey)
}
}
// Claim pending messages that have been idle for too long
claimPendingMessages()
} catch (e: Exception) {
logger.error("Error polling events: ${e.message}", e)
}
}
/**
* Polls a stream for new events.
*
* @param streamKey The key of the stream to poll
*/
private fun pollStream(streamKey: String) {
try {
// Read new messages from the stream
val options = StreamReadOptions.empty()
.count(properties.maxBatchSize.toLong())
.block(properties.pollTimeout)
val records = redisTemplate.opsForStream<String, String>()
.read(
Consumer.from(properties.consumerGroup, properties.consumerName),
options,
StreamOffset.create(streamKey, ReadOffset.lastConsumed())
)
// Process the records
if (records != null) {
for (record in records) {
processRecord(record)
}
}
} catch (e: Exception) {
// Ignore if the stream doesn't exist or the consumer group doesn't exist
val message = e.message
if (message == null || !message.contains("NOGROUP")) {
logger.error("Error polling stream $streamKey: ${e.message}", e)
}
}
}
/**
* Claims pending messages that have been idle for too long.
*/
private fun claimPendingMessages() {
try {
// Get all stream keys
val streamKeys = redisTemplate.keys("${properties.streamPrefix}*")
for (streamKey in streamKeys) {
// Get pending messages summary
val pendingSummary = redisTemplate.opsForStream<String, String>()
.pending(streamKey, properties.consumerGroup)
if (pendingSummary != null && pendingSummary.totalPendingMessages > 0) {
// Get pending messages with details
val pendingMessages = redisTemplate.opsForStream<String, String>()
.pending(
streamKey,
Consumer.from(properties.consumerGroup, properties.consumerName),
Range.unbounded<String>(),
properties.maxBatchSize.toLong()
)
if (pendingMessages.size() > 0) {
// Extract message IDs and convert to array
val messageIdsList = pendingMessages.map { it.id }.toList()
if (messageIdsList.isNotEmpty()) {
// Convert to array for the spread operator
val messageIds = messageIdsList.toTypedArray()
// Claim messages that have been idle for too long
val records = redisTemplate.opsForStream<String, String>()
.claim(
streamKey,
properties.consumerGroup,
properties.consumerName,
properties.claimIdleTimeout,
*messageIds
)
// Process the claimed records
for (record in records) {
processRecord(record)
}
}
}
}
}
} catch (e: Exception) {
logger.error("Error claiming pending messages: ${e.message}", e)
}
}
/**
* Processes a record from a stream.
*
* @param record The record to process
*/
private fun processRecord(record: MapRecord<String, String, String>) {
try {
val data = record.value
val event = serializer.deserialize(data)
val eventType = serializer.getEventType(data)
// Call handlers for the specific event type
eventTypeHandlers[eventType]?.forEach { handler ->
try {
handler(event)
} catch (e: Exception) {
logger.error("Error handling event of type $eventType: ${e.message}", e)
}
}
// Call handlers for all events
allEventHandlers.forEach { handler ->
try {
handler(event)
} catch (e: Exception) {
logger.error("Error handling event: ${e.message}", e)
}
}
// Acknowledge the message
redisTemplate.opsForStream<String, String>()
.acknowledge(properties.consumerGroup, record)
} catch (e: Exception) {
logger.error("Error processing record: ${e.message}", e)
}
}
/**
* Gets the Redis key for the all events stream.
*
* @return The Redis key for the all events stream
*/
private fun getAllEventsStreamKey(): String {
return "${properties.streamPrefix}${properties.allEventsStream}"
}
}
@@ -0,0 +1,336 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.DomainEvent
import at.mocode.infrastructure.eventstore.api.ConcurrencyException
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore
import at.mocode.infrastructure.eventstore.api.Subscription
import org.slf4j.LoggerFactory
import org.springframework.data.redis.connection.stream.MapRecord
import org.springframework.data.redis.connection.stream.ObjectRecord
import org.springframework.data.redis.connection.stream.ReadOffset
import org.springframework.data.redis.connection.stream.Record
import org.springframework.data.redis.connection.stream.StreamOffset
import org.springframework.data.redis.connection.stream.StreamReadOptions
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.data.redis.stream.StreamListener
import org.springframework.data.redis.stream.StreamMessageListenerContainer
import org.springframework.data.redis.stream.Subscription as RedisSubscription
import java.time.Duration
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/**
* Redis Streams implementation of EventStore.
*/
class RedisEventStore(
private val redisTemplate: StringRedisTemplate,
private val serializer: EventSerializer,
private val properties: RedisEventStoreProperties
) : EventStore {
private val logger = LoggerFactory.getLogger(RedisEventStore::class.java)
// Cache of stream versions to avoid reading from Redis for every append
private val streamVersionCache = ConcurrentHashMap<UUID, Long>()
// Active subscriptions
private val subscriptions = ConcurrentHashMap<UUID, RedisSubscription>()
// Listener containers for subscriptions
private val listenerContainers = ConcurrentHashMap<UUID, StreamMessageListenerContainer<String, MapRecord<String, String, String>>>()
override fun appendToStream(event: DomainEvent, streamId: UUID, expectedVersion: Long): Long {
return appendToStream(listOf(event), streamId, expectedVersion)
}
override fun appendToStream(events: List<DomainEvent>, streamId: UUID, expectedVersion: Long): Long {
if (events.isEmpty()) {
return expectedVersion
}
// Check if all events belong to the same aggregate
val aggregateId = events.first().aggregateId
if (events.any { it.aggregateId != aggregateId }) {
throw IllegalArgumentException("All events must belong to the same aggregate")
}
// Check if the stream ID matches the aggregate ID
if (streamId != aggregateId) {
throw IllegalArgumentException("Stream ID must match aggregate ID")
}
// Get the current version of the stream
val currentVersion = getStreamVersion(streamId)
// Check for concurrency conflicts
if (expectedVersion != currentVersion) {
throw ConcurrencyException(
"Concurrency conflict: expected version $expectedVersion but got $currentVersion"
)
}
// Append events to the stream
var newVersion = currentVersion
val streamKey = getStreamKey(streamId)
for (event in events) {
newVersion++
// Ensure the event has the correct version
if (event.version != newVersion) {
throw IllegalArgumentException(
"Event version ${event.version} does not match expected version $newVersion"
)
}
// Serialize the event
val eventData = serializer.serialize(event)
// Append to the stream
val result = redisTemplate.opsForStream<String, String>()
.add(streamKey, eventData)
logger.debug("Appended event ${event.eventId} to stream $streamId with ID $result")
// Also append to the all events stream
val allEventsStreamKey = getAllEventsStreamKey()
redisTemplate.opsForStream<String, String>()
.add(allEventsStreamKey, eventData)
}
// Update the version cache
streamVersionCache[streamId] = newVersion
return newVersion
}
override fun readFromStream(streamId: UUID, fromVersion: Long, toVersion: Long?): List<DomainEvent> {
val streamKey = getStreamKey(streamId)
// Check if the stream exists
if (!redisTemplate.hasKey(streamKey)) {
return emptyList()
}
// Calculate the range of events to read
val startOffset = if (fromVersion <= 0) ReadOffset.from("0") else ReadOffset.from("$fromVersion")
val endOffset = toVersion?.let { "=$it" } ?: "+"
// Read events from the stream
val options = StreamReadOptions.empty()
.count(toVersion?.let { (it - fromVersion + 1).toLong() } ?: Long.MAX_VALUE)
val records = redisTemplate.opsForStream<String, String>()
.read(options, StreamOffset.create(streamKey, startOffset))
// Deserialize events
return records?.mapNotNull { record ->
try {
val data = record.value
serializer.deserialize(data)
} catch (e: Exception) {
logger.error("Error deserializing event from stream $streamId: ${e.message}", e)
null
}
} ?: emptyList()
}
override fun readAllEvents(fromPosition: Long, maxCount: Int?): List<DomainEvent> {
val streamKey = getAllEventsStreamKey()
// Check if the stream exists
if (!redisTemplate.hasKey(streamKey)) {
return emptyList()
}
// Calculate the range of events to read
val startOffset = if (fromPosition <= 0) ReadOffset.from("0") else ReadOffset.from("$fromPosition")
// Read events from the stream
val options = StreamReadOptions.empty()
.count(maxCount?.toLong() ?: Long.MAX_VALUE)
val records = redisTemplate.opsForStream<String, String>()
.read(options, StreamOffset.create(streamKey, startOffset))
// Deserialize events
return records?.mapNotNull { record ->
try {
val data = record.value
serializer.deserialize(data)
} catch (e: Exception) {
logger.error("Error deserializing event from all events stream: ${e.message}", e)
null
}
} ?: emptyList()
}
override fun getStreamVersion(streamId: UUID): Long {
// Check the cache first
val cachedVersion = streamVersionCache[streamId]
if (cachedVersion != null) {
return cachedVersion
}
val streamKey = getStreamKey(streamId)
// Check if the stream exists
if (!redisTemplate.hasKey(streamKey)) {
return -1
}
// Get the last event from the stream
val options = StreamReadOptions.empty().count(1)
val records = redisTemplate.opsForStream<String, String>()
.read(options, StreamOffset.create(streamKey, ReadOffset.latest()))
if (records == null || records.isEmpty()) {
return -1
}
// Get the version from the last event
val lastEvent = records.first()
val version = serializer.getVersion(lastEvent.value)
// Update the cache
streamVersionCache[streamId] = version
return version
}
override fun subscribeToStream(
streamId: UUID,
fromVersion: Long,
handler: (DomainEvent) -> Unit
): Subscription {
val streamKey = getStreamKey(streamId)
// Create a unique ID for this subscription
val subscriptionId = UUID.randomUUID()
// Create a listener for the stream
val listener = StreamListener<String, MapRecord<String, String, String>> { record ->
try {
val data = record.value
val event = serializer.deserialize(data)
handler(event)
} catch (e: Exception) {
logger.error("Error handling event from stream $streamId: ${e.message}", e)
}
}
// Create a listener container
val container = StreamMessageListenerContainer
.create(redisTemplate.connectionFactory!!)
// Start from the specified version
val readOffset = if (fromVersion <= 0) ReadOffset.latest() else ReadOffset.from("$fromVersion")
// Create a subscription
val subscription = container.receive(
StreamOffset.create(streamKey, readOffset),
listener
)
// Start the container
container.start()
// Store the subscription and container
subscriptions[subscriptionId] = subscription
listenerContainers[subscriptionId] = container
// Return a subscription object
return object : Subscription {
private val active = AtomicBoolean(true)
override fun unsubscribe() {
if (active.compareAndSet(true, false)) {
subscription.cancel()
container.stop()
subscriptions.remove(subscriptionId)
listenerContainers.remove(subscriptionId)
}
}
override fun isActive(): Boolean {
return active.get()
}
}
}
override fun subscribeToAll(fromPosition: Long, handler: (DomainEvent) -> Unit): Subscription {
val streamKey = getAllEventsStreamKey()
// Create a unique ID for this subscription
val subscriptionId = UUID.randomUUID()
// Create a listener for the stream
val listener = StreamListener<String, MapRecord<String, String, String>> { record ->
try {
val data = record.value
val event = serializer.deserialize(data)
handler(event)
} catch (e: Exception) {
logger.error("Error handling event from all events stream: ${e.message}", e)
}
}
// Create a listener container
val container = StreamMessageListenerContainer
.create(redisTemplate.connectionFactory!!)
// Start from the specified position
val readOffset = if (fromPosition <= 0) ReadOffset.latest() else ReadOffset.from("$fromPosition")
// Create a subscription
val subscription = container.receive(
StreamOffset.create(streamKey, readOffset),
listener
)
// Start the container
container.start()
// Store the subscription and container
subscriptions[subscriptionId] = subscription
listenerContainers[subscriptionId] = container
// Return a subscription object
return object : Subscription {
private val active = AtomicBoolean(true)
override fun unsubscribe() {
if (active.compareAndSet(true, false)) {
subscription.cancel()
container.stop()
subscriptions.remove(subscriptionId)
listenerContainers.remove(subscriptionId)
}
}
override fun isActive(): Boolean {
return active.get()
}
}
}
/**
* Gets the Redis key for a stream.
*
* @param streamId The ID of the stream
* @return The Redis key for the stream
*/
private fun getStreamKey(streamId: UUID): String {
return "${properties.streamPrefix}$streamId"
}
/**
* Gets the Redis key for the all events stream.
*
* @return The Redis key for the all events stream
*/
private fun getAllEventsStreamKey(): String {
return "${properties.streamPrefix}${properties.allEventsStream}"
}
}
@@ -0,0 +1,136 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.connection.RedisPassword
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.StringRedisTemplate
import java.time.Duration
/**
* Redis event store properties.
*/
@ConfigurationProperties(prefix = "redis.event-store")
data class RedisEventStoreProperties(
val host: String = "localhost",
val port: Int = 6379,
val password: String? = null,
val database: Int = 0,
val connectionTimeout: Long = 2000,
val readTimeout: Long = 2000,
val usePooling: Boolean = true,
val maxPoolSize: Int = 8,
val minPoolSize: Int = 2,
val consumerGroup: String = "event-processors",
val consumerName: String = "event-consumer",
val streamPrefix: String = "event-stream:",
val allEventsStream: String = "all-events",
val claimIdleTimeout: Duration = Duration.ofMinutes(1),
val pollTimeout: Duration = Duration.ofMillis(100),
val maxBatchSize: Int = 100,
val createConsumerGroupIfNotExists: Boolean = true
)
/**
* Spring configuration for Redis event store.
*/
@Configuration
@EnableConfigurationProperties(RedisEventStoreProperties::class)
class RedisEventStoreConfiguration {
/**
* Creates a Redis connection factory for the event store.
*
* @param properties Redis event store properties
* @return Redis connection factory
*/
@Bean
@ConditionalOnMissingBean(name = ["eventStoreRedisConnectionFactory"])
fun eventStoreRedisConnectionFactory(properties: RedisEventStoreProperties): RedisConnectionFactory {
val config = RedisStandaloneConfiguration().apply {
hostName = properties.host
port = properties.port
properties.password?.let { password = RedisPassword.of(it) }
database = properties.database
}
return LettuceConnectionFactory(config).apply {
// Configure connection timeouts
afterPropertiesSet()
}
}
/**
* Creates a Redis template for the event store.
*
* @param connectionFactory Redis connection factory
* @return Redis template
*/
@Bean
@ConditionalOnMissingBean(name = ["eventStoreRedisTemplate"])
fun eventStoreRedisTemplate(
@org.springframework.beans.factory.annotation.Qualifier("eventStoreRedisConnectionFactory")
connectionFactory: RedisConnectionFactory
): StringRedisTemplate {
return StringRedisTemplate().apply {
setConnectionFactory(connectionFactory)
afterPropertiesSet()
}
}
/**
* Creates an event serializer.
*
* @return Event serializer
*/
@Bean
@ConditionalOnMissingBean
fun eventSerializer(): EventSerializer {
return JacksonEventSerializer()
}
/**
* Creates a Redis event store.
*
* @param redisTemplate Redis template
* @param eventSerializer Event serializer
* @param properties Redis event store properties
* @return Event store
*/
@Bean
@ConditionalOnMissingBean
fun eventStore(
@org.springframework.beans.factory.annotation.Qualifier("eventStoreRedisTemplate")
redisTemplate: StringRedisTemplate,
eventSerializer: EventSerializer,
properties: RedisEventStoreProperties
): EventStore {
return RedisEventStore(redisTemplate, eventSerializer, properties)
}
/**
* Creates a Redis event consumer.
*
* @param redisTemplate Redis template
* @param eventSerializer Event serializer
* @param properties Redis event store properties
* @return Event consumer
*/
@Bean
@ConditionalOnMissingBean
fun eventConsumer(
@org.springframework.beans.factory.annotation.Qualifier("eventStoreRedisTemplate")
redisTemplate: StringRedisTemplate,
eventSerializer: EventSerializer,
properties: RedisEventStoreProperties
): RedisEventConsumer {
return RedisEventConsumer(redisTemplate, eventSerializer, properties)
}
}
@@ -0,0 +1,296 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.event.DomainEvent
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore
import at.mocode.infrastructure.eventstore.api.Subscription
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.StringRedisTemplate
import org.testcontainers.containers.GenericContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName
import java.time.Duration
import java.time.Instant
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Integration tests for Redis Event Store.
*
* These tests verify the interaction between the Redis Event Store, Event Consumer, and Event Serializer
* in a more realistic scenario.
*/
@Testcontainers
class RedisEventStoreIntegrationTest {
companion object {
@Container
val redisContainer = GenericContainer(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379)
}
private lateinit var redisTemplate: StringRedisTemplate
private lateinit var serializer: EventSerializer
private lateinit var properties: RedisEventStoreProperties
private lateinit var eventStore: EventStore
private lateinit var eventConsumer: RedisEventConsumer
@BeforeEach
fun setUp() {
val redisPort = redisContainer.getMappedPort(6379)
val redisHost = redisContainer.host
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
val connectionFactory = LettuceConnectionFactory(redisConfig)
connectionFactory.afterPropertiesSet()
redisTemplate = StringRedisTemplate()
redisTemplate.setConnectionFactory(connectionFactory)
redisTemplate.afterPropertiesSet()
serializer = JacksonEventSerializer()
// Register test event types
serializer.registerEventType(TestCreatedEvent::class.java, "TestCreated")
serializer.registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
properties = RedisEventStoreProperties(
host = redisHost,
port = redisPort,
streamPrefix = "test-stream:",
allEventsStream = "all-events",
consumerGroup = "test-group",
consumerName = "test-consumer",
createConsumerGroupIfNotExists = true
)
eventStore = RedisEventStore(redisTemplate, serializer, properties)
eventConsumer = RedisEventConsumer(redisTemplate, serializer, properties)
// Clear all streams
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (keys != null && keys.isNotEmpty()) {
redisTemplate.delete(keys)
}
}
@AfterEach
fun tearDown() {
// Clear all streams
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (keys != null && keys.isNotEmpty()) {
redisTemplate.delete(keys)
}
}
@Test
fun `test event publishing and consuming with consumer groups`() {
// Create an aggregate ID
val aggregateId = UUID.randomUUID()
// Create events
val event1 = TestCreatedEvent(
aggregateId = aggregateId,
version = 1,
name = "Test Entity"
)
val event2 = TestUpdatedEvent(
aggregateId = aggregateId,
version = 2,
name = "Updated Test Entity"
)
// Set up a latch to wait for events
val latch = CountDownLatch(2)
val receivedEvents = mutableListOf<DomainEvent>()
// Register a handler for TestCreatedEvent
eventConsumer.registerEventHandler("TestCreated") { event ->
receivedEvents.add(event)
latch.countDown()
}
// Register a handler for TestUpdatedEvent
eventConsumer.registerEventHandler("TestUpdated") { event ->
receivedEvents.add(event)
latch.countDown()
}
// Initialize the consumer
eventConsumer.init()
// Append events to the stream
eventStore.appendToStream(event1, aggregateId, -1)
eventStore.appendToStream(event2, aggregateId, 1)
// Manually trigger event polling
eventConsumer.pollEvents()
// Wait for events to be processed
assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for events")
// Verify that both events were received
assertEquals(2, receivedEvents.size)
// Verify the first event
val receivedEvent1 = receivedEvents[0] as TestCreatedEvent
assertEquals(aggregateId, receivedEvent1.aggregateId)
assertEquals(1, receivedEvent1.version)
assertEquals("Test Entity", receivedEvent1.name)
// Verify the second event
val receivedEvent2 = receivedEvents[1] as TestUpdatedEvent
assertEquals(aggregateId, receivedEvent2.aggregateId)
assertEquals(2, receivedEvent2.version)
assertEquals("Updated Test Entity", receivedEvent2.name)
// Clean up
eventConsumer.shutdown()
}
@Test
fun `test event subscription and publishing`() {
// Create an aggregate ID
val aggregateId = UUID.randomUUID()
// Set up a latch to wait for events
val latch = CountDownLatch(2)
val receivedEvents = mutableListOf<DomainEvent>()
// Subscribe to the stream
val subscription = eventStore.subscribeToStream(aggregateId) { event ->
receivedEvents.add(event)
latch.countDown()
}
// Create events
val event1 = TestCreatedEvent(
aggregateId = aggregateId,
version = 1,
name = "Test Entity"
)
val event2 = TestUpdatedEvent(
aggregateId = aggregateId,
version = 2,
name = "Updated Test Entity"
)
// Append events to the stream
eventStore.appendToStream(event1, aggregateId, -1)
eventStore.appendToStream(event2, aggregateId, 1)
// Wait for events to be received
assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for events")
// Verify that both events were received
assertEquals(2, receivedEvents.size)
// Verify the first event
val receivedEvent1 = receivedEvents[0] as TestCreatedEvent
assertEquals(aggregateId, receivedEvent1.aggregateId)
assertEquals(1, receivedEvent1.version)
assertEquals("Test Entity", receivedEvent1.name)
// Verify the second event
val receivedEvent2 = receivedEvents[1] as TestUpdatedEvent
assertEquals(aggregateId, receivedEvent2.aggregateId)
assertEquals(2, receivedEvent2.version)
assertEquals("Updated Test Entity", receivedEvent2.name)
// Clean up
subscription.unsubscribe()
}
@Test
fun `test multiple consumers with consumer groups`() {
// Create an aggregate ID
val aggregateId = UUID.randomUUID()
// Create events
val event1 = TestCreatedEvent(
aggregateId = aggregateId,
version = 1,
name = "Test Entity"
)
val event2 = TestUpdatedEvent(
aggregateId = aggregateId,
version = 2,
name = "Updated Test Entity"
)
// Set up latches to wait for events
val latch1 = CountDownLatch(2)
val latch2 = CountDownLatch(2)
val receivedEvents1 = mutableListOf<DomainEvent>()
val receivedEvents2 = mutableListOf<DomainEvent>()
// Create a second consumer with a different consumer name
val properties2 = properties.copy(consumerName = "test-consumer-2")
val eventConsumer2 = RedisEventConsumer(redisTemplate, serializer, properties2)
// Register handlers for the first consumer
eventConsumer.registerAllEventsHandler { event ->
receivedEvents1.add(event)
latch1.countDown()
}
// Register handlers for the second consumer
eventConsumer2.registerAllEventsHandler { event ->
receivedEvents2.add(event)
latch2.countDown()
}
// Initialize the consumers
eventConsumer.init()
eventConsumer2.init()
// Append events to the stream
eventStore.appendToStream(event1, aggregateId, -1)
eventStore.appendToStream(event2, aggregateId, 1)
// Manually trigger event polling
eventConsumer.pollEvents()
eventConsumer2.pollEvents()
// Wait for events to be processed by both consumers
assertTrue(latch1.await(5, TimeUnit.SECONDS), "Timed out waiting for events on consumer 1")
assertTrue(latch2.await(5, TimeUnit.SECONDS), "Timed out waiting for events on consumer 2")
// Verify that both consumers received both events
assertEquals(2, receivedEvents1.size)
assertEquals(2, receivedEvents2.size)
// Clean up
eventConsumer.shutdown()
eventConsumer2.shutdown()
}
// Test event classes
class TestCreatedEvent(
override val eventId: UUID = UUID.randomUUID(),
override val timestamp: Instant = Instant.now(),
override val aggregateId: UUID,
override val version: Long,
val name: String
) : BaseDomainEvent(eventId, timestamp, aggregateId, version)
class TestUpdatedEvent(
override val eventId: UUID = UUID.randomUUID(),
override val timestamp: Instant = Instant.now(),
override val aggregateId: UUID,
override val version: Long,
val name: String
) : BaseDomainEvent(eventId, timestamp, aggregateId, version)
}
@@ -0,0 +1,317 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.event.DomainEvent
import at.mocode.infrastructure.eventstore.api.ConcurrencyException
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.Subscription
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.StringRedisTemplate
import org.testcontainers.containers.GenericContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName
import java.time.Instant
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@Testcontainers
class RedisEventStoreTest {
companion object {
@Container
val redisContainer = GenericContainer(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379)
}
private lateinit var redisTemplate: StringRedisTemplate
private lateinit var serializer: EventSerializer
private lateinit var properties: RedisEventStoreProperties
private lateinit var eventStore: RedisEventStore
@BeforeEach
fun setUp() {
val redisPort = redisContainer.getMappedPort(6379)
val redisHost = redisContainer.host
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
val connectionFactory = LettuceConnectionFactory(redisConfig)
connectionFactory.afterPropertiesSet()
redisTemplate = StringRedisTemplate()
redisTemplate.setConnectionFactory(connectionFactory)
redisTemplate.afterPropertiesSet()
serializer = JacksonEventSerializer()
// Register test event types
serializer.registerEventType(TestCreatedEvent::class.java, "TestCreated")
serializer.registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
properties = RedisEventStoreProperties(
host = redisHost,
port = redisPort,
streamPrefix = "test-stream:",
allEventsStream = "all-events"
)
eventStore = RedisEventStore(redisTemplate, serializer, properties)
// Clear all streams
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (keys != null && keys.isNotEmpty()) {
redisTemplate.delete(keys)
}
}
@AfterEach
fun tearDown() {
// Clear all streams
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (keys != null && keys.isNotEmpty()) {
redisTemplate.delete(keys)
}
}
@Test
fun `test append and read events`() {
val aggregateId = UUID.randomUUID()
// Create events
val event1 = TestCreatedEvent(
aggregateId = aggregateId,
version = 1,
name = "Test Entity"
)
val event2 = TestUpdatedEvent(
aggregateId = aggregateId,
version = 2,
name = "Updated Test Entity"
)
// Append events
val version1 = eventStore.appendToStream(event1, aggregateId, -1)
assertEquals(1, version1)
val version2 = eventStore.appendToStream(event2, aggregateId, 1)
assertEquals(2, version2)
// Read events
val events = eventStore.readFromStream(aggregateId)
assertEquals(2, events.size)
val firstEvent = events[0] as TestCreatedEvent
assertEquals(aggregateId, firstEvent.aggregateId)
assertEquals(1, firstEvent.version)
assertEquals("Test Entity", firstEvent.name)
val secondEvent = events[1] as TestUpdatedEvent
assertEquals(aggregateId, secondEvent.aggregateId)
assertEquals(2, secondEvent.version)
assertEquals("Updated Test Entity", secondEvent.name)
}
@Test
fun `test append events with concurrency conflict`() {
val aggregateId = UUID.randomUUID()
// Create events
val event1 = TestCreatedEvent(
aggregateId = aggregateId,
version = 1,
name = "Test Entity"
)
val event2 = TestUpdatedEvent(
aggregateId = aggregateId,
version = 2,
name = "Updated Test Entity"
)
// Append first event
val version1 = eventStore.appendToStream(event1, aggregateId, -1)
assertEquals(1, version1)
// Try to append second event with wrong expected version
assertThrows<ConcurrencyException> {
eventStore.appendToStream(event2, aggregateId, 0)
}
// Append second event with correct expected version
val version2 = eventStore.appendToStream(event2, aggregateId, 1)
assertEquals(2, version2)
}
@Test
fun `test append multiple events at once`() {
val aggregateId = UUID.randomUUID()
// Create events
val event1 = TestCreatedEvent(
aggregateId = aggregateId,
version = 1,
name = "Test Entity"
)
val event2 = TestUpdatedEvent(
aggregateId = aggregateId,
version = 2,
name = "Updated Test Entity"
)
// Append events
val version = eventStore.appendToStream(listOf(event1, event2), aggregateId, -1)
assertEquals(2, version)
// Read events
val events = eventStore.readFromStream(aggregateId)
assertEquals(2, events.size)
}
@Test
fun `test read all events`() {
val aggregate1Id = UUID.randomUUID()
val aggregate2Id = UUID.randomUUID()
// Create events for first aggregate
val event1 = TestCreatedEvent(
aggregateId = aggregate1Id,
version = 1,
name = "Test Entity 1"
)
val event2 = TestUpdatedEvent(
aggregateId = aggregate1Id,
version = 2,
name = "Updated Test Entity 1"
)
// Create events for second aggregate
val event3 = TestCreatedEvent(
aggregateId = aggregate2Id,
version = 1,
name = "Test Entity 2"
)
// Append events
eventStore.appendToStream(event1, aggregate1Id, -1)
eventStore.appendToStream(event2, aggregate1Id, 1)
eventStore.appendToStream(event3, aggregate2Id, -1)
// Read all events
val allEvents = eventStore.readAllEvents()
assertEquals(3, allEvents.size)
}
@Test
fun `test subscribe to stream`() {
val aggregateId = UUID.randomUUID()
val latch = CountDownLatch(2)
val receivedEvents = mutableListOf<DomainEvent>()
// Subscribe to stream
val subscription = eventStore.subscribeToStream(aggregateId) { event ->
receivedEvents.add(event)
latch.countDown()
}
// Create events
val event1 = TestCreatedEvent(
aggregateId = aggregateId,
version = 1,
name = "Test Entity"
)
val event2 = TestUpdatedEvent(
aggregateId = aggregateId,
version = 2,
name = "Updated Test Entity"
)
// Append events
eventStore.appendToStream(event1, aggregateId, -1)
eventStore.appendToStream(event2, aggregateId, 1)
// Wait for events to be received
assertTrue(latch.await(5, TimeUnit.SECONDS))
assertEquals(2, receivedEvents.size)
// Unsubscribe
subscription.unsubscribe()
assertFalse(subscription.isActive())
}
@Test
fun `test subscribe to all events`() {
val aggregate1Id = UUID.randomUUID()
val aggregate2Id = UUID.randomUUID()
val latch = CountDownLatch(3)
val receivedEvents = mutableListOf<DomainEvent>()
// Subscribe to all events
val subscription = eventStore.subscribeToAll { event ->
receivedEvents.add(event)
latch.countDown()
}
// Create events for first aggregate
val event1 = TestCreatedEvent(
aggregateId = aggregate1Id,
version = 1,
name = "Test Entity 1"
)
val event2 = TestUpdatedEvent(
aggregateId = aggregate1Id,
version = 2,
name = "Updated Test Entity 1"
)
// Create events for second aggregate
val event3 = TestCreatedEvent(
aggregateId = aggregate2Id,
version = 1,
name = "Test Entity 2"
)
// Append events
eventStore.appendToStream(event1, aggregate1Id, -1)
eventStore.appendToStream(event2, aggregate1Id, 1)
eventStore.appendToStream(event3, aggregate2Id, -1)
// Wait for events to be received
assertTrue(latch.await(5, TimeUnit.SECONDS))
assertEquals(3, receivedEvents.size)
// Unsubscribe
subscription.unsubscribe()
assertFalse(subscription.isActive())
}
// Test event classes
class TestCreatedEvent(
override val eventId: UUID = UUID.randomUUID(),
override val timestamp: Instant = Instant.now(),
override val aggregateId: UUID,
override val version: Long,
val name: String
) : BaseDomainEvent(eventId, timestamp, aggregateId, version)
class TestUpdatedEvent(
override val eventId: UUID = UUID.randomUUID(),
override val timestamp: Instant = Instant.now(),
override val aggregateId: UUID,
override val version: Long,
val name: String
) : BaseDomainEvent(eventId, timestamp, aggregateId, version)
}
@@ -0,0 +1,241 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.event.DomainEvent
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore
import at.mocode.infrastructure.eventstore.api.Subscription
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.StringRedisTemplate
import org.testcontainers.containers.GenericContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName
import java.time.Instant
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Integration tests for Redis Event Store and Event Consumer.
*
* These tests verify the interaction between the Redis Event Store, Event Consumer, and Event Serializer
* in a more realistic scenario.
*/
@Testcontainers
class RedisIntegrationTest {
companion object {
@Container
val redisContainer = GenericContainer(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379)
}
private lateinit var redisTemplate: StringRedisTemplate
private lateinit var serializer: EventSerializer
private lateinit var properties: RedisEventStoreProperties
private lateinit var eventStore: EventStore
private lateinit var eventConsumer: RedisEventConsumer
@BeforeEach
fun setUp() {
val redisPort = redisContainer.getMappedPort(6379)
val redisHost = redisContainer.host
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
val connectionFactory = LettuceConnectionFactory(redisConfig)
connectionFactory.afterPropertiesSet()
redisTemplate = StringRedisTemplate()
redisTemplate.setConnectionFactory(connectionFactory)
redisTemplate.afterPropertiesSet()
serializer = JacksonEventSerializer()
// Register test event types
serializer.registerEventType(TestCreatedEvent::class.java, "TestCreated")
serializer.registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
properties = RedisEventStoreProperties(
host = redisHost,
port = redisPort,
streamPrefix = "test-stream:",
allEventsStream = "all-events",
consumerGroup = "test-group",
consumerName = "test-consumer",
createConsumerGroupIfNotExists = true
)
eventStore = RedisEventStore(redisTemplate, serializer, properties)
eventConsumer = RedisEventConsumer(redisTemplate, serializer, properties)
// Clear all streams
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (keys != null && keys.isNotEmpty()) {
redisTemplate.delete(keys)
}
}
@AfterEach
fun tearDown() {
// Clear all streams
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (keys != null && keys.isNotEmpty()) {
redisTemplate.delete(keys)
}
}
@Test
fun `test event publishing and consuming with consumer groups`() {
// Create an aggregate ID
val aggregateId = UUID.randomUUID()
// Create events
val event1 = TestCreatedEvent(
aggregateId = aggregateId,
version = 1,
name = "Test Entity"
)
val event2 = TestUpdatedEvent(
aggregateId = aggregateId,
version = 2,
name = "Updated Test Entity"
)
// Set up a latch to wait for events
val latch = CountDownLatch(2)
val receivedEvents = mutableListOf<DomainEvent>()
// Register a handler for TestCreatedEvent
eventConsumer.registerEventHandler("TestCreated") { event ->
receivedEvents.add(event)
latch.countDown()
}
// Register a handler for TestUpdatedEvent
eventConsumer.registerEventHandler("TestUpdated") { event ->
receivedEvents.add(event)
latch.countDown()
}
// Initialize the consumer
eventConsumer.init()
// Append events to the stream
eventStore.appendToStream(event1, aggregateId, -1)
eventStore.appendToStream(event2, aggregateId, 1)
// Manually trigger event polling
eventConsumer.pollEvents()
// Wait for events to be processed
assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for events")
// Verify that both events were received
assertEquals(2, receivedEvents.size)
// Verify the first event
val receivedEvent1 = receivedEvents[0] as TestCreatedEvent
assertEquals(aggregateId, receivedEvent1.aggregateId)
assertEquals(1, receivedEvent1.version)
assertEquals("Test Entity", receivedEvent1.name)
// Verify the second event
val receivedEvent2 = receivedEvents[1] as TestUpdatedEvent
assertEquals(aggregateId, receivedEvent2.aggregateId)
assertEquals(2, receivedEvent2.version)
assertEquals("Updated Test Entity", receivedEvent2.name)
// Clean up
eventConsumer.shutdown()
}
@Test
fun `test multiple consumers with consumer groups`() {
// Create an aggregate ID
val aggregateId = UUID.randomUUID()
// Create events
val event1 = TestCreatedEvent(
aggregateId = aggregateId,
version = 1,
name = "Test Entity"
)
val event2 = TestUpdatedEvent(
aggregateId = aggregateId,
version = 2,
name = "Updated Test Entity"
)
// Set up latches to wait for events
val latch1 = CountDownLatch(2)
val latch2 = CountDownLatch(2)
val receivedEvents1 = mutableListOf<DomainEvent>()
val receivedEvents2 = mutableListOf<DomainEvent>()
// Create a second consumer with a different consumer name
val properties2 = properties.copy(consumerName = "test-consumer-2")
val eventConsumer2 = RedisEventConsumer(redisTemplate, serializer, properties2)
// Register handlers for the first consumer
eventConsumer.registerAllEventsHandler { event ->
receivedEvents1.add(event)
latch1.countDown()
}
// Register handlers for the second consumer
eventConsumer2.registerAllEventsHandler { event ->
receivedEvents2.add(event)
latch2.countDown()
}
// Initialize the consumers
eventConsumer.init()
eventConsumer2.init()
// Append events to the stream
eventStore.appendToStream(event1, aggregateId, -1)
eventStore.appendToStream(event2, aggregateId, 1)
// Manually trigger event polling
eventConsumer.pollEvents()
eventConsumer2.pollEvents()
// Wait for events to be processed by both consumers
assertTrue(latch1.await(5, TimeUnit.SECONDS), "Timed out waiting for events on consumer 1")
assertTrue(latch2.await(5, TimeUnit.SECONDS), "Timed out waiting for events on consumer 2")
// Verify that both consumers received both events
assertEquals(2, receivedEvents1.size)
assertEquals(2, receivedEvents2.size)
// Clean up
eventConsumer.shutdown()
eventConsumer2.shutdown()
}
// Test event classes
class TestCreatedEvent(
override val eventId: UUID = UUID.randomUUID(),
override val timestamp: Instant = Instant.now(),
override val aggregateId: UUID,
override val version: Long,
val name: String
) : BaseDomainEvent(eventId, timestamp, aggregateId, version)
class TestUpdatedEvent(
override val eventId: UUID = UUID.randomUUID(),
override val timestamp: Instant = Instant.now(),
override val aggregateId: UUID,
override val version: Long,
val name: String
) : BaseDomainEvent(eventId, timestamp, aggregateId, version)
}