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:
+119
@@ -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()
|
||||
}
|
||||
}
|
||||
+314
@@ -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}"
|
||||
}
|
||||
}
|
||||
+336
@@ -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}"
|
||||
}
|
||||
}
|
||||
+136
@@ -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)
|
||||
}
|
||||
}
|
||||
+296
@@ -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)
|
||||
}
|
||||
+317
@@ -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)
|
||||
}
|
||||
+241
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user