refactor: update docker images to use custom registry and optimize configurations

Switched container images in `dc-infra.yaml` to a custom Docker registry for better control and consistency across deployments. Added Keycloak with enhanced configurations and updated several container restart policies, memory allocations, and healthcheck settings for improved performance and compatibility.
This commit is contained in:
2026-02-12 18:52:03 +01:00
parent 523c1fef0b
commit 7757684b6e
36 changed files with 3274 additions and 3149 deletions
@@ -11,7 +11,7 @@ import kotlin.uuid.Uuid
interface EventSerializer {
/**
* Serialisiert ein Domain-Event zu einer Map von Strings zu Strings.
* Dieses Format ist für die Speicherung in Redis Streams geeignet.
* Dieses Format ist für die Speicherung in Valkey Streams geeignet.
*
* @param event Das zu serialisierende Event
* @return Eine Map von Strings zu Strings, die das Event repräsentiert
@@ -1,69 +0,0 @@
// Dieses Modul stellt eine konkrete Implementierung der `event-store-api`
// unter Verwendung von Redis Streams als Event-Store-Backend bereit.
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependencyManagement)
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll(
"-opt-in=kotlin.time.ExperimentalTime",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlin.uuid.ExperimentalUuidApi"
)
}
}
dependencies {
// === Core Dependencies ===
// Stellt sicher, dass alle Versionen aus der zentralen BOM kommen
implementation(platform(projects.platform.platformBom))
// Implementiert die provider-agnostische Event-Store-API
api(projects.backend.infrastructure.eventStore.eventStoreApi)
// Benötigt Zugriff auf Core-Module für Domänen-Events und Utilities
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
// === Redis & Spring Dependencies ===
// OPTIMIERUNG: Wiederverwendung des `redis-cache`-Bundles, da es die
// gleichen Technologien (Spring Data Redis, Lettuce, Jackson) verwendet
implementation(libs.bundles.valkey.cache)
// Stellt Jakarta Annotations bereit (z. B. @PostConstruct), die von Spring verwendet werden
implementation(libs.jakarta.annotation.api)
// Für Kotlin-spezifische Coroutines-Integration mit Spring
implementation(libs.kotlinx.coroutines.reactor)
// === Test Dependencies ===
// Fügt JUnit, Mockk, AssertJ etc. für die Tests hinzu
testImplementation(projects.platform.platformTesting)
testImplementation(libs.bundles.testing.jvm)
testImplementation(libs.bundles.testcontainers)
// Zusätzliche Test-Dependencies für erweiterte Event-Store-Tests
testImplementation(libs.kotlinx.serialization.json)
testImplementation(libs.reactor.test)
// Für Integration Tests mit beiden Redis-Modulen
testImplementation(projects.backend.infrastructure.cache.cacheApi)
testImplementation(projects.backend.infrastructure.cache.valkeyCache)
}
// === Task Configuration ===
// Deaktiviert die Erstellung eines ausführbaren Jars für dieses Bibliotheks-Modul
tasks.bootJar {
enabled = false
}
// Stellt sicher, dass stattdessen ein reguläres Jar gebaut wird
tasks.jar {
enabled = true
archiveClassifier.set("")
}
// Optimiert die Test-Ausführung
tasks.test {
useJUnitPlatform()
// Verbesserte Test-Performance für Testcontainer
systemProperty("testcontainers.reuse.enable", "true")
// Parallelisierung für bessere Performance
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
}
@@ -1,240 +0,0 @@
package at.mocode.infrastructure.eventstore.redis
import org.slf4j.LoggerFactory
import java.time.Duration
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.LongAdder
/**
* Umfassende Metriken-Verfolgung für Redis Event-Store-Operationen.
*
* Verfolgt Performance-Metriken, Fehlerquoten und Betriebsstatistiken,
* um Einblicke in die Gesundheit und Performance des Event-Stores zu geben.
*/
class EventStoreMetrics {
private val logger = LoggerFactory.getLogger(EventStoreMetrics::class.java)
// Operation counters
private val appendOperations = LongAdder()
private val appendBatchOperations = LongAdder()
private val readOperations = LongAdder()
private val subscriptionOperations = LongAdder()
// Success/Error tracking
private val successfulOperations = LongAdder()
private val failedOperations = LongAdder()
private val concurrencyExceptions = LongAdder()
// Performance metrics
private val totalOperationTime = LongAdder()
private val maxOperationTime = AtomicLong(0)
private val operationTimestamps = ConcurrentHashMap<String, Instant>()
// Cache metrics
private val cacheHits = LongAdder()
private val cacheMisses = LongAdder()
// Event statistics
private val totalEventsAppended = LongAdder()
private val totalEventsRead = LongAdder()
private val lastMetricsReport = AtomicLong(System.currentTimeMillis())
/**
* Records the start of an operation for timing purposes.
*/
fun startOperation(operationId: String) {
operationTimestamps[operationId] = Instant.now()
}
/**
* Records a successful append operation.
*/
fun recordAppendSuccess(operationId: String, eventCount: Int = 1, isBatch: Boolean = false) {
recordOperationEnd(operationId, true)
appendOperations.increment()
if (isBatch) appendBatchOperations.increment()
totalEventsAppended.add(eventCount.toLong())
logger.debug("[METRICS] Append operation completed successfully. Events: {}, Batch: {}", eventCount, isBatch)
}
/**
* Records a failed append operation.
*/
fun recordAppendFailure(operationId: String, error: Throwable? = null, isConcurrencyException: Boolean = false) {
recordOperationEnd(operationId, false)
if (isConcurrencyException) {
concurrencyExceptions.increment()
}
logger.debug("[METRICS] Append operation failed. Concurrency conflict: {}, Error: {}",
isConcurrencyException, error?.message ?: "Unknown")
}
/**
* Records a successful read operation.
*/
fun recordReadSuccess(operationId: String, eventCount: Int) {
recordOperationEnd(operationId, true)
readOperations.increment()
totalEventsRead.add(eventCount.toLong())
logger.debug("[METRICS] Read operation completed successfully. Events: {}", eventCount)
}
/**
* Records a failed read operation.
*/
fun recordReadFailure(operationId: String, error: Throwable? = null) {
recordOperationEnd(operationId, false)
logger.debug("[METRICS] Read operation failed. Error: {}", error?.message ?: "Unknown")
}
/**
* Records a cache hit.
*/
fun recordCacheHit() {
cacheHits.increment()
}
/**
* Records a cache miss.
*/
fun recordCacheMiss() {
cacheMisses.increment()
}
/**
* Records a subscription operation.
*/
fun recordSubscription() {
subscriptionOperations.increment()
logger.debug("[METRICS] New subscription created")
}
private fun recordOperationEnd(operationId: String, success: Boolean) {
val startTime = operationTimestamps.remove(operationId)
if (startTime != null) {
val duration = Duration.between(startTime, Instant.now())
val durationMs = duration.toMillis()
totalOperationTime.add(durationMs)
maxOperationTime.updateAndGet { current -> maxOf(current, durationMs) }
if (success) {
successfulOperations.increment()
} else {
failedOperations.increment()
}
}
}
/**
* Gets comprehensive metrics summary.
*/
fun getMetrics(): EventStoreMetricsSnapshot {
val totalOps = successfulOperations.sum() + failedOperations.sum()
val successRate = if (totalOps > 0) (successfulOperations.sum().toDouble() / totalOps * 100) else 0.0
val avgOperationTime = if (totalOps > 0) (totalOperationTime.sum().toDouble() / totalOps) else 0.0
val cacheHitRate = run {
val totalCacheOps = cacheHits.sum() + cacheMisses.sum()
if (totalCacheOps > 0) (cacheHits.sum().toDouble() / totalCacheOps * 100) else 0.0
}
return EventStoreMetricsSnapshot(
totalOperations = totalOps,
successfulOperations = successfulOperations.sum(),
failedOperations = failedOperations.sum(),
successRate = successRate,
appendOperations = appendOperations.sum(),
batchAppendOperations = appendBatchOperations.sum(),
readOperations = readOperations.sum(),
subscriptionOperations = subscriptionOperations.sum(),
concurrencyExceptions = concurrencyExceptions.sum(),
totalEventsAppended = totalEventsAppended.sum(),
totalEventsRead = totalEventsRead.sum(),
averageOperationTimeMs = avgOperationTime,
maxOperationTimeMs = maxOperationTime.get(),
cacheHits = cacheHits.sum(),
cacheMisses = cacheMisses.sum(),
cacheHitRate = cacheHitRate
)
}
/**
* Logs performance metrics if enough time has passed since the last report.
*/
fun logPerformanceMetrics() {
val now = System.currentTimeMillis()
val lastReport = lastMetricsReport.get()
// Log metrics every 5 minutes
if (now - lastReport > 300_000) {
if (lastMetricsReport.compareAndSet(lastReport, now)) {
val metrics = getMetrics()
logger.info("[PERFORMANCE_METRICS] {}", metrics.toLogString())
}
}
}
/**
* Resets all metrics. Useful for testing.
*/
internal fun reset() {
appendOperations.reset()
appendBatchOperations.reset()
readOperations.reset()
subscriptionOperations.reset()
successfulOperations.reset()
failedOperations.reset()
concurrencyExceptions.reset()
totalOperationTime.reset()
maxOperationTime.set(0)
operationTimestamps.clear()
cacheHits.reset()
cacheMisses.reset()
totalEventsAppended.reset()
totalEventsRead.reset()
lastMetricsReport.set(System.currentTimeMillis())
}
}
/**
* Immutable snapshot of event store metrics at a point in time.
*/
data class EventStoreMetricsSnapshot(
val totalOperations: Long,
val successfulOperations: Long,
val failedOperations: Long,
val successRate: Double,
val appendOperations: Long,
val batchAppendOperations: Long,
val readOperations: Long,
val subscriptionOperations: Long,
val concurrencyExceptions: Long,
val totalEventsAppended: Long,
val totalEventsRead: Long,
val averageOperationTimeMs: Double,
val maxOperationTimeMs: Long,
val cacheHits: Long,
val cacheMisses: Long,
val cacheHitRate: Double
) {
fun toLogString(): String {
return "EventStore Metrics: " +
"Operations=${totalOperations}, " +
"Success Rate=${String.format("%.1f%%", successRate)}, " +
"Appends=${appendOperations} (${batchAppendOperations} batches), " +
"Reads=${readOperations}, " +
"Subscriptions=${subscriptionOperations}, " +
"Events Appended=${totalEventsAppended}, " +
"Events Read=${totalEventsRead}, " +
"Avg Time=${String.format("%.1fms", averageOperationTimeMs)}, " +
"Max Time=${maxOperationTimeMs}ms, " +
"Cache Hit Rate=${String.format("%.1f%%", cacheHitRate)}, " +
"Concurrency Conflicts=${concurrencyExceptions}"
}
}
@@ -1,99 +0,0 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
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 org.slf4j.LoggerFactory
import java.util.concurrent.ConcurrentHashMap
import kotlin.uuid.Uuid
/**
* Jackson-basierte Implementierung des EventSerializer.
*/
class JacksonEventSerializer : EventSerializer {
private val logger = LoggerFactory.getLogger(JacksonEventSerializer::class.java)
private val objectMapper: ObjectMapper = ObjectMapper().apply {
registerModule(kotlinModule())
registerModule(JavaTimeModule())
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
}
private val eventTypeToClass = ConcurrentHashMap<String, Class<out DomainEvent>>()
private val eventClassToType = ConcurrentHashMap<Class<out DomainEvent>, String>()
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)
if (!eventClassToType.containsKey(event.javaClass)) {
registerEventType(event.javaClass, eventType)
}
val eventData = objectMapper.writeValueAsString(event)
return mapOf(
EVENT_TYPE_FIELD to eventType,
EVENT_ID_FIELD to event.eventId.value.toString(),
AGGREGATE_ID_FIELD to event.aggregateId.value.toString(),
VERSION_FIELD to event.version.value.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 {
return eventClassToType[event.javaClass] ?: event.javaClass.simpleName
}
override fun getEventType(data: Map<String, String>): String {
return data[EVENT_TYPE_FIELD] ?: throw IllegalArgumentException("Event type is missing")
}
// KORRIGIERT: Parameterreihenfolge umgedreht
override fun registerEventType(eventClass: Class<out DomainEvent>, eventType: String) {
eventTypeToClass[eventType] = eventClass
eventClassToType[eventClass] = eventType
logger.debug("Registered event type: {} for class: {}", eventType, eventClass.name)
}
override fun getAggregateId(data: Map<String, String>): Uuid {
val aggregateIdStr = data[AGGREGATE_ID_FIELD]
?: throw IllegalArgumentException("Aggregate ID is missing")
return Uuid.parse(aggregateIdStr)
}
override fun getEventId(data: Map<String, String>): Uuid {
val eventIdStr = data[EVENT_ID_FIELD]
?: throw IllegalArgumentException("Event ID is missing")
return Uuid.parse(eventIdStr)
}
override fun getVersion(data: Map<String, String>): Long {
val versionStr = data[VERSION_FIELD]
?: throw IllegalArgumentException("Version is missing")
return versionStr.toLong()
}
}
@@ -1,313 +0,0 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.DomainEvent
import at.mocode.core.domain.model.EventVersion
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.dao.DataAccessException
import org.springframework.data.domain.Range
import org.springframework.data.redis.core.SessionCallback
import org.springframework.data.redis.core.StringRedisTemplate
import java.util.concurrent.ConcurrentHashMap
import kotlin.uuid.Uuid
class RedisEventStore(
private val redisTemplate: StringRedisTemplate,
private val serializer: EventSerializer,
private val properties: RedisEventStoreProperties
) : EventStore {
private val logger = LoggerFactory.getLogger(RedisEventStore::class.java)
private val streamVersionCache = ConcurrentHashMap<Uuid, Long>()
private val metrics = EventStoreMetrics()
override fun appendToStream(events: List<DomainEvent>, streamId: Uuid, expectedVersion: Long): Long {
val operationId = "batch-append-${System.nanoTime()}"
metrics.startOperation(operationId)
try {
if (events.isEmpty()) {
logger.debug("Empty event list provided for stream {}, returning current version", streamId)
val version = getStreamVersion(streamId)
metrics.recordAppendSuccess(operationId, 0, true)
return version
}
val aggregateId = events.first().aggregateId
require(events.all { it.aggregateId == aggregateId }) {
"All events must belong to the same aggregate. Expected: $aggregateId, but found mixed aggregate IDs"
}
require(streamId == aggregateId.value) {
"Stream ID $streamId must match aggregate ID ${aggregateId.value}"
}
logger.debug("Appending {} events to stream {} with expected version {}", events.size, streamId, expectedVersion)
val currentVersion = validateAndGetCurrentVersion(streamId, expectedVersion)
val newVersion = appendEventsInBatch(events, streamId, currentVersion)
logger.info("Successfully appended {} events to stream {}. New version: {}", events.size, streamId, newVersion)
metrics.recordAppendSuccess(operationId, events.size, true)
metrics.logPerformanceMetrics()
return newVersion
} catch (e: ConcurrencyException) {
metrics.recordAppendFailure(operationId, e, true)
throw e
} catch (e: Exception) {
metrics.recordAppendFailure(operationId, e, false)
throw e
}
}
override fun appendToStream(event: DomainEvent, streamId: Uuid, expectedVersion: Long): Long {
val operationId = "single-append-${System.nanoTime()}"
metrics.startOperation(operationId)
try {
logger.debug("Appending single event to stream {} with expected version {}", streamId, expectedVersion)
val currentVersion = validateAndGetCurrentVersion(streamId, expectedVersion)
val newVersion = appendToStreamInternal(event, streamId, currentVersion)
logger.info("Successfully appended event to stream {}. New version: {}", streamId, newVersion)
metrics.recordAppendSuccess(operationId, 1, false)
metrics.logPerformanceMetrics()
return newVersion
} catch (e: ConcurrencyException) {
metrics.recordAppendFailure(operationId, e, true)
throw e
} catch (e: Exception) {
metrics.recordAppendFailure(operationId, e, false)
throw e
}
}
/**
* Validiert die erwartete Version und gibt die aktuelle Version zurück, behandelt Cache-Invalidierung bei Konflikten.
*/
private fun validateAndGetCurrentVersion(streamId: Uuid, expectedVersion: Long): Long {
var currentVersion = getStreamVersion(streamId)
if (currentVersion != expectedVersion) {
logger.warn("Version conflict detected for stream {}. Expected: {}, current: {}", streamId, expectedVersion, currentVersion)
streamVersionCache.remove(streamId) // Invalidate cache on conflict
val actualVersion = getStreamVersion(streamId) // Re-fetch from Redis
if (actualVersion != expectedVersion) {
throw ConcurrencyException("Concurrency conflict for stream $streamId: expected version $expectedVersion but got $actualVersion")
}
currentVersion = actualVersion
}
return currentVersion
}
/**
* Fügt mehrere Events in einer einzigen Redis-Transaktion hinzu für optimale Performance.
*/
private fun appendEventsInBatch(events: List<DomainEvent>, streamId: Uuid, currentVersion: Long): Long {
val streamKey = getStreamKey(streamId)
val allEventsStreamKey = getAllEventsStreamKey()
// Validate all events have correct sequential versions
events.forEachIndexed { index, event ->
val expectedVersion = currentVersion + index + 1
require(event.version.value == expectedVersion) {
"Event ${index} version ${event.version.value} does not match expected version $expectedVersion for stream $streamId"
}
}
logger.debug("Writing {} events to stream {} and all-events stream in single transaction", events.size, streamId)
try {
redisTemplate.execute(object : SessionCallback<List<Any>> {
@Throws(DataAccessException::class)
override fun <K : Any?, V : Any?> execute(operations: org.springframework.data.redis.core.RedisOperations<K, V>): List<Any> {
val streamOps = (operations as StringRedisTemplate).opsForStream<String, String>()
operations.multi()
// Add all events to both streams in a single transaction
events.forEach { event ->
val eventData = serializer.serialize(event)
streamOps.add(streamKey, eventData)
streamOps.add(allEventsStreamKey, eventData)
}
return operations.exec()
}
})
val newVersion = currentVersion + events.size
streamVersionCache[streamId] = newVersion
logger.debug("Successfully wrote {} events to Redis streams in batch, updated cache version to {}", events.size, newVersion)
return newVersion
} catch (e: Exception) {
logger.error("Failed to append {} events in batch for stream {}: {}", events.size, streamId, e.message, e)
streamVersionCache.remove(streamId)
throw e
}
}
private fun appendToStreamInternal(event: DomainEvent, streamId: Uuid, currentVersion: Long): Long {
val newVersion = currentVersion + 1
require(event.version.value == newVersion) {
"Event version ${event.version.value} does not match expected new version $newVersion for stream $streamId"
}
val streamKey = getStreamKey(streamId)
val allEventsStreamKey = getAllEventsStreamKey()
val eventData = serializer.serialize(event)
logger.debug("Writing event {} to stream {} and all-events stream atomically", event.eventId, streamId)
try {
redisTemplate.execute(object : SessionCallback<List<Any>> {
@Throws(DataAccessException::class)
override fun <K : Any?, V : Any?> execute(operations: org.springframework.data.redis.core.RedisOperations<K, V>): List<Any> {
val streamOps = (operations as StringRedisTemplate).opsForStream<String, String>()
operations.multi()
streamOps.add(streamKey, eventData)
streamOps.add(allEventsStreamKey, eventData)
return operations.exec()
}
})
streamVersionCache[streamId] = newVersion
logger.debug("Successfully wrote event {} to Redis streams, updated cache version to {}", event.eventId, newVersion)
return newVersion
} catch (e: Exception) {
logger.error("Failed to append event {} transactionally for stream {}: {}", event.eventId, streamId, e.message, e)
streamVersionCache.remove(streamId)
throw e
}
}
override fun readFromStream(streamId: Uuid, fromVersion: Long, toVersion: Long?): List<DomainEvent> {
val operationId = "read-stream-${System.nanoTime()}"
metrics.startOperation(operationId)
try {
val streamKey = getStreamKey(streamId)
val range = Range.of(Range.Bound.inclusive("-"), Range.Bound.unbounded())
val records = redisTemplate.opsForStream<String, String>().range(streamKey, range)
val events = records?.mapNotNull { record ->
try {
serializer.deserialize(record.value)
} catch (e: Exception) {
logger.error("Error deserializing event from stream {}: {}", streamId, e.message, e)
null
}
} ?: emptyList()
val filteredEvents = events.filter { it.version >= EventVersion(fromVersion) && (toVersion == null || it.version <= EventVersion(toVersion)) }
metrics.recordReadSuccess(operationId, filteredEvents.size)
return filteredEvents
} catch (e: Exception) {
metrics.recordReadFailure(operationId, e)
throw e
}
}
override fun getStreamVersion(streamId: Uuid): Long {
streamVersionCache[streamId]?.let {
metrics.recordCacheHit()
return it
}
metrics.recordCacheMiss()
val streamKey = getStreamKey(streamId)
val size = redisTemplate.opsForStream<String, String>().size(streamKey) ?: 0L
streamVersionCache[streamId] = size
return size
}
private fun getStreamKey(streamId: Uuid): String {
return "${properties.streamPrefix}$streamId"
}
private fun getAllEventsStreamKey(): String {
return "${properties.streamPrefix}${properties.allEventsStream}"
}
override fun readAllEvents(fromPosition: Long, maxCount: Int?): List<DomainEvent> {
val operationId = "read-all-events-${System.nanoTime()}"
metrics.startOperation(operationId)
try {
val allEventsStreamKey = getAllEventsStreamKey()
val range = Range.of(Range.Bound.inclusive("-"), Range.Bound.unbounded())
val records = redisTemplate.opsForStream<String, String>().range(allEventsStreamKey, range)
val events = records?.mapNotNull { record ->
try {
serializer.deserialize(record.value)
} catch (e: Exception) {
logger.error("Error deserializing event from all events stream: {}", e.message, e)
null
}
} ?: emptyList()
val filteredEvents = events.drop(fromPosition.toInt())
val result = if (maxCount != null && maxCount > 0) {
filteredEvents.take(maxCount)
} else {
filteredEvents
}
metrics.recordReadSuccess(operationId, result.size)
return result
} catch (e: Exception) {
metrics.recordReadFailure(operationId, e)
throw e
}
}
override fun subscribeToStream(streamId: Uuid, fromVersion: Long, handler: (DomainEvent) -> Unit): Subscription {
// Basic implementation - for full functionality, integrate with RedisEventConsumer
logger.info("Stream subscription for streamId {} from version {} - basic implementation", streamId, fromVersion)
metrics.recordSubscription()
return BasicSubscription {
logger.info("Unsubscribed from stream {}", streamId)
}
}
override fun subscribeToAll(fromPosition: Long, handler: (DomainEvent) -> Unit): Subscription {
// Basic implementation - for full functionality, integrate with RedisEventConsumer
logger.info("All events subscription from position {} - basic implementation", fromPosition)
metrics.recordSubscription()
return BasicSubscription {
logger.info("Unsubscribed from all events")
}
}
}
/**
* Basic subscription implementation.
*/
private class BasicSubscription(
private val unsubscribeAction: () -> Unit
) : Subscription {
@Volatile
private var active = true
override fun unsubscribe() {
if (active) {
active = false
unsubscribeAction()
}
}
override fun isActive(): Boolean = active
}
@@ -1,136 +0,0 @@
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 Eigenschaften.
*/
@ConfigurationProperties(prefix = "redis.event-store")
data class RedisEventStoreProperties(
var host: String = "localhost",
var port: Int = 6379,
var password: String? = null,
var database: Int = 0,
var connectionTimeout: Long = 2000,
var readTimeout: Long = 2000,
var usePooling: Boolean = true,
var maxPoolSize: Int = 8,
var minPoolSize: Int = 2,
var consumerGroup: String = "event-processors",
var consumerName: String = "event-consumer",
var streamPrefix: String = "event-stream:",
var allEventsStream: String = "all-events",
var claimIdleTimeout: Duration = Duration.ofMinutes(1),
var pollTimeout: Duration = Duration.ofMillis(100),
var maxBatchSize: Int = 100,
var createConsumerGroupIfNotExists: Boolean = true
)
/**
* Spring-Konfiguration für Redis Event Store.
*/
@Configuration
@EnableConfigurationProperties(RedisEventStoreProperties::class)
class RedisEventStoreConfiguration {
/**
* Erstellt eine Redis-Verbindungsfactory für den Event Store.
*
* @param properties Redis Event Store Eigenschaften
* @return Redis-Verbindungsfactory
*/
@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()
}
}
/**
* Erstellt ein Redis-Template für den Event Store.
*
* @param connectionFactory Redis-Verbindungsfactory
* @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()
}
}
/**
* Erstellt einen Event-Serializer.
*
* @return Event-Serializer
*/
@Bean
@ConditionalOnMissingBean
fun eventSerializer(): EventSerializer {
return JacksonEventSerializer()
}
/**
* Erstellt einen Redis Event Store.
*
* @param redisTemplate Redis-Template
* @param eventSerializer Event-Serializer
* @param properties Redis Event Store Eigenschaften
* @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)
}
/**
* Erstellt einen Redis Event Consumer.
*
* @param redisTemplate Redis-Template
* @param eventSerializer Event-Serializer
* @param properties Redis Event Store Eigenschaften
* @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)
}
}
@@ -1,251 +0,0 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.DomainEvent
import at.mocode.core.domain.model.*
import at.mocode.infrastructure.cache.api.CacheConfiguration
import at.mocode.infrastructure.cache.api.DistributedCache
import at.mocode.infrastructure.cache.valkey.JacksonCacheSerializer
import at.mocode.infrastructure.cache.valkey.ValkeyConfiguration
import at.mocode.infrastructure.cache.valkey.ValkeyDistributedCache
import at.mocode.infrastructure.eventstore.api.EventStore
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.GenericContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName
import kotlin.time.Duration.Companion.minutes
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Integration Test zur Demonstration der gleichzeitigen Verwendung von
* redis-cache und redis-event-store im selben Service.
*
* Dieser Test zeigt:
* 1. Beide Module können ohne Konflikte gleichzeitig verwendet werden
* 2. Separate Redis Databases verhindern Daten-Überschneidungen
* 3. Separate Bean-Namen verhindern Bean-Konflikte
* 4. Beide Module arbeiten unabhängig voneinander
*/
@OptIn(ExperimentalUuidApi::class)
@SpringBootTest(
classes = [
RedisCacheAndEventStoreIntegrationTest.TestConfig::class
]
)
@Testcontainers
class RedisCacheAndEventStoreIntegrationTest {
companion object {
@Container
@JvmStatic
val redisContainer: GenericContainer<*> = GenericContainer(
DockerImageName.parse("redis:7-alpine")
).withExposedPorts(6379)
@DynamicPropertySource
@JvmStatic
fun configureProperties(registry: DynamicPropertyRegistry) {
// Cache Configuration (Database 0)
registry.add("redis.host") { redisContainer.host }
registry.add("redis.port") { redisContainer.getMappedPort(6379) }
registry.add("redis.database") { 0 }
// Event Store Configuration (Database 1)
registry.add("redis.event-store.host") { redisContainer.host }
registry.add("redis.event-store.port") { redisContainer.getMappedPort(6379) }
registry.add("redis.event-store.database") { 1 }
registry.add("redis.event-store.consumerGroup") { "test-group" }
}
@BeforeAll
@JvmStatic
fun setUp() {
println("[DEBUG_LOG] Starting Redis container for integration test")
redisContainer.start()
}
@AfterAll
@JvmStatic
fun tearDown() {
println("[DEBUG_LOG] Stopping Redis container")
redisContainer.stop()
}
}
@Configuration
@Import(
ValkeyConfiguration::class,
RedisEventStoreConfiguration::class
)
class TestConfig {
@Bean
fun distributedCache(
@Qualifier("valkeyTemplate") redisTemplate: RedisTemplate<String, ByteArray>,
cacheConfiguration: CacheConfiguration
): DistributedCache {
return ValkeyDistributedCache(
valkeyTemplate = redisTemplate,
serializer = JacksonCacheSerializer(),
config = cacheConfiguration
)
}
}
@Autowired
private lateinit var cache: DistributedCache
@Autowired
private lateinit var eventStore: EventStore
// Verify separate ConnectionFactories
@Autowired
@Qualifier("valkeyConnectionFactory")
private lateinit var cacheConnectionFactory: RedisConnectionFactory
@Autowired
@Qualifier("eventStoreRedisConnectionFactory")
private lateinit var eventStoreConnectionFactory: RedisConnectionFactory
@Test
fun `test both modules can be used simultaneously without conflicts`(): Unit = runBlocking {
println("[DEBUG_LOG] Testing simultaneous usage of cache and event store")
// Test Cache Operations
val cacheKey = "test-user-${Uuid.random()}"
val cacheData = TestUser("John Doe", 30)
println("[DEBUG_LOG] Cache: Storing data with key=$cacheKey")
cache.set(cacheKey, cacheData, ttl = 5.minutes)
val retrievedCacheData = cache.get(cacheKey, TestUser::class.java)
println("[DEBUG_LOG] Cache: Retrieved data=$retrievedCacheData")
assertNotNull(retrievedCacheData)
assertEquals(cacheData.name, retrievedCacheData!!.name)
assertEquals(cacheData.age, retrievedCacheData.age)
// Test Event Store Operations
val aggregateId = Uuid.random()
val event = TestEvent(
aggregateId = AggregateId(aggregateId),
eventType = EventType("UserCreated"),
data = mapOf("userId" to aggregateId.toString(), "name" to "Jane Doe")
)
println("[DEBUG_LOG] EventStore: Appending event for aggregateId=$aggregateId")
eventStore.appendToStream(event, aggregateId, 0L)
val loadedEvents = eventStore.readFromStream(aggregateId)
println("[DEBUG_LOG] EventStore: Loaded ${loadedEvents.size} events")
assertEquals(1, loadedEvents.size)
assertEquals(event.eventType, (loadedEvents[0] as TestEvent).eventType)
// Verify Cache and Event Store are independent
println("[DEBUG_LOG] Verifying cache and event store are independent")
// Cache should still work after event operations
val cacheStillWorks = cache.get(cacheKey, TestUser::class.java)
assertNotNull(cacheStillWorks)
println("[DEBUG_LOG] Cache still works: key=$cacheKey exists")
// Event store should still work after cache operations
val eventsStillWork = eventStore.readFromStream(aggregateId)
assertEquals(1, eventsStillWork.size)
println("[DEBUG_LOG] Event store still works: aggregateId=$aggregateId has ${eventsStillWork.size} events")
println("[DEBUG_LOG] Test completed successfully - Both modules work independently")
}
@Test
fun `test separate connection factories are used`() {
println("[DEBUG_LOG] Testing separate connection factories")
assertNotNull(cacheConnectionFactory)
assertNotNull(eventStoreConnectionFactory)
// The connection factories should be different instances
println("[DEBUG_LOG] Cache ConnectionFactory: ${cacheConnectionFactory.javaClass.simpleName}")
println("[DEBUG_LOG] EventStore ConnectionFactory: ${eventStoreConnectionFactory.javaClass.simpleName}")
// Both should be functional
val cacheConnection = cacheConnectionFactory.connection
val eventStoreConnection = eventStoreConnectionFactory.connection
assertNotNull(cacheConnection)
assertNotNull(eventStoreConnection)
// Different databases
println("[DEBUG_LOG] Cache uses database: ${cacheConnection.nativeConnection}")
println("[DEBUG_LOG] EventStore uses database: ${eventStoreConnection.nativeConnection}")
cacheConnection.close()
eventStoreConnection.close()
println("[DEBUG_LOG] Both connection factories are functional and independent")
}
@Test
fun `test data isolation between cache and event store`(): Unit = runBlocking {
println("[DEBUG_LOG] Testing data isolation between cache and event store")
val sharedKey = "shared-key-${Uuid.random()}"
// Store data in cache
cache.set(sharedKey, TestUser("Cache User", 25), ttl = 5.minutes)
println("[DEBUG_LOG] Stored data in cache with key=$sharedKey")
// Store event with same UUID in event store
val aggregateId = Uuid.random()
val event = TestEvent(
aggregateId = AggregateId(aggregateId),
eventType = EventType("TestEvent"),
data = mapOf("key" to sharedKey)
)
eventStore.appendToStream(event, aggregateId, 0L)
println("[DEBUG_LOG] Stored event in event store with aggregateId=$aggregateId")
// Both should be retrievable independently
val cachedUser = cache.get(sharedKey, TestUser::class.java)
val storedEvents = eventStore.readFromStream(aggregateId)
assertNotNull(cachedUser)
assertEquals(1, storedEvents.size)
println("[DEBUG_LOG] Data isolation verified:")
println("[DEBUG_LOG] - Cache retrieved: ${cachedUser?.name}")
println("[DEBUG_LOG] - Event store retrieved: ${storedEvents.size} events")
println("[DEBUG_LOG] Cache and Event Store use separate databases - no conflicts!")
}
// Test data classes
data class TestUser(
val name: String,
val age: Int
)
data class TestEvent(
override val aggregateId: AggregateId,
override val eventType: EventType,
val data: Map<String, String>,
override val eventId: EventId = EventId(Uuid.random()),
override val timestamp: kotlin.time.Instant = kotlin.time.Clock.System.now(),
override val version: EventVersion = EventVersion(1),
override val correlationId: CorrelationId? = null,
override val causationId: CausationId? = null
) : DomainEvent
}
@@ -1,509 +0,0 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.event.DomainEvent
import at.mocode.core.domain.model.AggregateId
import at.mocode.core.domain.model.EventType
import at.mocode.core.domain.model.EventVersion
import at.mocode.infrastructure.eventstore.api.EventSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
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.util.concurrent.*
import java.util.concurrent.atomic.AtomicInteger
import kotlin.uuid.Uuid
/**
* Consumer Resilience Tests - Important for Event-Processing reliability.
*/
@Testcontainers
class RedisEventConsumerResilienceTest {
private val logger = LoggerFactory.getLogger(RedisEventConsumerResilienceTest::class.java)
companion object {
@Container
val redisContainer: GenericContainer<*> = 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
private lateinit var consumer1: RedisEventConsumer
private lateinit var consumer2: 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(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(ResilienceTestEvent::class.java, "ResilienceTestEvent")
registerEventType(SlowTestEvent::class.java, "SlowTestEvent")
registerEventType(FailingTestEvent::class.java, "FailingTestEvent")
}
properties = RedisEventStoreProperties().apply {
streamPrefix = "test-stream:"
allEventsStream = "all-events"
consumerGroup = "resilience-test-group"
consumerName = "resilience-consumer-1"
claimIdleTimeout = java.time.Duration.ofMillis(100) // Short timeout for testing
pollTimeout = java.time.Duration.ofMillis(50)
maxBatchSize = 10
}
eventStore = RedisEventStore(redisTemplate, serializer, properties)
consumer1 = RedisEventConsumer(redisTemplate, serializer, properties)
// Create second consumer with different name for testing multiple consumers
val properties2 = RedisEventStoreProperties().apply {
streamPrefix = properties.streamPrefix
allEventsStream = properties.allEventsStream
consumerGroup = properties.consumerGroup
consumerName = "resilience-consumer-2"
claimIdleTimeout = properties.claimIdleTimeout
pollTimeout = properties.pollTimeout
maxBatchSize = properties.maxBatchSize
}
consumer2 = RedisEventConsumer(redisTemplate, serializer, properties2)
cleanupRedis()
}
@AfterEach
fun tearDown() {
try {
consumer1.shutdown()
consumer2.shutdown()
} catch (_: Exception) {
// Ignore shutdown errors in tests
}
cleanupRedis()
}
private fun cleanupRedis() {
try {
val streamKey = "${properties.streamPrefix}${properties.allEventsStream}"
// First, try to destroy the consumer group multiple times with retry logic
var attempts = 0
while (attempts < 3) {
try {
redisTemplate.opsForStream<String, String>()
.destroyGroup(streamKey, properties.consumerGroup)
logger.debug("Successfully destroyed consumer group: ${properties.consumerGroup}")
break
} catch (e: Exception) {
attempts++
if (e.message?.contains("NOGROUP") == true) {
// Group doesn't exist, which is fine
break
}
if (attempts < 3) {
Thread.sleep(100) // Wait before retry
} else {
logger.debug("Could not destroy consumer group after 3 attempts: ${e.message}")
}
}
}
// Wait for group destruction to complete
Thread.sleep(100)
// Then delete all stream-related keys
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
redisTemplate.delete(keys)
logger.debug("Deleted ${keys.size} Redis keys with prefix: ${properties.streamPrefix}")
}
// Wait for Redis operations to complete
Thread.sleep(200)
// Verify cleanup by checking if keys still exist
val remainingKeys = redisTemplate.keys("${properties.streamPrefix}*")
if (!remainingKeys.isNullOrEmpty()) {
logger.warn("Some keys still exist after cleanup: $remainingKeys")
// Force delete remaining keys
redisTemplate.delete(remainingKeys)
Thread.sleep(100)
}
} catch (e: Exception) {
logger.warn("Error during Redis cleanup: ${e.message}", e)
// Additional cleanup attempt
try {
Thread.sleep(200)
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
redisTemplate.delete(keys)
}
} catch (retryException: Exception) {
logger.warn("Retry cleanup also failed: ${retryException.message}")
}
}
}
@Test
fun `should handle multiple consumers processing events without conflicts`() {
val aggregateId = Uuid.random()
val latch = CountDownLatch(2)
val processedEvents = CopyOnWriteArrayList<DomainEvent>()
// Both consumers will process events
consumer1.registerEventHandler("ResilienceTestEvent") { event ->
processedEvents.add(event)
logger.debug("Consumer1 processed: {}", (event as ResilienceTestEvent).data)
latch.countDown()
}
consumer2.registerEventHandler("ResilienceTestEvent") { event ->
processedEvents.add(event)
logger.debug("Consumer2 processed: {}", (event as ResilienceTestEvent).data)
latch.countDown()
}
// Initialize both consumers
consumer1.init()
consumer2.init()
// Publish test events
val event1 = ResilienceTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "Multi consumer event 1"
)
val event2 = ResilienceTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(2L),
data = "Multi consumer event 2"
)
eventStore.appendToStream(listOf(event1, event2), aggregateId, 0)
// Let both consumers poll multiple times to ensure all events are processed
val executor = Executors.newFixedThreadPool(2)
executor.submit {
repeat(5) {
consumer1.pollEvents()
Thread.sleep(50)
}
}
executor.submit {
repeat(5) {
consumer2.pollEvents()
Thread.sleep(50)
}
}
// Wait for processing with increased timeout
assertTrue(latch.await(10, TimeUnit.SECONDS), "Events were not processed within timeout")
executor.shutdown()
// Verify that events were processed (by either consumer due to consumer groups)
assertTrue(processedEvents.size >= 2, "Expected at least 2 processed events, got ${processedEvents.size}")
println("[DEBUG_LOG] Processed ${processedEvents.size} events total")
}
@Test
fun `should handle consumer group creation and recovery`() {
// Test that a consumer group is created automatically during init()
val aggregateId = Uuid.random()
val latch = CountDownLatch(1)
val receivedEvents = CopyOnWriteArrayList<DomainEvent>()
// Register handler before init
consumer1.registerEventHandler("ResilienceTestEvent") { receivedEvent ->
receivedEvents.add(receivedEvent)
latch.countDown()
}
// Init should create consumer groups automatically
consumer1.init()
// Add an event after initialization
val event = ResilienceTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "Group creation test"
)
eventStore.appendToStream(event, aggregateId, 0)
// Consumer should be able to process events from the automatically created group
consumer1.pollEvents()
assertTrue(latch.await(3, TimeUnit.SECONDS), "Event was not processed")
assertEquals(1, receivedEvents.size)
assertEquals("Group creation test", (receivedEvents[0] as ResilienceTestEvent).data)
}
@Test
fun `should process events exactly once in consumer group`() {
val aggregateId = Uuid.random()
val numberOfEvents = 10
val processedEvents = ConcurrentHashMap<String, AtomicInteger>()
val latch = CountDownLatch(numberOfEvents)
// Register the same handler on both consumers
val handler = { event: DomainEvent ->
val testEvent = event as ResilienceTestEvent
processedEvents.computeIfAbsent(testEvent.data) { AtomicInteger(0) }.incrementAndGet()
logger.debug("Processed: {}", testEvent.data)
latch.countDown()
}
consumer1.registerEventHandler("ResilienceTestEvent", handler)
consumer2.registerEventHandler("ResilienceTestEvent", handler)
// Initialize both consumers
consumer1.init()
consumer2.init()
// Create and append events
val events = (1..numberOfEvents).map { i ->
ResilienceTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(i.toLong()),
data = "Exactly-once event $i"
)
}
eventStore.appendToStream(events, aggregateId, 0)
// Start polling from both consumers simultaneously
val executor = Executors.newFixedThreadPool(2)
executor.submit {
repeat(5) {
consumer1.pollEvents()
Thread.sleep(50)
}
}
executor.submit {
repeat(5) {
consumer2.pollEvents()
Thread.sleep(50)
}
}
// Wait for all events to be processed
assertTrue(latch.await(10, TimeUnit.SECONDS), "Not all events were processed in time")
executor.shutdown()
// Verify each event was processed exactly once across both consumers
assertEquals(numberOfEvents, processedEvents.size)
processedEvents.forEach { (eventData, count) ->
assertEquals(1, count.get(), "Event '$eventData' was processed ${count.get()} times instead of exactly once")
}
logger.debug("All {} events processed exactly once", numberOfEvents)
}
@Test
fun `should handle slow event handlers gracefully`() {
val aggregateId = Uuid.random()
val processedEvents = CopyOnWriteArrayList<String>()
val latch = CountDownLatch(3)
// Register a slow handler
consumer1.registerEventHandler("SlowTestEvent") { event ->
val slowEvent = event as SlowTestEvent
processedEvents.add("Started: ${slowEvent.data}")
Thread.sleep(slowEvent.processingTimeMs) // Simulate slow processing
processedEvents.add("Completed: ${slowEvent.data}")
latch.countDown()
}
consumer1.init()
// Create events with different processing times
val events = listOf(
SlowTestEvent(AggregateId(aggregateId), EventVersion(1L), "Fast event", 10),
SlowTestEvent(AggregateId(aggregateId), EventVersion(2L), "Medium event", 100),
SlowTestEvent(AggregateId(aggregateId), EventVersion(3L), "Slow event", 200)
)
eventStore.appendToStream(events, aggregateId, 0)
// Start processing
val startTime = System.currentTimeMillis()
consumer1.pollEvents()
// Wait for processing to complete
assertTrue(latch.await(10, TimeUnit.SECONDS), "Slow events were not processed within timeout")
val totalTime = System.currentTimeMillis() - startTime
// Verify all events were processed
assertEquals(6, processedEvents.size) // 3 started + 3 completed
assertTrue(processedEvents.contains("Started: Fast event"))
assertTrue(processedEvents.contains("Completed: Fast event"))
assertTrue(processedEvents.contains("Started: Medium event"))
assertTrue(processedEvents.contains("Completed: Medium event"))
assertTrue(processedEvents.contains("Started: Slow event"))
assertTrue(processedEvents.contains("Completed: Slow event"))
logger.debug("Processed {} slow events in {}ms", events.size, totalTime)
processedEvents.forEach { logger.debug("Event: {}", it) }
}
@Test
fun `should handle consumer restart correctly`() {
val aggregateId = Uuid.random()
val firstPhaseEvents = mutableListOf<DomainEvent>()
val secondPhaseEvents = mutableListOf<DomainEvent>()
// First processing session
val firstLatch = CountDownLatch(1)
consumer1.registerEventHandler("ResilienceTestEvent") { event ->
firstPhaseEvents.add(event)
firstLatch.countDown()
}
consumer1.init()
// Add and process the first event
val event1 = ResilienceTestEvent(AggregateId(aggregateId), EventVersion(1L), "Before restart")
eventStore.appendToStream(event1, aggregateId, 0)
consumer1.pollEvents()
assertTrue(firstLatch.await(3, TimeUnit.SECONDS), "First event not processed")
// Verify the first phase
assertEquals(1, firstPhaseEvents.size)
assertEquals("Before restart", (firstPhaseEvents[0] as ResilienceTestEvent).data)
// Simulate shutdown and restart - create new consumer to ensure a clean state
consumer1.shutdown()
// Create a fresh consumer instance for restart simulation
val restartedConsumer = RedisEventConsumer(redisTemplate, serializer, properties)
val secondLatch = CountDownLatch(1)
restartedConsumer.registerEventHandler("ResilienceTestEvent") { event ->
secondPhaseEvents.add(event)
secondLatch.countDown()
}
restartedConsumer.init()
// Add and process a second event after restart
val event2 = ResilienceTestEvent(AggregateId(aggregateId), EventVersion(2L), "After restart")
eventStore.appendToStream(event2, aggregateId, 1)
restartedConsumer.pollEvents()
assertTrue(secondLatch.await(3, TimeUnit.SECONDS), "Second event not processed after restart")
// Verify the second phase
assertEquals(1, secondPhaseEvents.size)
assertEquals("After restart", (secondPhaseEvents[0] as ResilienceTestEvent).data)
// Cleanup
restartedConsumer.shutdown()
logger.debug("Successfully handled consumer restart")
logger.debug("First phase events: {}", firstPhaseEvents.map { (it as ResilienceTestEvent).data })
logger.debug("Second phase events: {}", secondPhaseEvents.map { (it as ResilienceTestEvent).data })
}
@Test
fun `should handle event handler exceptions gracefully without stopping processing`() {
// Ensure clean state for this test
cleanupRedis()
val aggregateId = Uuid.random()
val processedEvents = CopyOnWriteArrayList<String>()
val latch = CountDownLatch(3) // Expecting 3 events to be processed (2 success + 1 failure)
// Register a handler that fails on specific events
consumer1.registerEventHandler("FailingTestEvent") { event ->
val failingEvent = event as FailingTestEvent
if (failingEvent.shouldFail) {
processedEvents.add("Failed: ${failingEvent.data}")
latch.countDown()
throw RuntimeException("Simulated handler failure for: ${failingEvent.data}")
} else {
processedEvents.add("Success: ${failingEvent.data}")
latch.countDown()
}
}
consumer1.init()
// Create events - some that will fail, some that will succeed
val events = listOf(
FailingTestEvent(AggregateId(aggregateId), EventVersion(1L), "Event 1", false),
FailingTestEvent(AggregateId(aggregateId), EventVersion(2L), "Event 2", true), // Will fail
FailingTestEvent(AggregateId(aggregateId), EventVersion(3L), "Event 3", false)
)
eventStore.appendToStream(events, aggregateId, 0)
// Poll multiple times to ensure all events are processed
// This is necessary because Redis streams might not deliver all events in a single poll
for (i in 1..10) {
consumer1.pollEvents()
Thread.sleep(100)
if (latch.count == 0L) break
}
// Wait for processing
assertTrue(latch.await(5, TimeUnit.SECONDS), "Events were not processed within timeout")
// Verify that both successful and failed events were attempted
assertEquals(3, processedEvents.size)
assertTrue(processedEvents.contains("Success: Event 1"))
assertTrue(processedEvents.contains("Failed: Event 2"))
assertTrue(processedEvents.contains("Success: Event 3"))
logger.debug("Handler exceptions handled gracefully:")
processedEvents.forEach { logger.debug("Event result: {}", it) }
}
// Test event classes
@Serializable
data class ResilienceTestEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val data: String
) : BaseDomainEvent(aggregateId, EventType("ResilienceTestEvent"), version)
@Serializable
data class SlowTestEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val data: String,
val processingTimeMs: Long
) : BaseDomainEvent(aggregateId, EventType("SlowTestEvent"), version)
@Serializable
data class FailingTestEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val data: String,
val shouldFail: Boolean
) : BaseDomainEvent(aggregateId, EventType("FailingTestEvent"), version)
}
@@ -1,385 +0,0 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.AutoConfigurations
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.test.context.runner.ApplicationContextRunner
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.core.StringRedisTemplate
import java.time.Duration
/**
* Comprehensive test suite for RedisEventStoreConfiguration.
*
* Tests all aspects of Spring Boot autoconfiguration including:
* - Configuration properties binding
* - Bean creation and dependency injection
* - Default value handling
* - Property conversion and validation
* - Conditional bean creation
*/
@DisplayName("RedisEventStoreConfiguration Tests")
class RedisEventStoreConfigurationTest {
private val logger = LoggerFactory.getLogger(RedisEventStoreConfigurationTest::class.java)
@Configuration
@EnableConfigurationProperties(RedisEventStoreProperties::class)
class TestConfiguration
private val contextRunner = ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(
org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration::class.java,
org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration::class.java,
RedisEventStoreConfiguration::class.java
))
.withUserConfiguration(TestConfiguration::class.java)
@Test
@DisplayName("Should create all beans with custom configuration properties")
fun `should create beans with custom configuration properties`() {
contextRunner
.withPropertyValues(
"redis.event-store.host=custom-redis-host",
"redis.event-store.port=6380",
"redis.event-store.consumer-group=custom-group",
"redis.event-store.max-batch-size=50"
)
.run { context ->
// Verify properties are correctly bound
val properties = context.getBean(RedisEventStoreProperties::class.java)
assertNotNull(properties)
assertEquals("custom-redis-host", properties.host)
assertEquals(6380, properties.port)
assertEquals("custom-group", properties.consumerGroup)
assertEquals(50, properties.maxBatchSize)
// Verify all beans are created
assertTrue(context.containsBean("eventStoreRedisConnectionFactory"))
assertTrue(context.containsBean("eventStoreRedisTemplate"))
assertTrue(context.containsBean("eventSerializer"))
assertTrue(context.containsBean("eventStore"))
assertTrue(context.containsBean("eventConsumer"))
// Verify bean types
assertNotNull(context.getBean("eventStoreRedisConnectionFactory", RedisConnectionFactory::class.java))
assertNotNull(context.getBean("eventStoreRedisTemplate", StringRedisTemplate::class.java))
assertNotNull(context.getBean("eventSerializer", EventSerializer::class.java))
assertNotNull(context.getBean("eventStore", EventStore::class.java))
assertNotNull(context.getBean("eventConsumer", RedisEventConsumer::class.java))
logger.debug("Custom configuration test passed - all beans created with custom properties")
}
}
@Test
@DisplayName("Should fallback to default configuration when properties are missing")
fun `should fallback to default configuration when properties missing`() {
contextRunner
.run { context ->
// Verify properties use defaults
val properties = context.getBean(RedisEventStoreProperties::class.java)
assertNotNull(properties)
assertEquals("localhost", properties.host)
assertEquals(6379, properties.port)
assertNull(properties.password)
assertEquals(0, properties.database)
assertEquals(2000L, properties.connectionTimeout)
assertEquals(2000L, properties.readTimeout)
assertTrue(properties.usePooling)
assertEquals(8, properties.maxPoolSize)
assertEquals(2, properties.minPoolSize)
assertEquals("event-processors", properties.consumerGroup)
assertEquals("event-consumer", properties.consumerName)
assertEquals("event-stream:", properties.streamPrefix)
assertEquals("all-events", properties.allEventsStream)
assertEquals(Duration.ofMinutes(1), properties.claimIdleTimeout)
assertEquals(Duration.ofMillis(100), properties.pollTimeout)
assertEquals(100, properties.maxBatchSize)
assertTrue(properties.createConsumerGroupIfNotExists)
// Verify all required beans are still created by defaults
assertTrue(context.containsBean("eventStoreRedisConnectionFactory"))
assertTrue(context.containsBean("eventStoreRedisTemplate"))
assertTrue(context.containsBean("eventSerializer"))
assertTrue(context.containsBean("eventStore"))
assertTrue(context.containsBean("eventConsumer"))
logger.debug("Default configuration test passed - all beans created with default values")
}
}
@Test
@DisplayName("Should handle partial configuration correctly with mixed custom and default properties")
fun `should handle partial configuration correctly`() {
contextRunner
.withPropertyValues(
"redis.event-store.host=partial-host",
"redis.event-store.consumer-group=partial-group"
// Other properties should use defaults
)
.run { context ->
val properties = context.getBean(RedisEventStoreProperties::class.java)
assertNotNull(properties)
// Verify custom properties are set
assertEquals("partial-host", properties.host)
assertEquals("partial-group", properties.consumerGroup)
// Verify defaults are used for unspecified properties
assertEquals(6379, properties.port) // Default
assertEquals("event-consumer", properties.consumerName) // Default
assertEquals("event-stream:", properties.streamPrefix) // Default
// All beans should still be created
assertTrue(context.containsBean("eventStoreRedisConnectionFactory"))
assertTrue(context.containsBean("eventStore"))
assertTrue(context.containsBean("eventConsumer"))
logger.debug("Partial configuration test passed - mixed custom/default properties work")
}
}
@Test
@DisplayName("Should handle Redis connection factory creation correctly")
fun `should handle Redis connection factory creation correctly`() {
contextRunner
.withPropertyValues(
"redis.event-store.host=test-host",
"redis.event-store.port=6380",
"redis.event-store.password=test-password",
"redis.event-store.database=1"
)
.run { context ->
val connectionFactory = context.getBean("eventStoreRedisConnectionFactory", RedisConnectionFactory::class.java)
assertNotNull(connectionFactory)
// Verify the connection factory is properly configured
// Note: We can't easily test the internal configuration without making actual connections,
// but we can verify the bean is created and is the right type
assertTrue(connectionFactory::class.java.name.contains("LettuceConnectionFactory"))
logger.debug("Redis connection factory creation test passed")
}
}
@Test
fun `should handle Redis template creation correctly`() {
contextRunner
.run { context ->
val redisTemplate = context.getBean("eventStoreRedisTemplate", StringRedisTemplate::class.java)
assertNotNull(redisTemplate)
// Verify the template is properly set up
assertNotNull(redisTemplate.connectionFactory)
logger.debug("Redis template creation test passed")
}
}
@Test
fun `should create EventSerializer with correct type`() {
contextRunner
.run { context ->
val eventSerializer = context.getBean("eventSerializer", EventSerializer::class.java)
assertNotNull(eventSerializer)
// Verify it's the Jackson implementation
assertTrue(eventSerializer is JacksonEventSerializer)
logger.debug("EventSerializer creation test passed - JacksonEventSerializer created")
}
}
@Test
fun `should create EventStore with correct dependencies`() {
contextRunner
.run { context ->
val eventStore = context.getBean("eventStore", EventStore::class.java)
assertNotNull(eventStore)
// Verify it's the Redis implementation
assertTrue(eventStore is RedisEventStore)
// Verify dependencies are wired correctly
val redisTemplate = context.getBean("eventStoreRedisTemplate", StringRedisTemplate::class.java)
val eventSerializer = context.getBean("eventSerializer", EventSerializer::class.java)
val properties = context.getBean(RedisEventStoreProperties::class.java)
assertNotNull(redisTemplate)
assertNotNull(eventSerializer)
assertNotNull(properties)
logger.debug("EventStore creation test passed - RedisEventStore created with dependencies")
}
}
@Test
fun `should create EventConsumer with correct dependencies`() {
contextRunner
.run { context ->
val eventConsumer = context.getBean("eventConsumer", RedisEventConsumer::class.java)
assertNotNull(eventConsumer)
// Verify dependencies are available
val redisTemplate = context.getBean("eventStoreRedisTemplate", StringRedisTemplate::class.java)
val eventSerializer = context.getBean("eventSerializer", EventSerializer::class.java)
val properties = context.getBean(RedisEventStoreProperties::class.java)
assertNotNull(redisTemplate)
assertNotNull(eventSerializer)
assertNotNull(properties)
logger.debug("EventConsumer creation test passed - RedisEventConsumer created with dependencies")
}
}
@Test
fun `should handle boolean and numeric property conversion correctly`() {
contextRunner
.withPropertyValues(
"redis.event-store.use-pooling=false",
"redis.event-store.max-pool-size=16",
"redis.event-store.min-pool-size=4",
"redis.event-store.max-batch-size=25",
"redis.event-store.create-consumer-group-if-not-exists=false"
)
.run { context ->
val properties = context.getBean(RedisEventStoreProperties::class.java)
assertNotNull(properties)
// Verify boolean properties
assertFalse(properties.usePooling)
assertFalse(properties.createConsumerGroupIfNotExists)
// Verify numeric properties
assertEquals(16, properties.maxPoolSize)
assertEquals(4, properties.minPoolSize)
assertEquals(25, properties.maxBatchSize)
logger.debug("Property type conversion test passed - boolean and numeric values handled correctly")
}
}
@Test
fun `should handle Duration property conversion correctly`() {
contextRunner
.withPropertyValues(
"redis.event-store.claim-idle-timeout=5m", // 5 minutes
"redis.event-store.poll-timeout=500ms" // 500 milliseconds
)
.run { context ->
val properties = context.getBean(RedisEventStoreProperties::class.java)
assertNotNull(properties)
// Verify Duration properties
assertEquals(Duration.ofMinutes(5), properties.claimIdleTimeout)
assertEquals(Duration.ofMillis(500), properties.pollTimeout)
logger.debug("Duration property conversion test passed")
}
}
@Test
fun `should handle ConditionalOnMissingBean annotations correctly`() {
contextRunner
.withBean("eventSerializer", EventSerializer::class.java, { JacksonEventSerializer() })
.run { context ->
// Should use the manually provided bean instead of creating a new one
val eventSerializer = context.getBean("eventSerializer", EventSerializer::class.java)
assertNotNull(eventSerializer)
// Should still create other beans
assertTrue(context.containsBean("eventStore"))
assertTrue(context.containsBean("eventConsumer"))
logger.debug("ConditionalOnMissingBean test passed - manual bean used, others created")
}
}
@Test
@DisplayName("Should handle boundary property values correctly")
fun `should handle boundary property values correctly`() {
contextRunner
.withPropertyValues(
"redis.event-store.port=65535", // Maximum valid port
"redis.event-store.max-batch-size=1", // Minimum valid batch size
"redis.event-store.connection-timeout=1", // Minimum valid timeout
"redis.event-store.database=15" // High database number
)
.run { context ->
// Context should start with boundary values
assertTrue(context.isRunning)
val properties = context.getBean(RedisEventStoreProperties::class.java)
assertNotNull(properties)
// Verify boundary values are accepted
assertEquals(65535, properties.port)
assertEquals(1, properties.maxBatchSize)
assertEquals(1L, properties.connectionTimeout)
assertEquals(15, properties.database)
logger.debug("[DEBUG_LOG] Boundary property values test passed")
}
}
@Test
@DisplayName("Should handle complex Duration configurations correctly")
fun `should handle complex Duration configurations correctly`() {
contextRunner
.withPropertyValues(
"redis.event-store.claim-idle-timeout=PT30S", // 30 seconds
"redis.event-store.poll-timeout=PT1.5S" // 1.5 seconds
)
.run { context ->
val properties = context.getBean(RedisEventStoreProperties::class.java)
assertNotNull(properties)
// Verify complex Duration parsing
assertEquals(Duration.ofSeconds(30), properties.claimIdleTimeout)
assertEquals(Duration.ofMillis(1500), properties.pollTimeout)
// Verify all beans are still created with complex durations
assertTrue(context.containsBean("eventStore"))
assertTrue(context.containsBean("eventConsumer"))
logger.debug("[DEBUG_LOG] Complex Duration configuration test passed")
}
}
@Test
@DisplayName("Should handle special property combinations")
fun `should handle special property combinations`() {
contextRunner
.withPropertyValues(
"redis.event-store.host=redis.example.com", // External host
"redis.event-store.password=", // Empty password (no auth)
"redis.event-store.stream-prefix=custom:", // Custom prefix
"redis.event-store.use-pooling=false", // Disable pooling
"redis.event-store.create-consumer-group-if-not-exists=false" // Manual group management
)
.run { context ->
val properties = context.getBean(RedisEventStoreProperties::class.java)
assertNotNull(properties)
// Verify special configuration combinations
assertEquals("redis.example.com", properties.host)
assertEquals("", properties.password)
assertEquals("custom:", properties.streamPrefix)
assertFalse(properties.usePooling)
assertFalse(properties.createConsumerGroupIfNotExists)
// Beans should still be created with special combinations
assertTrue(context.containsBean("eventStoreRedisConnectionFactory"))
assertTrue(context.containsBean("eventStore"))
logger.debug("[DEBUG_LOG] Special property combinations test passed")
}
}
}
@@ -1,356 +0,0 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.model.AggregateId
import at.mocode.core.domain.model.EventType
import at.mocode.core.domain.model.EventVersion
import at.mocode.infrastructure.eventstore.api.ConcurrencyException
import at.mocode.infrastructure.eventstore.api.EventSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.*
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 kotlin.time.Clock
import kotlin.uuid.Uuid
/**
* Simplified error handling tests for RedisEventStore using Testcontainers.
* Tests real scenarios without complex mocking.
*/
@Testcontainers
class RedisEventStoreErrorHandlingTest {
companion object {
@Container
val redisContainer: GenericContainer<*> = 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(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(TestErrorEvent::class.java, "TestErrorEvent")
registerEventType(LargePayloadEvent::class.java, "LargePayloadEvent")
registerEventType(ComplexErrorEvent::class.java, "ComplexErrorEvent")
}
properties = RedisEventStoreProperties().apply {
streamPrefix = "test-stream:"
allEventsStream = "all-events"
}
eventStore = RedisEventStore(redisTemplate, serializer, properties)
cleanupRedis()
}
@AfterEach
fun tearDown() = cleanupRedis()
private fun cleanupRedis() {
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
redisTemplate.delete(keys)
}
}
@Test
fun `should handle large event payloads correctly without memory issues`() {
val aggregateId = Uuid.random()
// Create an event with a very large payload (1MB)
val largeData = "X".repeat(1024 * 1024) // 1MB of data
val largeMetadata = (1..1000).associate { "key$it" to "value$it".repeat(100) } // Additional large metadata
val largeEvent = LargePayloadEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
largeData = largeData,
metadata = largeMetadata
)
// Should handle serialization and storage of large payloads without exception
assertDoesNotThrow {
val version = eventStore.appendToStream(largeEvent, aggregateId, 0)
assertEquals(1L, version)
}
// Should be able to read back the large event correctly
val retrievedEvents = eventStore.readFromStream(aggregateId)
assertEquals(1, retrievedEvents.size)
val retrievedEvent = retrievedEvents[0] as LargePayloadEvent
assertEquals(largeData, retrievedEvent.largeData)
assertEquals(largeMetadata, retrievedEvent.metadata)
assertEquals(EventVersion(1L), retrievedEvent.version)
}
@Test
fun `should handle multiple large events in sequence`() {
val aggregateId = Uuid.random()
val numberOfLargeEvents = 10
val sizePerEvent = 100 * 1024 // 100KB per event
// Create multiple large events
val largeEvents = (1..numberOfLargeEvents).map { i ->
LargePayloadEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(i.toLong()),
largeData = "Event$i-".repeat(sizePerEvent / 10),
metadata = mapOf("eventNumber" to "$i", "size" to "$sizePerEvent")
)
}
// Append all large events
assertDoesNotThrow {
eventStore.appendToStream(largeEvents, aggregateId, 0)
}
// Verify all events can be retrieved
val allEvents = eventStore.readFromStream(aggregateId)
assertEquals(numberOfLargeEvents, allEvents.size)
// Verify each event's integrity
allEvents.forEachIndexed { index, event ->
val largeEvent = event as LargePayloadEvent
assertEquals(EventVersion((index + 1).toLong()), largeEvent.version)
assertTrue(largeEvent.largeData.startsWith("Event${index + 1}-"))
assertEquals("${index + 1}", largeEvent.metadata["eventNumber"])
}
}
@Test
fun `should handle corrupted data gracefully during deserialization by skipping bad events`() {
val aggregateId = Uuid.random()
val streamKey = "test-stream:$aggregateId"
// First, add a valid event
val validEvent = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "valid event"
)
eventStore.appendToStream(validEvent, aggregateId, 0)
// Manually corrupt data in Redis by adding malformed JSON
val corruptedEventData = mapOf(
"eventType" to "TestErrorEvent",
"eventData" to "{\"corrupted\":\"json\",\"missing\":", // Invalid JSON - missing closing brace
"aggregateId" to aggregateId.toString(),
"version" to "2",
"eventId" to Uuid.random().toString(),
"timestamp" to Clock.System.now().toString()
)
// Directly add corrupted data to the Redis stream
redisTemplate.opsForStream<String, String>().add(streamKey, corruptedEventData)
// Add another valid event after the corrupted one
val validEvent2 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(3L),
data = "another valid event"
)
eventStore.appendToStream(validEvent2, aggregateId, 2)
// Reading should skip corrupted events and return only valid ones
val events = eventStore.readFromStream(aggregateId)
// Should return only the valid events (corrupted event should be skipped)
assertEquals(2, events.size)
val firstEvent = events[0] as TestErrorEvent
assertEquals("valid event", firstEvent.data)
assertEquals(EventVersion(1L), firstEvent.version)
val secondEvent = events[1] as TestErrorEvent
assertEquals("another valid event", secondEvent.data)
assertEquals(EventVersion(3L), secondEvent.version)
}
@Test
fun `should handle unregistered event types gracefully during read operations`() {
val aggregateId = Uuid.random()
val streamKey = "test-stream:$aggregateId"
// Add a valid registered event first
val validEvent = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "valid registered event"
)
eventStore.appendToStream(validEvent, aggregateId, 0)
// Manually add event data for an unregistered event type
val unregisteredEventData = mapOf(
"eventType" to "UnknownEventType", // Not registered in serializer
"eventData" to """{"someField": "someValue", "aggregateId": {"value": "$aggregateId"}, "version": {"value": 2}}""",
"aggregateId" to aggregateId.toString(),
"version" to "2",
"eventId" to Uuid.random().toString(),
"timestamp" to Clock.System.now().toString()
)
redisTemplate.opsForStream<String, String>().add(streamKey, unregisteredEventData)
// Add another valid event
val validEvent2 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(3L),
data = "final valid event"
)
eventStore.appendToStream(validEvent2, aggregateId, 2)
// Reading should skip unregistered events and return only valid ones
val events = eventStore.readFromStream(aggregateId)
assertEquals(2, events.size)
assertEquals("valid registered event", (events[0] as TestErrorEvent).data)
assertEquals("final valid event", (events[1] as TestErrorEvent).data)
}
@Test
fun `should handle concurrent version conflicts properly with retry logic`() {
val aggregateId = Uuid.random()
// Create an initial event
val event1 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "initial event"
)
eventStore.appendToStream(event1, aggregateId, 0)
// Try to append two events with the same expected version (simulating concurrent access)
val event2 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(2L),
data = "concurrent event 1"
)
val event3 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(2L), // Same version - will conflict
data = "concurrent event 2"
)
// First append should succeed
val version2 = eventStore.appendToStream(event2, aggregateId, 1)
assertEquals(2L, version2)
// Second append with the same expected version should fail
assertThrows<ConcurrencyException> {
eventStore.appendToStream(event3, aggregateId, 1) // Still expecting version 1
}
// But should succeed with a correct expected version
val correctedEvent3 = event3.copy(version = EventVersion(3L))
val version3 = eventStore.appendToStream(correctedEvent3, aggregateId, 2)
assertEquals(3L, version3)
// Verify all events are in the stream
val allEvents = eventStore.readFromStream(aggregateId)
assertEquals(3, allEvents.size)
assertEquals(listOf(1L, 2L, 3L), allEvents.map { it.version.value })
}
@Test
fun `should handle complex nested object serialization correctly`() {
val aggregateId = Uuid.random()
val complexEvent = ComplexErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
nestedData = ComplexNestedData(
id = 42,
name = "Complex Test",
subObjects = listOf(
SubObject("sub1", 1, mapOf("key1" to "value1")),
SubObject("sub2", 2, mapOf("key2" to "value2", "key3" to "value3"))
),
metadata = mapOf(
"level1" to mapOf("level2" to mapOf("level3" to "deep value")),
"array" to listOf("item1", "item2", "item3")
)
)
)
// Should handle complex serialization without issues
assertDoesNotThrow {
eventStore.appendToStream(complexEvent, aggregateId, 0)
}
// Should deserialize a complex object correctly
val retrievedEvents = eventStore.readFromStream(aggregateId)
assertEquals(1, retrievedEvents.size)
val retrievedEvent = retrievedEvents[0] as ComplexErrorEvent
assertEquals(42, retrievedEvent.nestedData.id)
assertEquals("Complex Test", retrievedEvent.nestedData.name)
assertEquals(2, retrievedEvent.nestedData.subObjects.size)
assertEquals("sub1", retrievedEvent.nestedData.subObjects[0].name)
assertEquals(2, retrievedEvent.nestedData.subObjects[1].value)
assertTrue(retrievedEvent.nestedData.metadata.containsKey("level1"))
}
// Test event classes
@Serializable
data class TestErrorEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val data: String
) : BaseDomainEvent(aggregateId, EventType("TestErrorEvent"), version)
@Serializable
data class LargePayloadEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val largeData: String,
val metadata: Map<String, String>
) : BaseDomainEvent(aggregateId, EventType("LargePayloadEvent"), version)
@Serializable
data class ComplexErrorEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val nestedData: ComplexNestedData
) : BaseDomainEvent(aggregateId, EventType("ComplexErrorEvent"), version)
@Serializable
data class ComplexNestedData(
val id: Int,
val name: String,
val subObjects: List<SubObject>,
val metadata: Map<String, Any>
)
@Serializable
data class SubObject(
val name: String,
val value: Int,
val properties: Map<String, String>
)
}
@@ -1,146 +0,0 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.event.DomainEvent
import at.mocode.core.domain.model.*
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
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.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
@Testcontainers
class RedisEventStoreIntegrationTest {
companion object {
@Container
val redisContainer: GenericContainer<*> = 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(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(TestCreatedEvent::class.java, "TestCreated")
registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
}
properties = RedisEventStoreProperties().apply {
streamPrefix = "test-stream:"
allEventsStream = "all-events"
consumerGroup = "test-group"
consumerName = "test-consumer"
}
eventStore = RedisEventStore(redisTemplate, serializer, properties)
eventConsumer = RedisEventConsumer(redisTemplate, serializer, properties)
cleanupRedis()
}
@AfterEach
fun tearDown() {
eventConsumer.shutdown()
cleanupRedis()
}
private fun cleanupRedis() {
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
redisTemplate.delete(keys)
}
val allEventsStreamKey = "${properties.streamPrefix}${properties.allEventsStream}"
redisTemplate.delete(allEventsStreamKey)
}
@Test
fun `event publishing and consuming with consumer groups should work`() {
val aggregateId = Uuid.random()
val event1 = TestCreatedEvent(aggregateId = AggregateId(aggregateId), version = EventVersion(1L), name = "Test Entity")
val event2 = TestUpdatedEvent(aggregateId = AggregateId(aggregateId), version = EventVersion(2L), name = "Updated Test Entity")
val latch = CountDownLatch(2)
val receivedEvents = mutableListOf<DomainEvent>()
eventConsumer.registerEventHandler("TestCreated") { event ->
receivedEvents.add(event)
latch.countDown()
}
eventConsumer.registerEventHandler("TestUpdated") { event ->
receivedEvents.add(event)
latch.countDown()
}
eventConsumer.init()
eventStore.appendToStream(listOf(event1, event2), aggregateId, 0)
// KORREKTUR: Manuelles Auslösen des Pollings, da @Scheduled im Test nicht aktiv ist.
eventConsumer.pollEvents()
// Der Latch sollte jetzt fast sofort herunterzählen. Wir warten zur Sicherheit eine kurze Zeit.
assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for events. Latch count: ${latch.count}")
assertEquals(2, receivedEvents.size)
val receivedEvent1 = receivedEvents.find { it.version == EventVersion(1L) } as TestCreatedEvent
assertEquals(AggregateId(aggregateId), receivedEvent1.aggregateId)
assertEquals("Test Entity", receivedEvent1.name)
val receivedEvent2 = receivedEvents.find { it.version == EventVersion(2L) } as TestUpdatedEvent
assertEquals(AggregateId(aggregateId), receivedEvent2.aggregateId)
assertEquals("Updated Test Entity", receivedEvent2.name)
}
data class TestCreatedEvent(
override val aggregateId: AggregateId,
override val version: EventVersion,
val name: String,
override val eventType: EventType = EventType("TestCreated"),
override val eventId: EventId = EventId(Uuid.random()),
override val timestamp: Instant = Clock.System.now(),
override val correlationId: CorrelationId? = null,
override val causationId: CausationId? = null
) : BaseDomainEvent(aggregateId, eventType, version, eventId, timestamp, correlationId, causationId)
data class TestUpdatedEvent(
override val aggregateId: AggregateId,
override val version: EventVersion,
val name: String,
override val eventType: EventType = EventType("TestUpdated"),
override val eventId: EventId = EventId(Uuid.random()),
override val timestamp: Instant = Clock.System.now(),
override val correlationId: CorrelationId? = null,
override val causationId: CausationId? = null
) : BaseDomainEvent(aggregateId, eventType, version, eventId, timestamp, correlationId, causationId)
}
@@ -1,345 +0,0 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.model.AggregateId
import at.mocode.core.domain.model.EventType
import at.mocode.core.domain.model.EventVersion
import at.mocode.infrastructure.eventstore.api.EventSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
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 kotlin.uuid.Uuid
/**
* Stream-specific tests for RedisEventStore - Core functionality validation.
*/
@Testcontainers
class RedisEventStoreStreamTest {
private val logger = LoggerFactory.getLogger(RedisEventStoreStreamTest::class.java)
companion object {
@Container
val redisContainer: GenericContainer<*> = 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(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(StreamTestEvent::class.java, "StreamTestEvent")
registerEventType(OrderTestEvent::class.java, "OrderTestEvent")
}
properties = RedisEventStoreProperties().apply {
streamPrefix = "test-stream:"
}
eventStore = RedisEventStore(redisTemplate, serializer, properties)
cleanupRedis()
}
@AfterEach
fun tearDown() = cleanupRedis()
private fun cleanupRedis() {
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
redisTemplate.delete(keys)
}
}
@Test
fun `readFromStream should respect fromVersion and toVersion parameters`() {
val aggregateId = Uuid.random()
val events = (1..10).map { i ->
StreamTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(i.toLong()),
data = "Event $i"
)
}
// Append all events
eventStore.appendToStream(events, aggregateId, 0)
// Test reading from a specific version
val eventsFromVersion3 = eventStore.readFromStream(aggregateId, fromVersion = 3)
assertEquals(8, eventsFromVersion3.size) // Events 3-10
assertEquals(EventVersion(3L), eventsFromVersion3.first().version)
assertEquals(EventVersion(10L), eventsFromVersion3.last().version)
// Test reading with both fromVersion and toVersion
val eventsRange = eventStore.readFromStream(aggregateId, fromVersion = 4, toVersion = 7)
assertEquals(4, eventsRange.size) // Events 4-7
assertEquals(EventVersion(4L), eventsRange.first().version)
assertEquals(EventVersion(7L), eventsRange.last().version)
// Test reading a single event
val singleEvent = eventStore.readFromStream(aggregateId, fromVersion = 5, toVersion = 5)
assertEquals(1, singleEvent.size)
assertEquals(EventVersion(5L), singleEvent.first().version)
// Test reading beyond the available range
val beyondRange = eventStore.readFromStream(aggregateId, fromVersion = 15, toVersion = 20)
assertEquals(0, beyondRange.size)
}
@Test
fun `readAllEvents should handle pagination correctly`() {
val aggregateId1 = Uuid.random()
val aggregateId2 = Uuid.random()
val events1 = (1..5).map { i ->
StreamTestEvent(
aggregateId = AggregateId(aggregateId1),
version = EventVersion(i.toLong()),
data = "Stream1 Event $i"
)
}
val events2 = (1..5).map { i ->
StreamTestEvent(
aggregateId = AggregateId(aggregateId2),
version = EventVersion(i.toLong()),
data = "Stream2 Event $i"
)
}
// Append events to both streams
eventStore.appendToStream(events1, aggregateId1, 0)
eventStore.appendToStream(events2, aggregateId2, 0)
// Test reading all events
val allEvents = eventStore.readAllEvents()
assertEquals(10, allEvents.size)
// Test reading with fromPosition
val eventsFromPosition3 = eventStore.readAllEvents(fromPosition = 3)
assertEquals(7, eventsFromPosition3.size)
// Test reading with maxCount
val limitedEvents = eventStore.readAllEvents(maxCount = 4)
assertEquals(4, limitedEvents.size)
// Test reading with both fromPosition and maxCount
val paginatedEvents = eventStore.readAllEvents(fromPosition = 2, maxCount = 3)
assertEquals(3, paginatedEvents.size)
// Test reading beyond available events
val beyondEvents = eventStore.readAllEvents(fromPosition = 20)
assertEquals(0, beyondEvents.size)
}
@Test
fun `getStreamVersion should return -1 for non-existent streams`() {
val nonExistentStreamId = Uuid.random()
val version = eventStore.getStreamVersion(nonExistentStreamId)
assertEquals(0L, version) // Redis streams return 0 for non-existent streams, not -1
}
@Test
fun `should handle empty streams correctly`() {
val emptyStreamId = Uuid.random()
// Reading from an empty stream should return an empty list
val emptyEvents = eventStore.readFromStream(emptyStreamId)
assertEquals(0, emptyEvents.size)
// Version of an empty stream should be 0
val emptyVersion = eventStore.getStreamVersion(emptyStreamId)
assertEquals(0L, emptyVersion)
// Reading with version range on an empty stream should return an empty list
val rangeEvents = eventStore.readFromStream(emptyStreamId, fromVersion = 1, toVersion = 5)
assertEquals(0, rangeEvents.size)
}
@Test
fun `should handle concurrent version conflicts properly using optimistic locking`() {
val aggregateId = Uuid.random()
// Add initial event
val initialEvent = OrderTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
threadId = 0,
eventIndex = 0,
data = "Initial event"
)
eventStore.appendToStream(initialEvent, aggregateId, 0)
// Simulate simplified concurrent access with manual version handling
val event1 = OrderTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(2L),
threadId = 1,
eventIndex = 1,
data = "Concurrent event 1"
)
val event2 = OrderTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(3L),
threadId = 2,
eventIndex = 1,
data = "Concurrent event 2"
)
// First append should succeed
val version1 = eventStore.appendToStream(event1, aggregateId, 1)
assertEquals(2L, version1)
// The second appending should succeed with an updated expected version
val version2 = eventStore.appendToStream(event2, aggregateId, 2)
assertEquals(3L, version2)
// Verify the final stream state
val allEvents = eventStore.readFromStream(aggregateId)
assertEquals(3, allEvents.size)
assertEquals(3L, eventStore.getStreamVersion(aggregateId))
// Verify events are in correct order
val versions = allEvents.map { it.version.value }
assertEquals(listOf(1L, 2L, 3L), versions)
}
@Test
fun `should handle version gaps correctly in stream reading`() {
val aggregateId = Uuid.random()
// Create events with non-sequential versions (simulating gaps)
val event1 = StreamTestEvent(AggregateId(aggregateId), EventVersion(1L), "Event 1")
val event5 = StreamTestEvent(AggregateId(aggregateId), EventVersion(2L), "Event 5") // Actual version 2, but data says 5
val event10 = StreamTestEvent(AggregateId(aggregateId), EventVersion(3L), "Event 10")
eventStore.appendToStream(event1, aggregateId, 0)
eventStore.appendToStream(event5, aggregateId, 1)
eventStore.appendToStream(event10, aggregateId, 2)
// Reading should work despite data content suggesting gaps
val allEvents = eventStore.readFromStream(aggregateId)
assertEquals(3, allEvents.size)
assertEquals(listOf(1L, 2L, 3L), allEvents.map { it.version.value })
// Range reading should work correctly
val rangeEvents = eventStore.readFromStream(aggregateId, fromVersion = 2, toVersion = 3)
assertEquals(2, rangeEvents.size)
assertEquals(listOf(2L, 3L), rangeEvents.map { it.version.value })
}
@Test
fun `should handle large streams efficiently`() {
val aggregateId = Uuid.random()
val numberOfEvents = 1000
// Create and append a large number of events
val events = (1..numberOfEvents).map { i ->
StreamTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(i.toLong()),
data = "Large stream event $i with some additional data to make it more realistic"
)
}
// Measure appends time
val startAppend = System.currentTimeMillis()
eventStore.appendToStream(events, aggregateId, 0)
val appendTime = System.currentTimeMillis() - startAppend
logger.debug("Appended {} events in {}ms", numberOfEvents, appendTime)
// Verify version
assertEquals(numberOfEvents.toLong(), eventStore.getStreamVersion(aggregateId))
// Measure read time for full stream
val startRead = System.currentTimeMillis()
val allReadEvents = eventStore.readFromStream(aggregateId)
val readTime = System.currentTimeMillis() - startRead
logger.debug("Read {} events in {}ms", numberOfEvents, readTime)
assertEquals(numberOfEvents, allReadEvents.size)
// Measure time for range reading
val startRange = System.currentTimeMillis()
val rangeEvents = eventStore.readFromStream(aggregateId, fromVersion = 500, toVersion = 600)
val rangeTime = System.currentTimeMillis() - startRange
logger.debug("Read 101 events from range in {}ms", rangeTime)
assertEquals(101, rangeEvents.size)
// Verify performance is reasonable (should be under 5 seconds for 1000 events)
assertTrue(appendTime < 5000, "Append time too slow: ${appendTime}ms")
assertTrue(readTime < 5000, "Read time too slow: ${readTime}ms")
}
@Test
fun `subscribeToStream and subscribeToAll should return working subscriptions`() {
val aggregateId = Uuid.random()
var streamEventReceived = false
var allEventReceived = false
// Test stream subscription
val streamSubscription = eventStore.subscribeToStream(aggregateId, 0) { event ->
streamEventReceived = true
}
assertTrue(streamSubscription.isActive())
// Test all-events subscription
val allSubscription = eventStore.subscribeToAll(0) { event ->
allEventReceived = true
}
assertTrue(allSubscription.isActive())
// Test unsubscribe
streamSubscription.unsubscribe()
assertFalse(streamSubscription.isActive())
allSubscription.unsubscribe()
assertFalse(allSubscription.isActive())
// Note: These are basic implementation subscriptions that don't process events
// The focus here is testing that they return proper subscription objects
}
// Test event classes
@Serializable
data class StreamTestEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val data: String
) : BaseDomainEvent(aggregateId, EventType("StreamTestEvent"), version)
@Serializable
data class OrderTestEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val threadId: Int,
val eventIndex: Int,
val data: String
) : BaseDomainEvent(aggregateId, EventType("OrderTestEvent"), version)
}
@@ -1,119 +0,0 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.event.DomainEvent
import at.mocode.core.domain.model.AggregateId
import at.mocode.core.domain.model.EventType
import at.mocode.core.domain.model.EventVersion
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
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 kotlin.uuid.Uuid
@Testcontainers
class RedisIntegrationTest {
companion object {
@Container
val redisContainer: GenericContainer<*> = 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(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(TestCreatedEvent::class.java, "TestCreated")
registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
}
properties = RedisEventStoreProperties().apply {
streamPrefix = "test-stream:"
allEventsStream = "all-events"
consumerGroup = "test-group"
consumerName = "test-consumer"
}
eventStore = RedisEventStore(redisTemplate, serializer, properties)
eventConsumer = RedisEventConsumer(redisTemplate, serializer, properties)
cleanupRedis()
eventConsumer.init()
}
@AfterEach
fun tearDown() {
eventConsumer.shutdown()
cleanupRedis()
}
private fun cleanupRedis() {
val allEventsStreamKey = "${properties.streamPrefix}${properties.allEventsStream}"
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
redisTemplate.delete(keys)
}
redisTemplate.delete(allEventsStreamKey)
}
@Test
fun `event publishing and consuming should be fast and reliable`() {
val aggregateId = Uuid.random()
val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity")
val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity")
val receivedEvents = mutableListOf<DomainEvent>()
eventConsumer.registerEventHandler("TestCreated") { receivedEvents.add(it) }
eventConsumer.registerEventHandler("TestUpdated") { receivedEvents.add(it) }
eventStore.appendToStream(listOf(event1, event2), aggregateId, 0)
eventConsumer.pollEvents()
assertEquals(2, receivedEvents.size)
val receivedEvent1 = receivedEvents.find { it.version == EventVersion(1L) } as TestCreatedEvent
assertEquals(AggregateId(aggregateId), receivedEvent1.aggregateId)
assertEquals("Test Entity", receivedEvent1.name)
val receivedEvent2 = receivedEvents.find { it.version == EventVersion(2L) } as TestUpdatedEvent
assertEquals(AggregateId(aggregateId), receivedEvent2.aggregateId)
assertEquals("Updated Test Entity", receivedEvent2.name)
}
@Serializable
data class TestCreatedEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val name: String
) : BaseDomainEvent(aggregateId, EventType("TestCreated"), version)
@Serializable
data class TestUpdatedEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val name: String
) : BaseDomainEvent(aggregateId, EventType("TestUpdated"), version)
}
@@ -0,0 +1,69 @@
// Dieses Modul stellt eine konkrete Implementierung der `event-store-api`
// unter Verwendung von Valkey Streams als Event-Store-Backend bereit.
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependencyManagement)
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll(
"-opt-in=kotlin.time.ExperimentalTime",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlin.uuid.ExperimentalUuidApi"
)
}
}
dependencies {
// === Core Dependencies ===
// Stellt sicher, dass alle Versionen aus der zentralen BOM kommen
implementation(platform(projects.platform.platformBom))
// Implementiert die provider-agnostische Event-Store-API
api(projects.backend.infrastructure.eventStore.eventStoreApi)
// Benötigt Zugriff auf Core-Module für Domänen-Events und Utilities
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
// === Valkey & Spring Dependencies ===
// OPTIMIERUNG: Wiederverwendung des `valkey-cache`-Bundles, da es die
// gleichen Technologien (Spring Data Valkey, Lettuce, Jackson) verwendet
implementation(libs.bundles.valkey.cache)
// Stellt Jakarta Annotations bereit (z. B. @PostConstruct), die von Spring verwendet werden
implementation(libs.jakarta.annotation.api)
// Für Kotlin-spezifische Coroutines-Integration mit Spring
implementation(libs.kotlinx.coroutines.reactor)
// === Test Dependencies ===
// Fügt JUnit, Mockk, AssertJ etc. für die Tests hinzu
testImplementation(projects.platform.platformTesting)
testImplementation(libs.bundles.testing.jvm)
testImplementation(libs.bundles.testcontainers)
// Zusätzliche Test-Dependencies für erweiterte Event-Store-Tests
testImplementation(libs.kotlinx.serialization.json)
testImplementation(libs.reactor.test)
// Für Integration Tests mit beiden Valkey-Modulen
testImplementation(projects.backend.infrastructure.cache.cacheApi)
testImplementation(projects.backend.infrastructure.cache.valkeyCache)
}
// === Task Configuration ===
// Deaktiviert die Erstellung eines ausführbaren Jars für dieses Valkey Bibliothek-Modul
tasks.bootJar {
enabled = false
}
// Stellt sicher, dass stattdessen ein reguläres Jar gebaut wird
tasks.jar {
enabled = true
archiveClassifier.set("")
}
// Optimiert die Test-Ausführung
tasks.test {
useJUnitPlatform()
// Verbesserte Test-Performance für Testcontainer
systemProperty("testcontainers.reuse.enable", "true")
// Parallelisierung für bessere Performance
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
}
@@ -0,0 +1,242 @@
package at.mocode.infrastructure.eventstore.valkey
import org.slf4j.LoggerFactory
import java.time.Duration
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.LongAdder
/**
* Umfassende Metriken-Verfolgung für Valkey Event-Store-Operationen.
*
* Verfolgt Performance-Metriken, Fehlerquoten und Betriebsstatistiken,
* um Einblicke in die Gesundheit und Performance des Event-Stores zu geben.
*/
class EventStoreMetrics {
private val logger = LoggerFactory.getLogger(EventStoreMetrics::class.java)
// Operation counters
private val appendOperations = LongAdder()
private val appendBatchOperations = LongAdder()
private val readOperations = LongAdder()
private val subscriptionOperations = LongAdder()
// Success/Error tracking
private val successfulOperations = LongAdder()
private val failedOperations = LongAdder()
private val concurrencyExceptions = LongAdder()
// Performance metrics
private val totalOperationTime = LongAdder()
private val maxOperationTime = AtomicLong(0)
private val operationTimestamps = ConcurrentHashMap<String, Instant>()
// Cache metrics
private val cacheHits = LongAdder()
private val cacheMisses = LongAdder()
// Event statistics
private val totalEventsAppended = LongAdder()
private val totalEventsRead = LongAdder()
private val lastMetricsReport = AtomicLong(System.currentTimeMillis())
/**
* Records the start of an operation for timing purposes.
*/
fun startOperation(operationId: String) {
operationTimestamps[operationId] = Instant.now()
}
/**
* Records a successful append operation.
*/
fun recordAppendSuccess(operationId: String, eventCount: Int = 1, isBatch: Boolean = false) {
recordOperationEnd(operationId, true)
appendOperations.increment()
if (isBatch) appendBatchOperations.increment()
totalEventsAppended.add(eventCount.toLong())
logger.debug("[METRICS] Append operation completed successfully. Events: {}, Batch: {}", eventCount, isBatch)
}
/**
* Records a failed append operation.
*/
fun recordAppendFailure(operationId: String, error: Throwable? = null, isConcurrencyException: Boolean = false) {
recordOperationEnd(operationId, false)
if (isConcurrencyException) {
concurrencyExceptions.increment()
}
logger.debug(
"[METRICS] Append operation failed. Concurrency conflict: {}, Error: {}",
isConcurrencyException, error?.message ?: "Unknown"
)
}
/**
* Records a successful read operation.
*/
fun recordReadSuccess(operationId: String, eventCount: Int) {
recordOperationEnd(operationId, true)
readOperations.increment()
totalEventsRead.add(eventCount.toLong())
logger.debug("[METRICS] Read operation completed successfully. Events: {}", eventCount)
}
/**
* Records a failed read operation.
*/
fun recordReadFailure(operationId: String, error: Throwable? = null) {
recordOperationEnd(operationId, false)
logger.debug("[METRICS] Read operation failed. Error: {}", error?.message ?: "Unknown")
}
/**
* Records a cache hit.
*/
fun recordCacheHit() {
cacheHits.increment()
}
/**
* Records a cache miss.
*/
fun recordCacheMiss() {
cacheMisses.increment()
}
/**
* Records a subscription operation.
*/
fun recordSubscription() {
subscriptionOperations.increment()
logger.debug("[METRICS] New subscription created")
}
private fun recordOperationEnd(operationId: String, success: Boolean) {
val startTime = operationTimestamps.remove(operationId)
if (startTime != null) {
val duration = Duration.between(startTime, Instant.now())
val durationMs = duration.toMillis()
totalOperationTime.add(durationMs)
maxOperationTime.updateAndGet { current -> maxOf(current, durationMs) }
if (success) {
successfulOperations.increment()
} else {
failedOperations.increment()
}
}
}
/**
* Gets comprehensive metrics summary.
*/
fun getMetrics(): EventStoreMetricsSnapshot {
val totalOps = successfulOperations.sum() + failedOperations.sum()
val successRate = if (totalOps > 0) (successfulOperations.sum().toDouble() / totalOps * 100) else 0.0
val avgOperationTime = if (totalOps > 0) (totalOperationTime.sum().toDouble() / totalOps) else 0.0
val cacheHitRate = run {
val totalCacheOps = cacheHits.sum() + cacheMisses.sum()
if (totalCacheOps > 0) (cacheHits.sum().toDouble() / totalCacheOps * 100) else 0.0
}
return EventStoreMetricsSnapshot(
totalOperations = totalOps,
successfulOperations = successfulOperations.sum(),
failedOperations = failedOperations.sum(),
successRate = successRate,
appendOperations = appendOperations.sum(),
batchAppendOperations = appendBatchOperations.sum(),
readOperations = readOperations.sum(),
subscriptionOperations = subscriptionOperations.sum(),
concurrencyExceptions = concurrencyExceptions.sum(),
totalEventsAppended = totalEventsAppended.sum(),
totalEventsRead = totalEventsRead.sum(),
averageOperationTimeMs = avgOperationTime,
maxOperationTimeMs = maxOperationTime.get(),
cacheHits = cacheHits.sum(),
cacheMisses = cacheMisses.sum(),
cacheHitRate = cacheHitRate
)
}
/**
* Logs performance metrics if enough time has passed since the last report.
*/
fun logPerformanceMetrics() {
val now = System.currentTimeMillis()
val lastReport = lastMetricsReport.get()
// Log metrics every 5 minutes
if (now - lastReport > 300_000) {
if (lastMetricsReport.compareAndSet(lastReport, now)) {
val metrics = getMetrics()
logger.info("[PERFORMANCE_METRICS] {}", metrics.toLogString())
}
}
}
/**
* Resets all metrics. Useful for testing.
*/
internal fun reset() {
appendOperations.reset()
appendBatchOperations.reset()
readOperations.reset()
subscriptionOperations.reset()
successfulOperations.reset()
failedOperations.reset()
concurrencyExceptions.reset()
totalOperationTime.reset()
maxOperationTime.set(0)
operationTimestamps.clear()
cacheHits.reset()
cacheMisses.reset()
totalEventsAppended.reset()
totalEventsRead.reset()
lastMetricsReport.set(System.currentTimeMillis())
}
}
/**
* Immutable snapshot of event store metrics at a point in time.
*/
data class EventStoreMetricsSnapshot(
val totalOperations: Long,
val successfulOperations: Long,
val failedOperations: Long,
val successRate: Double,
val appendOperations: Long,
val batchAppendOperations: Long,
val readOperations: Long,
val subscriptionOperations: Long,
val concurrencyExceptions: Long,
val totalEventsAppended: Long,
val totalEventsRead: Long,
val averageOperationTimeMs: Double,
val maxOperationTimeMs: Long,
val cacheHits: Long,
val cacheMisses: Long,
val cacheHitRate: Double
) {
fun toLogString(): String {
return "EventStore Metrics: " +
"Operations=${totalOperations}, " +
"Success Rate=${String.format("%.1f%%", successRate)}, " +
"Appends=${appendOperations} (${batchAppendOperations} batches), " +
"Reads=${readOperations}, " +
"Subscriptions=${subscriptionOperations}, " +
"Events Appended=${totalEventsAppended}, " +
"Events Read=${totalEventsRead}, " +
"Avg Time=${String.format("%.1fms", averageOperationTimeMs)}, " +
"Max Time=${maxOperationTimeMs}ms, " +
"Cache Hit Rate=${String.format("%.1f%%", cacheHitRate)}, " +
"Concurrency Conflicts=${concurrencyExceptions}"
}
}
@@ -0,0 +1,99 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.infrastructure.eventstore.valkey
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 org.slf4j.LoggerFactory
import java.util.concurrent.ConcurrentHashMap
import kotlin.uuid.Uuid
/**
* Jackson-basierte Implementierung des EventSerializer.
*/
class JacksonEventSerializer : EventSerializer {
private val logger = LoggerFactory.getLogger(JacksonEventSerializer::class.java)
private val objectMapper: ObjectMapper = ObjectMapper().apply {
registerModule(kotlinModule())
registerModule(JavaTimeModule())
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
}
private val eventTypeToClass = ConcurrentHashMap<String, Class<out DomainEvent>>()
private val eventClassToType = ConcurrentHashMap<Class<out DomainEvent>, String>()
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)
if (!eventClassToType.containsKey(event.javaClass)) {
registerEventType(event.javaClass, eventType)
}
val eventData = objectMapper.writeValueAsString(event)
return mapOf(
EVENT_TYPE_FIELD to eventType,
EVENT_ID_FIELD to event.eventId.value.toString(),
AGGREGATE_ID_FIELD to event.aggregateId.value.toString(),
VERSION_FIELD to event.version.value.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 {
return eventClassToType[event.javaClass] ?: event.javaClass.simpleName
}
override fun getEventType(data: Map<String, String>): String {
return data[EVENT_TYPE_FIELD] ?: throw IllegalArgumentException("Event type is missing")
}
// KORRIGIERT: Parameterreihenfolge umgedreht
override fun registerEventType(eventClass: Class<out DomainEvent>, eventType: String) {
eventTypeToClass[eventType] = eventClass
eventClassToType[eventClass] = eventType
logger.debug("Registered event type: {} for class: {}", eventType, eventClass.name)
}
override fun getAggregateId(data: Map<String, String>): Uuid {
val aggregateIdStr = data[AGGREGATE_ID_FIELD]
?: throw IllegalArgumentException("Aggregate ID is missing")
return Uuid.parse(aggregateIdStr)
}
override fun getEventId(data: Map<String, String>): Uuid {
val eventIdStr = data[EVENT_ID_FIELD]
?: throw IllegalArgumentException("Event ID is missing")
return Uuid.parse(eventIdStr)
}
override fun getVersion(data: Map<String, String>): Long {
val versionStr = data[VERSION_FIELD]
?: throw IllegalArgumentException("Version is missing")
return versionStr.toLong()
}
}
@@ -1,4 +1,4 @@
package at.mocode.infrastructure.eventstore.redis
package at.mocode.infrastructure.eventstore.valkey
import at.mocode.core.domain.event.DomainEvent
import at.mocode.infrastructure.eventstore.api.EventSerializer
@@ -13,14 +13,14 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
/**
* Consumer for Redis Streams that processes events using consumer groups.
* Consumer for Valkey Streams that processes events using consumer groups.
*/
class RedisEventConsumer(
private val redisTemplate: StringRedisTemplate,
private val serializer: EventSerializer,
private val properties: RedisEventStoreProperties
class ValkeyEventConsumer(
private val valkeyTemplate: StringRedisTemplate,
private val serializer: EventSerializer,
private val properties: ValkeyEventStoreProperties
) {
private val logger = LoggerFactory.getLogger(RedisEventConsumer::class.java)
private val logger = LoggerFactory.getLogger(ValkeyEventConsumer::class.java)
private val eventTypeHandlers = ConcurrentHashMap<String, CopyOnWriteArrayList<(DomainEvent) -> Unit>>()
private val allEventHandlers = CopyOnWriteArrayList<(DomainEvent) -> Unit>()
private var running = false
@@ -92,7 +92,7 @@ class RedisEventConsumer(
try {
val allEventsStreamKey = getAllEventsStreamKey()
try {
redisTemplate.opsForStream<String, String>()
valkeyTemplate.opsForStream<String, String>()
.add(allEventsStreamKey, mapOf("init" to "init"))
logger.debug("Ensured all-events stream has messages: $allEventsStreamKey")
} catch (e: Exception) {
@@ -101,7 +101,7 @@ class RedisEventConsumer(
createConsumerGroupIfNotExists(allEventsStreamKey)
val streamKeys = redisTemplate.keys("${properties.streamPrefix}*")
val streamKeys = valkeyTemplate.keys("${properties.streamPrefix}*")
for (streamKey in streamKeys) {
if (streamKey != allEventsStreamKey) {
@@ -121,7 +121,7 @@ class RedisEventConsumer(
private fun createConsumerGroupIfNotExists(streamKey: String) {
try {
try {
redisTemplate.opsForStream<String, String>()
valkeyTemplate.opsForStream<String, String>()
.add(streamKey, mapOf("init" to "init"))
logger.debug("Ensured stream has messages: $streamKey")
} catch (e: Exception) {
@@ -129,7 +129,7 @@ class RedisEventConsumer(
}
try {
redisTemplate.opsForStream<String, String>()
valkeyTemplate.opsForStream<String, String>()
.createGroup(streamKey, ReadOffset.latest(), properties.consumerGroup)
logger.debug("Created consumer group ${properties.consumerGroup} for stream: $streamKey")
} catch (e: Exception) {
@@ -143,7 +143,7 @@ class RedisEventConsumer(
/**
* Periodic polls for new events from all streams.
*/
@Scheduled(fixedDelayString = $$"${redis.event-store.poll-interval:100}")
@Scheduled(fixedDelayString = $$"${valkey.event-store.poll-interval:100}")
fun pollEvents() {
if (!running) {
running = true
@@ -168,7 +168,7 @@ class RedisEventConsumer(
.count(properties.maxBatchSize.toLong())
.block(properties.pollTimeout)
val records = redisTemplate.opsForStream<String, String>()
val records = valkeyTemplate.opsForStream<String, String>()
.read(
Consumer.from(properties.consumerGroup, properties.consumerName),
options,
@@ -195,11 +195,11 @@ class RedisEventConsumer(
try {
val streamKey = getAllEventsStreamKey()
val pendingSummary = redisTemplate.opsForStream<String, String>()
val pendingSummary = valkeyTemplate.opsForStream<String, String>()
.pending(streamKey, properties.consumerGroup)
if (pendingSummary != null && pendingSummary.totalPendingMessages > 0) {
val pendingMessages = redisTemplate.opsForStream<String, String>()
val pendingMessages = valkeyTemplate.opsForStream<String, String>()
.pending(
streamKey,
Consumer.from(properties.consumerGroup, properties.consumerName),
@@ -213,7 +213,7 @@ class RedisEventConsumer(
if (messageIdsList.isNotEmpty()) {
val messageIds = messageIdsList.toTypedArray()
val records = redisTemplate.opsForStream<String, String>()
val records = valkeyTemplate.opsForStream<String, String>()
.claim(
streamKey,
properties.consumerGroup,
@@ -244,7 +244,7 @@ class RedisEventConsumer(
if (data.size == 1 && data.containsKey("init") && data["init"] == "init") {
logger.debug("Skipping init message")
redisTemplate.opsForStream<String, String>()
valkeyTemplate.opsForStream<String, String>()
.acknowledge(properties.consumerGroup, record)
return
}
@@ -268,7 +268,7 @@ class RedisEventConsumer(
}
}
redisTemplate.opsForStream<String, String>()
valkeyTemplate.opsForStream<String, String>()
.acknowledge(properties.consumerGroup, record)
} catch (e: Exception) {
@@ -277,9 +277,9 @@ class RedisEventConsumer(
}
/**
* Gets the Redis key for the all-events stream.
* Gets the Valkey key for the all-events stream.
*
* @return The Redis key for the all-events stream
* @return The Valkey key for the all-events stream
*/
private fun getAllEventsStreamKey(): String {
return "${properties.streamPrefix}${properties.allEventsStream}"
@@ -0,0 +1,328 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.infrastructure.eventstore.valkey
import at.mocode.core.domain.event.DomainEvent
import at.mocode.core.domain.model.EventVersion
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.dao.DataAccessException
import org.springframework.data.domain.Range
import org.springframework.data.redis.core.SessionCallback
import org.springframework.data.redis.core.StringRedisTemplate
import java.util.concurrent.ConcurrentHashMap
import kotlin.uuid.Uuid
class ValkeyEventStore(
private val valkeyTemplate: StringRedisTemplate,
private val serializer: EventSerializer,
private val properties: ValkeyEventStoreProperties
) : EventStore {
private val logger = LoggerFactory.getLogger(ValkeyEventStore::class.java)
private val streamVersionCache = ConcurrentHashMap<Uuid, Long>()
private val metrics = EventStoreMetrics()
override fun appendToStream(events: List<DomainEvent>, streamId: Uuid, expectedVersion: Long): Long {
val operationId = "batch-append-${System.nanoTime()}"
metrics.startOperation(operationId)
try {
if (events.isEmpty()) {
logger.debug("Empty event list provided for stream {}, returning current version", streamId)
val version = getStreamVersion(streamId)
metrics.recordAppendSuccess(operationId, 0, true)
return version
}
val aggregateId = events.first().aggregateId
require(events.all { it.aggregateId == aggregateId }) {
"All events must belong to the same aggregate. Expected: $aggregateId, but found mixed aggregate IDs"
}
require(streamId == aggregateId.value) {
"Stream ID $streamId must match aggregate ID ${aggregateId.value}"
}
logger.debug("Appending {} events to stream {} with expected version {}", events.size, streamId, expectedVersion)
val currentVersion = validateAndGetCurrentVersion(streamId, expectedVersion)
val newVersion = appendEventsInBatch(events, streamId, currentVersion)
logger.info("Successfully appended {} events to stream {}. New version: {}", events.size, streamId, newVersion)
metrics.recordAppendSuccess(operationId, events.size, true)
metrics.logPerformanceMetrics()
return newVersion
} catch (e: ConcurrencyException) {
metrics.recordAppendFailure(operationId, e, true)
throw e
} catch (e: Exception) {
metrics.recordAppendFailure(operationId, e, false)
throw e
}
}
override fun appendToStream(event: DomainEvent, streamId: Uuid, expectedVersion: Long): Long {
val operationId = "single-append-${System.nanoTime()}"
metrics.startOperation(operationId)
try {
logger.debug("Appending single event to stream {} with expected version {}", streamId, expectedVersion)
val currentVersion = validateAndGetCurrentVersion(streamId, expectedVersion)
val newVersion = appendToStreamInternal(event, streamId, currentVersion)
logger.info("Successfully appended event to stream {}. New version: {}", streamId, newVersion)
metrics.recordAppendSuccess(operationId, 1, false)
metrics.logPerformanceMetrics()
return newVersion
} catch (e: ConcurrencyException) {
metrics.recordAppendFailure(operationId, e, true)
throw e
} catch (e: Exception) {
metrics.recordAppendFailure(operationId, e, false)
throw e
}
}
/**
* Validiert die erwartete Version und gibt die aktuelle Version zurück, behandelt Cache-Invalidator bei Konflikten.
*/
private fun validateAndGetCurrentVersion(streamId: Uuid, expectedVersion: Long): Long {
var currentVersion = getStreamVersion(streamId)
if (currentVersion != expectedVersion) {
logger.warn(
"Version conflict detected for stream {}. Expected: {}, current: {}",
streamId,
expectedVersion,
currentVersion
)
streamVersionCache.remove(streamId) // Invalidate cache on conflict
val actualVersion = getStreamVersion(streamId) // Re-fetch from Valkey
if (actualVersion != expectedVersion) {
throw ConcurrencyException("Concurrency conflict for stream $streamId: expected version $expectedVersion but got $actualVersion")
}
currentVersion = actualVersion
}
return currentVersion
}
/**
* Fügt mehrere Events in einer einzigen Valkey-Transaktion zu für optimale Performance.
*/
private fun appendEventsInBatch(events: List<DomainEvent>, streamId: Uuid, currentVersion: Long): Long {
val streamKey = getStreamKey(streamId)
val allEventsStreamKey = getAllEventsStreamKey()
// Validate all events have correct sequential versions
events.forEachIndexed { index, event ->
val expectedVersion = currentVersion + index + 1
require(event.version.value == expectedVersion) {
"Event $index version ${event.version.value} does not match expected version $expectedVersion for stream $streamId"
}
}
logger.debug("Writing {} events to stream {} and all-events stream in single transaction", events.size, streamId)
try {
valkeyTemplate.execute(object : SessionCallback<List<Any>> {
@Throws(DataAccessException::class)
override fun <K, V> execute(operations: org.springframework.data.redis.core.RedisOperations<K, V>): List<Any> {
val streamOps = (operations as StringRedisTemplate).opsForStream<String, String>()
operations.multi()
// Add all events to both streams in a single transaction
events.forEach { event ->
val eventData = serializer.serialize(event)
streamOps.add(streamKey, eventData)
streamOps.add(allEventsStreamKey, eventData)
}
return operations.exec()
}
})
val newVersion = currentVersion + events.size
streamVersionCache[streamId] = newVersion
logger.debug(
"Successfully wrote {} events to Valkey streams in batch, updated cache version to {}",
events.size,
newVersion
)
return newVersion
} catch (e: Exception) {
logger.error("Failed to append {} events in batch for stream {}: {}", events.size, streamId, e.message, e)
streamVersionCache.remove(streamId)
throw e
}
}
private fun appendToStreamInternal(event: DomainEvent, streamId: Uuid, currentVersion: Long): Long {
val newVersion = currentVersion + 1
require(event.version.value == newVersion) {
"Event version ${event.version.value} does not match expected new version $newVersion for stream $streamId"
}
val streamKey = getStreamKey(streamId)
val allEventsStreamKey = getAllEventsStreamKey()
val eventData = serializer.serialize(event)
logger.debug("Writing event {} to stream {} and all-events stream atomically", event.eventId, streamId)
try {
valkeyTemplate.execute(object : SessionCallback<List<Any>> {
@Throws(DataAccessException::class)
override fun <K, V> execute(operations: org.springframework.data.redis.core.RedisOperations<K, V>): List<Any> {
val streamOps = (operations as StringRedisTemplate).opsForStream<String, String>()
operations.multi()
streamOps.add(streamKey, eventData)
streamOps.add(allEventsStreamKey, eventData)
return operations.exec()
}
})
streamVersionCache[streamId] = newVersion
logger.debug(
"Successfully wrote event {} to Valkey streams, updated cache version to {}",
event.eventId,
newVersion
)
return newVersion
} catch (e: Exception) {
logger.error("Failed to append event {} transactionally for stream {}: {}", event.eventId, streamId, e.message, e)
streamVersionCache.remove(streamId)
throw e
}
}
override fun readFromStream(streamId: Uuid, fromVersion: Long, toVersion: Long?): List<DomainEvent> {
val operationId = "read-stream-${System.nanoTime()}"
metrics.startOperation(operationId)
try {
val streamKey = getStreamKey(streamId)
val range = Range.of(Range.Bound.inclusive("-"), Range.Bound.unbounded())
val records = valkeyTemplate.opsForStream<String, String>().range(streamKey, range)
val events = records?.mapNotNull { record ->
try {
serializer.deserialize(record.value)
} catch (e: Exception) {
logger.error("Error deserializing event from stream {}: {}", streamId, e.message, e)
null
}
} ?: emptyList()
val filteredEvents = events.filter {
it.version >= EventVersion(fromVersion) && (toVersion == null || it.version <= EventVersion(toVersion))
}
metrics.recordReadSuccess(operationId, filteredEvents.size)
return filteredEvents
} catch (e: Exception) {
metrics.recordReadFailure(operationId, e)
throw e
}
}
override fun getStreamVersion(streamId: Uuid): Long {
streamVersionCache[streamId]?.let {
metrics.recordCacheHit()
return it
}
metrics.recordCacheMiss()
val streamKey = getStreamKey(streamId)
val size = valkeyTemplate.opsForStream<String, String>().size(streamKey) ?: 0L
streamVersionCache[streamId] = size
return size
}
private fun getStreamKey(streamId: Uuid): String {
return "${properties.streamPrefix}$streamId"
}
private fun getAllEventsStreamKey(): String {
return "${properties.streamPrefix}${properties.allEventsStream}"
}
override fun readAllEvents(fromPosition: Long, maxCount: Int?): List<DomainEvent> {
val operationId = "read-all-events-${System.nanoTime()}"
metrics.startOperation(operationId)
try {
val allEventsStreamKey = getAllEventsStreamKey()
val range = Range.of(Range.Bound.inclusive("-"), Range.Bound.unbounded())
val records = valkeyTemplate.opsForStream<String, String>().range(allEventsStreamKey, range)
val events = records?.mapNotNull { record ->
try {
serializer.deserialize(record.value)
} catch (e: Exception) {
logger.error("Error deserializing event from all events stream: {}", e.message, e)
null
}
} ?: emptyList()
val filteredEvents = events.drop(fromPosition.toInt())
val result = if (maxCount != null && maxCount > 0) {
filteredEvents.take(maxCount)
} else {
filteredEvents
}
metrics.recordReadSuccess(operationId, result.size)
return result
} catch (e: Exception) {
metrics.recordReadFailure(operationId, e)
throw e
}
}
override fun subscribeToStream(streamId: Uuid, fromVersion: Long, handler: (DomainEvent) -> Unit): Subscription {
// Basic implementation - for full functionality, integrate with ValkeyEventConsumer
logger.info("Stream subscription for streamId {} from version {} - basic implementation", streamId, fromVersion)
metrics.recordSubscription()
return BasicSubscription {
logger.info("Unsubscribed from stream {}", streamId)
}
}
override fun subscribeToAll(fromPosition: Long, handler: (DomainEvent) -> Unit): Subscription {
// Basic implementation - for full functionality, integrate with ValkeyEventConsumer
logger.info("All events subscription from position {} - basic implementation", fromPosition)
metrics.recordSubscription()
return BasicSubscription {
logger.info("Unsubscribed from all events")
}
}
}
/**
* Basic subscription implementation.
*/
private class BasicSubscription(
private val unsubscribeAction: () -> Unit
) : Subscription {
@Volatile
private var active = true
override fun unsubscribe() {
if (active) {
active = false
unsubscribeAction()
}
}
override fun isActive(): Boolean = active
}
@@ -0,0 +1,136 @@
package at.mocode.infrastructure.eventstore.valkey
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
/**
* Valkey Event Store Eigenschaften.
*/
@ConfigurationProperties(prefix = "valkey.event-store")
data class ValkeyEventStoreProperties(
var host: String = "localhost",
var port: Int = 6379,
var password: String? = null,
var database: Int = 0,
var connectionTimeout: Long = 2000,
var readTimeout: Long = 2000,
var usePooling: Boolean = true,
var maxPoolSize: Int = 8,
var minPoolSize: Int = 2,
var consumerGroup: String = "event-processors",
var consumerName: String = "event-consumer",
var streamPrefix: String = "event-stream:",
var allEventsStream: String = "all-events",
var claimIdleTimeout: Duration = Duration.ofMinutes(1),
var pollTimeout: Duration = Duration.ofMillis(100),
var maxBatchSize: Int = 100,
var createConsumerGroupIfNotExists: Boolean = true
)
/**
* Spring-Konfiguration für Valkey Event Store.
*/
@Configuration
@EnableConfigurationProperties(ValkeyEventStoreProperties::class)
class ValkeyEventStoreConfiguration {
/**
* Erstellt eine Valkey-Unsatisfactoriness für den Event Store.
*
* @param properties Valkey Event Store Eigenschaften
* @return Valkey-Unsatisfactoriness
*/
@Bean
@ConditionalOnMissingBean(name = ["eventStoreValkeyConnectionFactory"])
fun eventStoreValkeyConnectionFactory(properties: ValkeyEventStoreProperties): 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()
}
}
/**
* Erstellt ein Valkey-Template für den Event Store.
*
* @param connectionFactory Valkey-Unsatisfactoriness
* @return Valkey-Template
*/
@Bean
@ConditionalOnMissingBean(name = ["eventStoreValkeyTemplate"])
fun eventStoreValkeyTemplate(
@org.springframework.beans.factory.annotation.Qualifier("eventStoreValkeyConnectionFactory")
connectionFactory: RedisConnectionFactory
): StringRedisTemplate {
return StringRedisTemplate().apply {
setConnectionFactory(connectionFactory)
afterPropertiesSet()
}
}
/**
* Erstellt einen Event-Serializer.
*
* @return Event-Serializer
*/
@Bean
@ConditionalOnMissingBean
fun eventSerializer(): EventSerializer {
return JacksonEventSerializer()
}
/**
* Erstellt einen Valkey Event Store.
*
* @param valkeyTemplate Valkey-Template
* @param eventSerializer Event-Serializer
* @param properties Valkey Event Store Eigenschaften
* @return Event Store
*/
@Bean
@ConditionalOnMissingBean
fun eventStore(
@org.springframework.beans.factory.annotation.Qualifier("eventStoreValkeyTemplate")
valkeyTemplate: StringRedisTemplate,
eventSerializer: EventSerializer,
properties: ValkeyEventStoreProperties
): EventStore {
return ValkeyEventStore(valkeyTemplate, eventSerializer, properties)
}
/**
* Erstellt einen Valkey Event Consumer.
*
* @param valkeyTemplate Valkey-Template
* @param eventSerializer Event-Serializer
* @param properties Valkey Event Store Eigenschaften
* @return Event Consumer
*/
@Bean
@ConditionalOnMissingBean
fun eventConsumer(
@org.springframework.beans.factory.annotation.Qualifier("eventStoreValkeyTemplate")
valkeyTemplate: StringRedisTemplate,
eventSerializer: EventSerializer,
properties: ValkeyEventStoreProperties
): ValkeyEventConsumer {
return ValkeyEventConsumer(valkeyTemplate, eventSerializer, properties)
}
}
@@ -1,4 +1,4 @@
package at.mocode.infrastructure.eventstore.redis
package at.mocode.infrastructure.eventstore.valkey
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.model.*
@@ -0,0 +1,251 @@
package at.mocode.infrastructure.eventstore.valkey
import at.mocode.core.domain.event.DomainEvent
import at.mocode.core.domain.model.*
import at.mocode.infrastructure.cache.api.CacheConfiguration
import at.mocode.infrastructure.cache.api.DistributedCache
import at.mocode.infrastructure.cache.valkey.JacksonCacheSerializer
import at.mocode.infrastructure.cache.valkey.ValkeyConfiguration
import at.mocode.infrastructure.cache.valkey.ValkeyDistributedCache
import at.mocode.infrastructure.eventstore.api.EventStore
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.GenericContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName
import kotlin.time.Duration.Companion.minutes
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Integration Test zur Demonstration der gleichzeitigen Verwendung von
* valkey-cache und valkey-event-store im selben Service.
*
* Dieser Test zeigt:
* 1. Beide Module können ohne Konflikte gleichzeitig verwendet werden
* 2. Separate Valkey Databases verhindern Daten-Überschneidungen
* 3. Separate Bean-Namen verhindern Bean-Konflikte
* 4. Beide Module arbeiten unabhängig voneinander
*/
@OptIn(ExperimentalUuidApi::class)
@SpringBootTest(
classes = [
ValkeyCacheAndEventStoreIntegrationTest.TestConfig::class
]
)
@Testcontainers
class ValkeyCacheAndEventStoreIntegrationTest {
companion object {
@Container
@JvmStatic
val valkeyContainer: GenericContainer<*> = GenericContainer(
DockerImageName.parse("valkey/valkey:9-alpine")
).withExposedPorts(6379)
@DynamicPropertySource
@JvmStatic
fun configureProperties(registry: DynamicPropertyRegistry) {
// Cache Configuration (Database 0)
registry.add("valkey.host") { valkeyContainer.host }
registry.add("valkey.port") { valkeyContainer.getMappedPort(6379) }
registry.add("valkey.database") { 0 }
// Event Store Configuration (Database 1)
registry.add("valkey.event-store.host") { valkeyContainer.host }
registry.add("valkey.event-store.port") { valkeyContainer.getMappedPort(6379) }
registry.add("valkey.event-store.database") { 1 }
registry.add("valkey.event-store.consumerGroup") { "test-group" }
}
@BeforeAll
@JvmStatic
fun setUp() {
println("[DEBUG_LOG] Starting Valkey container for integration test")
valkeyContainer.start()
}
@AfterAll
@JvmStatic
fun tearDown() {
println("[DEBUG_LOG] Stopping Valkey container")
valkeyContainer.stop()
}
}
@Configuration
@Import(
ValkeyConfiguration::class,
ValkeyEventStoreConfiguration::class
)
class TestConfig {
@Bean
fun distributedCache(
@Qualifier("valkeyTemplate") valkeyTemplate: RedisTemplate<String, ByteArray>,
cacheConfiguration: CacheConfiguration
): DistributedCache {
return ValkeyDistributedCache(
valkeyTemplate = valkeyTemplate,
serializer = JacksonCacheSerializer(),
config = cacheConfiguration
)
}
}
@Autowired
private lateinit var cache: DistributedCache
@Autowired
private lateinit var eventStore: EventStore
// Verify separate ConnectionFactories
@Autowired
@Qualifier("valkeyConnectionFactory")
private lateinit var cacheConnectionFactory: RedisConnectionFactory
@Autowired
@Qualifier("eventStoreValkeyConnectionFactory")
private lateinit var eventStoreConnectionFactory: RedisConnectionFactory
@Test
fun `test both modules can be used simultaneously without conflicts`(): Unit = runBlocking {
println("[DEBUG_LOG] Testing simultaneous usage of cache and event store")
// Test Cache Operations
val cacheKey = "test-user-${Uuid.random()}"
val cacheData = TestUser("John Doe", 30)
println("[DEBUG_LOG] Cache: Storing data with key=$cacheKey")
cache.set(cacheKey, cacheData, ttl = 5.minutes)
val retrievedCacheData = cache.get(cacheKey, TestUser::class.java)
println("[DEBUG_LOG] Cache: Retrieved data=$retrievedCacheData")
assertNotNull(retrievedCacheData)
assertEquals(cacheData.name, retrievedCacheData!!.name)
assertEquals(cacheData.age, retrievedCacheData.age)
// Test Event Store Operations
val aggregateId = Uuid.random()
val event = TestEvent(
aggregateId = AggregateId(aggregateId),
eventType = EventType("UserCreated"),
data = mapOf("userId" to aggregateId.toString(), "name" to "Jane Doe")
)
println("[DEBUG_LOG] EventStore: Appending event for aggregateId=$aggregateId")
eventStore.appendToStream(event, aggregateId, 0L)
val loadedEvents = eventStore.readFromStream(aggregateId)
println("[DEBUG_LOG] EventStore: Loaded ${loadedEvents.size} events")
assertEquals(1, loadedEvents.size)
assertEquals(event.eventType, (loadedEvents[0] as TestEvent).eventType)
// Verify Cache and Event Store are independent
println("[DEBUG_LOG] Verifying cache and event store are independent")
// Cache should still work after event operations
val cacheStillWorks = cache.get(cacheKey, TestUser::class.java)
assertNotNull(cacheStillWorks)
println("[DEBUG_LOG] Cache still works: key=$cacheKey exists")
// Event store should still work after cache operations
val eventsStillWork = eventStore.readFromStream(aggregateId)
assertEquals(1, eventsStillWork.size)
println("[DEBUG_LOG] Event store still works: aggregateId=$aggregateId has ${eventsStillWork.size} events")
println("[DEBUG_LOG] Test completed successfully - Both modules work independently")
}
@Test
fun `test separate connection factories are used`() {
println("[DEBUG_LOG] Testing separate connection factories")
assertNotNull(cacheConnectionFactory)
assertNotNull(eventStoreConnectionFactory)
// The connection factories should be different instances
println("[DEBUG_LOG] Cache ConnectionFactory: ${cacheConnectionFactory.javaClass.simpleName}")
println("[DEBUG_LOG] EventStore ConnectionFactory: ${eventStoreConnectionFactory.javaClass.simpleName}")
// Both should be functional
val cacheConnection = cacheConnectionFactory.connection
val eventStoreConnection = eventStoreConnectionFactory.connection
assertNotNull(cacheConnection)
assertNotNull(eventStoreConnection)
// Different databases
println("[DEBUG_LOG] Cache uses database: ${cacheConnection.nativeConnection}")
println("[DEBUG_LOG] EventStore uses database: ${eventStoreConnection.nativeConnection}")
cacheConnection.close()
eventStoreConnection.close()
println("[DEBUG_LOG] Both connection factories are functional and independent")
}
@Test
fun `test data isolation between cache and event store`(): Unit = runBlocking {
println("[DEBUG_LOG] Testing data isolation between cache and event store")
val sharedKey = "shared-key-${Uuid.random()}"
// Store data in a cache
cache.set(sharedKey, TestUser("Cache User", 25), ttl = 5.minutes)
println("[DEBUG_LOG] Stored data in cache with key=$sharedKey")
// Store event with the same UUID in the event store
val aggregateId = Uuid.random()
val event = TestEvent(
aggregateId = AggregateId(aggregateId),
eventType = EventType("TestEvent"),
data = mapOf("key" to sharedKey)
)
eventStore.appendToStream(event, aggregateId, 0L)
println("[DEBUG_LOG] Stored event in event store with aggregateId=$aggregateId")
// Both should be retrievable independently
val cachedUser = cache.get(sharedKey, TestUser::class.java)
val storedEvents = eventStore.readFromStream(aggregateId)
assertNotNull(cachedUser)
assertEquals(1, storedEvents.size)
println("[DEBUG_LOG] Data isolation verified:")
println("[DEBUG_LOG] - Cache retrieved: ${cachedUser?.name}")
println("[DEBUG_LOG] - Event store retrieved: ${storedEvents.size} events")
println("[DEBUG_LOG] Cache and Event Store use separate databases - no conflicts!")
}
// Test data classes
data class TestUser(
val name: String,
val age: Int
)
data class TestEvent(
override val aggregateId: AggregateId,
override val eventType: EventType,
val data: Map<String, String>,
override val eventId: EventId = EventId(Uuid.random()),
override val timestamp: kotlin.time.Instant = kotlin.time.Clock.System.now(),
override val version: EventVersion = EventVersion(1),
override val correlationId: CorrelationId? = null,
override val causationId: CausationId? = null
) : DomainEvent
}
@@ -0,0 +1,509 @@
package at.mocode.infrastructure.eventstore.valkey
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.event.DomainEvent
import at.mocode.core.domain.model.AggregateId
import at.mocode.core.domain.model.EventType
import at.mocode.core.domain.model.EventVersion
import at.mocode.infrastructure.eventstore.api.EventSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
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.util.concurrent.*
import java.util.concurrent.atomic.AtomicInteger
import kotlin.uuid.Uuid
/**
* Consumer Resilience Tests - Important for Event-Processing reliability.
*/
@Testcontainers
class ValkeyEventConsumerResilienceTest {
private val logger = LoggerFactory.getLogger(ValkeyEventConsumerResilienceTest::class.java)
companion object {
@Container
val valkeyContainer: GenericContainer<*> = GenericContainer(DockerImageName.parse("valkey/valkey:9-alpine"))
.withExposedPorts(6379)
}
private lateinit var valkeyTemplate: StringRedisTemplate
private lateinit var serializer: EventSerializer
private lateinit var properties: ValkeyEventStoreProperties
private lateinit var eventStore: ValkeyEventStore
private lateinit var consumer1: ValkeyEventConsumer
private lateinit var consumer2: ValkeyEventConsumer
@BeforeEach
fun setUp() {
val valkeyPort = valkeyContainer.getMappedPort(6379)
val valkeyHost = valkeyContainer.host
val valkeyConfig = RedisStandaloneConfiguration(valkeyHost, valkeyPort)
val connectionFactory = LettuceConnectionFactory(valkeyConfig)
connectionFactory.afterPropertiesSet()
valkeyTemplate = StringRedisTemplate(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(ResilienceTestEvent::class.java, "ResilienceTestEvent")
registerEventType(SlowTestEvent::class.java, "SlowTestEvent")
registerEventType(FailingTestEvent::class.java, "FailingTestEvent")
}
properties = ValkeyEventStoreProperties().apply {
streamPrefix = "test-stream:"
allEventsStream = "all-events"
consumerGroup = "resilience-test-group"
consumerName = "resilience-consumer-1"
claimIdleTimeout = java.time.Duration.ofMillis(100) // Short timeout for testing
pollTimeout = java.time.Duration.ofMillis(50)
maxBatchSize = 10
}
eventStore = ValkeyEventStore(valkeyTemplate, serializer, properties)
consumer1 = ValkeyEventConsumer(valkeyTemplate, serializer, properties)
// Create a second consumer with a different name for testing multiple consumers
val properties2 = ValkeyEventStoreProperties().apply {
streamPrefix = properties.streamPrefix
allEventsStream = properties.allEventsStream
consumerGroup = properties.consumerGroup
consumerName = "resilience-consumer-2"
claimIdleTimeout = properties.claimIdleTimeout
pollTimeout = properties.pollTimeout
maxBatchSize = properties.maxBatchSize
}
consumer2 = ValkeyEventConsumer(valkeyTemplate, serializer, properties2)
cleanupValkey()
}
@AfterEach
fun tearDown() {
try {
consumer1.shutdown()
consumer2.shutdown()
} catch (_: Exception) {
// Ignore shutdown errors in tests
}
cleanupValkey()
}
private fun cleanupValkey() {
try {
val streamKey = "${properties.streamPrefix}${properties.allEventsStream}"
// First, try to destroy the consumer group multiple times with retry logic
var attempts = 0
while (attempts < 3) {
try {
valkeyTemplate.opsForStream<String, String>()
.destroyGroup(streamKey, properties.consumerGroup)
logger.debug("Successfully destroyed consumer group: ${properties.consumerGroup}")
break
} catch (e: Exception) {
attempts++
if (e.message?.contains("NOGROUP") == true) {
// Group doesn't exist, which is fine
break
}
if (attempts < 3) {
Thread.sleep(100) // Wait before retry
} else {
logger.debug("Could not destroy consumer group after 3 attempts: ${e.message}")
}
}
}
// Wait for group destruction to complete
Thread.sleep(100)
// Then delete all stream-related keys
val keys = valkeyTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
valkeyTemplate.delete(keys)
logger.debug("Deleted ${keys.size} Valkey keys with prefix: ${properties.streamPrefix}")
}
// Wait for Valkey operations to complete
Thread.sleep(200)
// Verify cleanup by checking if keys still exist
val remainingKeys = valkeyTemplate.keys("${properties.streamPrefix}*")
if (!remainingKeys.isNullOrEmpty()) {
logger.warn("Some keys still exist after cleanup: $remainingKeys")
// Force to delete remaining keys
valkeyTemplate.delete(remainingKeys)
Thread.sleep(100)
}
} catch (e: Exception) {
logger.warn("Error during Valkey cleanup: ${e.message}", e)
// Additional cleanup attempt
try {
Thread.sleep(200)
val keys = valkeyTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
valkeyTemplate.delete(keys)
}
} catch (retryException: Exception) {
logger.warn("Retry cleanup also failed: ${retryException.message}")
}
}
}
@Test
fun `should handle multiple consumers processing events without conflicts`() {
val aggregateId = Uuid.random()
val latch = CountDownLatch(2)
val processedEvents = CopyOnWriteArrayList<DomainEvent>()
// Both consumers will process events
consumer1.registerEventHandler("ResilienceTestEvent") { event ->
processedEvents.add(event)
logger.debug("Consumer1 processed: {}", (event as ResilienceTestEvent).data)
latch.countDown()
}
consumer2.registerEventHandler("ResilienceTestEvent") { event ->
processedEvents.add(event)
logger.debug("Consumer2 processed: {}", (event as ResilienceTestEvent).data)
latch.countDown()
}
// Initialize both consumers
consumer1.init()
consumer2.init()
// Publish test events
val event1 = ResilienceTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "Multi consumer event 1"
)
val event2 = ResilienceTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(2L),
data = "Multi consumer event 2"
)
eventStore.appendToStream(listOf(event1, event2), aggregateId, 0)
// Let both consumers poll multiple times to ensure all events are processed
val executor = Executors.newFixedThreadPool(2)
executor.submit {
repeat(5) {
consumer1.pollEvents()
Thread.sleep(50)
}
}
executor.submit {
repeat(5) {
consumer2.pollEvents()
Thread.sleep(50)
}
}
// Wait for processing with increased timeout
assertTrue(latch.await(10, TimeUnit.SECONDS), "Events were not processed within timeout")
executor.shutdown()
// Verify that events were processed (by either consumer due to consumer groups)
assertTrue(processedEvents.size >= 2, "Expected at least 2 processed events, got ${processedEvents.size}")
println("[DEBUG_LOG] Processed ${processedEvents.size} events total")
}
@Test
fun `should handle consumer group creation and recovery`() {
// Test that a consumer group is created automatically during init()
val aggregateId = Uuid.random()
val latch = CountDownLatch(1)
val receivedEvents = CopyOnWriteArrayList<DomainEvent>()
// Register handler before init
consumer1.registerEventHandler("ResilienceTestEvent") { receivedEvent ->
receivedEvents.add(receivedEvent)
latch.countDown()
}
// Init should create consumer groups automatically
consumer1.init()
// Add an event after initialization
val event = ResilienceTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "Group creation test"
)
eventStore.appendToStream(event, aggregateId, 0)
// Consumer should be able to process events from the automatically created group
consumer1.pollEvents()
assertTrue(latch.await(3, TimeUnit.SECONDS), "Event was not processed")
assertEquals(1, receivedEvents.size)
assertEquals("Group creation test", (receivedEvents[0] as ResilienceTestEvent).data)
}
@Test
fun `should process events exactly once in consumer group`() {
val aggregateId = Uuid.random()
val numberOfEvents = 10
val processedEvents = ConcurrentHashMap<String, AtomicInteger>()
val latch = CountDownLatch(numberOfEvents)
// Register the same handler on both consumers
val handler = { event: DomainEvent ->
val testEvent = event as ResilienceTestEvent
processedEvents.computeIfAbsent(testEvent.data) { AtomicInteger(0) }.incrementAndGet()
logger.debug("Processed: {}", testEvent.data)
latch.countDown()
}
consumer1.registerEventHandler("ResilienceTestEvent", handler)
consumer2.registerEventHandler("ResilienceTestEvent", handler)
// Initialize both consumers
consumer1.init()
consumer2.init()
// Create and append events
val events = (1..numberOfEvents).map { i ->
ResilienceTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(i.toLong()),
data = "Exactly-once event $i"
)
}
eventStore.appendToStream(events, aggregateId, 0)
// Start polling from both consumers simultaneously
val executor = Executors.newFixedThreadPool(2)
executor.submit {
repeat(5) {
consumer1.pollEvents()
Thread.sleep(50)
}
}
executor.submit {
repeat(5) {
consumer2.pollEvents()
Thread.sleep(50)
}
}
// Wait for all events to be processed
assertTrue(latch.await(10, TimeUnit.SECONDS), "Not all events were processed in time")
executor.shutdown()
// Verify each event was processed exactly once across both consumers
assertEquals(numberOfEvents, processedEvents.size)
processedEvents.forEach { (eventData, count) ->
assertEquals(1, count.get(), "Event '$eventData' was processed ${count.get()} times instead of exactly once")
}
logger.debug("All {} events processed exactly once", numberOfEvents)
}
@Test
fun `should handle slow event handlers gracefully`() {
val aggregateId = Uuid.random()
val processedEvents = CopyOnWriteArrayList<String>()
val latch = CountDownLatch(3)
// Register a slow handler
consumer1.registerEventHandler("SlowTestEvent") { event ->
val slowEvent = event as SlowTestEvent
processedEvents.add("Started: ${slowEvent.data}")
Thread.sleep(slowEvent.processingTimeMs) // Simulate slow processing
processedEvents.add("Completed: ${slowEvent.data}")
latch.countDown()
}
consumer1.init()
// Create events with different processing times
val events = listOf(
SlowTestEvent(AggregateId(aggregateId), EventVersion(1L), "Fast event", 10),
SlowTestEvent(AggregateId(aggregateId), EventVersion(2L), "Medium event", 100),
SlowTestEvent(AggregateId(aggregateId), EventVersion(3L), "Slow event", 200)
)
eventStore.appendToStream(events, aggregateId, 0)
// Start processing
val startTime = System.currentTimeMillis()
consumer1.pollEvents()
// Wait for processing to complete
assertTrue(latch.await(10, TimeUnit.SECONDS), "Slow events were not processed within timeout")
val totalTime = System.currentTimeMillis() - startTime
// Verify all events were processed
assertEquals(6, processedEvents.size) // 3 started + 3 completed
assertTrue(processedEvents.contains("Started: Fast event"))
assertTrue(processedEvents.contains("Completed: Fast event"))
assertTrue(processedEvents.contains("Started: Medium event"))
assertTrue(processedEvents.contains("Completed: Medium event"))
assertTrue(processedEvents.contains("Started: Slow event"))
assertTrue(processedEvents.contains("Completed: Slow event"))
logger.debug("Processed {} slow events in {}ms", events.size, totalTime)
processedEvents.forEach { logger.debug("Event: {}", it) }
}
@Test
fun `should handle consumer restart correctly`() {
val aggregateId = Uuid.random()
val firstPhaseEvents = mutableListOf<DomainEvent>()
val secondPhaseEvents = mutableListOf<DomainEvent>()
// First processing session
val firstLatch = CountDownLatch(1)
consumer1.registerEventHandler("ResilienceTestEvent") { event ->
firstPhaseEvents.add(event)
firstLatch.countDown()
}
consumer1.init()
// Add and process the first event
val event1 = ResilienceTestEvent(AggregateId(aggregateId), EventVersion(1L), "Before restart")
eventStore.appendToStream(event1, aggregateId, 0)
consumer1.pollEvents()
assertTrue(firstLatch.await(3, TimeUnit.SECONDS), "First event not processed")
// Verify the first phase
assertEquals(1, firstPhaseEvents.size)
assertEquals("Before restart", (firstPhaseEvents[0] as ResilienceTestEvent).data)
// Simulate shutdown and restart - create new consumer to ensure a clean state
consumer1.shutdown()
// Create a fresh consumer instance for restart simulation
val restartedConsumer = ValkeyEventConsumer(valkeyTemplate, serializer, properties)
val secondLatch = CountDownLatch(1)
restartedConsumer.registerEventHandler("ResilienceTestEvent") { event ->
secondPhaseEvents.add(event)
secondLatch.countDown()
}
restartedConsumer.init()
// Add and process a second event after restart
val event2 = ResilienceTestEvent(AggregateId(aggregateId), EventVersion(2L), "After restart")
eventStore.appendToStream(event2, aggregateId, 1)
restartedConsumer.pollEvents()
assertTrue(secondLatch.await(3, TimeUnit.SECONDS), "Second event not processed after restart")
// Verify the second phase
assertEquals(1, secondPhaseEvents.size)
assertEquals("After restart", (secondPhaseEvents[0] as ResilienceTestEvent).data)
// Cleanup
restartedConsumer.shutdown()
logger.debug("Successfully handled consumer restart")
logger.debug("First phase events: {}", firstPhaseEvents.map { (it as ResilienceTestEvent).data })
logger.debug("Second phase events: {}", secondPhaseEvents.map { (it as ResilienceTestEvent).data })
}
@Test
fun `should handle event handler exceptions gracefully without stopping processing`() {
// Ensure a clean state for this test
cleanupValkey()
val aggregateId = Uuid.random()
val processedEvents = CopyOnWriteArrayList<String>()
val latch = CountDownLatch(3) // Expecting 3 events to be processed (2 success + 1 failure)
// Register a handler that fails on specific events
consumer1.registerEventHandler("FailingTestEvent") { event ->
val failingEvent = event as FailingTestEvent
if (failingEvent.shouldFail) {
processedEvents.add("Failed: ${failingEvent.data}")
latch.countDown()
throw RuntimeException("Simulated handler failure for: ${failingEvent.data}")
} else {
processedEvents.add("Success: ${failingEvent.data}")
latch.countDown()
}
}
consumer1.init()
// Create events - some that will fail, some that will succeed
val events = listOf(
FailingTestEvent(AggregateId(aggregateId), EventVersion(1L), "Event 1", false),
FailingTestEvent(AggregateId(aggregateId), EventVersion(2L), "Event 2", true), // Will fail
FailingTestEvent(AggregateId(aggregateId), EventVersion(3L), "Event 3", false)
)
eventStore.appendToStream(events, aggregateId, 0)
// Poll multiple times to ensure all events are processed
// This is necessary because Valkey streams might not deliver all events in a single poll
for (i in 1..10) {
consumer1.pollEvents()
Thread.sleep(100)
if (latch.count == 0L) break
}
// Wait for processing
assertTrue(latch.await(5, TimeUnit.SECONDS), "Events were not processed within timeout")
// Verify that both successful and failed events were attempted
assertEquals(3, processedEvents.size)
assertTrue(processedEvents.contains("Success: Event 1"))
assertTrue(processedEvents.contains("Failed: Event 2"))
assertTrue(processedEvents.contains("Success: Event 3"))
logger.debug("Handler exceptions handled gracefully:")
processedEvents.forEach { logger.debug("Event result: {}", it) }
}
// Test event classes
@Serializable
data class ResilienceTestEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val data: String
) : BaseDomainEvent(aggregateId, EventType("ResilienceTestEvent"), version)
@Serializable
data class SlowTestEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val data: String,
val processingTimeMs: Long
) : BaseDomainEvent(aggregateId, EventType("SlowTestEvent"), version)
@Serializable
data class FailingTestEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val data: String,
val shouldFail: Boolean
) : BaseDomainEvent(aggregateId, EventType("FailingTestEvent"), version)
}
@@ -0,0 +1,388 @@
package at.mocode.infrastructure.eventstore.valkey
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.getBean
import org.springframework.boot.autoconfigure.AutoConfigurations
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.test.context.runner.ApplicationContextRunner
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.core.StringRedisTemplate
import java.time.Duration
/**
* Comprehensive test suite for ValkeyEventStoreConfiguration.
*
* Tests all aspects of Spring Boot autoconfiguration including
* - Configuration properties binding
* - Bean creation and dependency injection
* - Default value handling
* - Property conversion and validation
* - Conditional bean creation
*/
@DisplayName("ValkeyEventStoreConfiguration Tests")
class ValkeyEventStoreConfigurationTest {
private val logger = LoggerFactory.getLogger(ValkeyEventStoreConfigurationTest::class.java)
@Configuration
@EnableConfigurationProperties(ValkeyEventStoreProperties::class)
class TestConfiguration
private val contextRunner = ApplicationContextRunner()
.withConfiguration(
AutoConfigurations.of(
org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration::class.java,
org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration::class.java,
ValkeyEventStoreConfiguration::class.java
)
)
.withUserConfiguration(TestConfiguration::class.java)
@Test
@DisplayName("Should create all beans with custom configuration properties")
fun `should create beans with custom configuration properties`() {
contextRunner
.withPropertyValues(
"valkey.event-store.host=custom-valkey-host",
"valkey.event-store.port=6380",
"valkey.event-store.consumer-group=custom-group",
"valkey.event-store.max-batch-size=50"
)
.run { context ->
// Verify properties are correctly bound
val properties = context.getBean<ValkeyEventStoreProperties>()
assertNotNull(properties)
assertEquals("custom-valkey-host", properties.host)
assertEquals(6380, properties.port)
assertEquals("custom-group", properties.consumerGroup)
assertEquals(50, properties.maxBatchSize)
// Verify all beans are created
assertTrue(context.containsBean("eventStoreValkeyConnectionFactory"))
assertTrue(context.containsBean("eventStoreValkeyTemplate"))
assertTrue(context.containsBean("eventSerializer"))
assertTrue(context.containsBean("eventStore"))
assertTrue(context.containsBean("eventConsumer"))
// Verify bean types
assertNotNull(context.getBean<RedisConnectionFactory>("eventStoreValkeyConnectionFactory"))
assertNotNull(context.getBean<StringRedisTemplate>("eventStoreValkeyTemplate"))
assertNotNull(context.getBean<EventSerializer>("eventSerializer"))
assertNotNull(context.getBean<EventStore>("eventStore"))
assertNotNull(context.getBean<ValkeyEventConsumer>("eventConsumer"))
logger.debug("Custom configuration test passed - all beans created with custom properties")
}
}
@Test
@DisplayName("Should fallback to default configuration when properties are missing")
fun `should fallback to default configuration when properties missing`() {
contextRunner
.run { context ->
// Verify properties use defaults
val properties = context.getBean<ValkeyEventStoreProperties>()
assertNotNull(properties)
assertEquals("localhost", properties.host)
assertEquals(6379, properties.port)
assertNull(properties.password)
assertEquals(0, properties.database)
assertEquals(2000L, properties.connectionTimeout)
assertEquals(2000L, properties.readTimeout)
assertTrue(properties.usePooling)
assertEquals(8, properties.maxPoolSize)
assertEquals(2, properties.minPoolSize)
assertEquals("event-processors", properties.consumerGroup)
assertEquals("event-consumer", properties.consumerName)
assertEquals("event-stream:", properties.streamPrefix)
assertEquals("all-events", properties.allEventsStream)
assertEquals(Duration.ofMinutes(1), properties.claimIdleTimeout)
assertEquals(Duration.ofMillis(100), properties.pollTimeout)
assertEquals(100, properties.maxBatchSize)
assertTrue(properties.createConsumerGroupIfNotExists)
// Verify all required beans are still created by defaults
assertTrue(context.containsBean("eventStoreValkeyConnectionFactory"))
assertTrue(context.containsBean("eventStoreValkeyTemplate"))
assertTrue(context.containsBean("eventSerializer"))
assertTrue(context.containsBean("eventStore"))
assertTrue(context.containsBean("eventConsumer"))
logger.debug("Default configuration test passed - all beans created with default values")
}
}
@Test
@DisplayName("Should handle partial configuration correctly with mixed custom and default properties")
fun `should handle partial configuration correctly`() {
contextRunner
.withPropertyValues(
"valkey.event-store.host=partial-host",
"valkey.event-store.consumer-group=partial-group"
// Other properties should use defaults
)
.run { context ->
val properties = context.getBean<ValkeyEventStoreProperties>()
assertNotNull(properties)
// Verify custom properties are set
assertEquals("partial-host", properties.host)
assertEquals("partial-group", properties.consumerGroup)
// Verify defaults are used for unspecified properties
assertEquals(6379, properties.port) // Default
assertEquals("event-consumer", properties.consumerName) // Default
assertEquals("event-stream:", properties.streamPrefix) // Default
// All beans should still be created
assertTrue(context.containsBean("eventStoreValkeyConnectionFactory"))
assertTrue(context.containsBean("eventStore"))
assertTrue(context.containsBean("eventConsumer"))
logger.debug("Partial configuration test passed - mixed custom/default properties work")
}
}
@Test
@DisplayName("Should handle Valkey connection factory creation correctly")
fun `should handle Valkey connection factory creation correctly`() {
contextRunner
.withPropertyValues(
"valkey.event-store.host=test-host",
"valkey.event-store.port=6380",
"valkey.event-store.password=test-password",
"valkey.event-store.database=1"
)
.run { context ->
val connectionFactory = context.getBean<RedisConnectionFactory>("eventStoreValkeyConnectionFactory")
assertNotNull(connectionFactory)
// Verify the connection factory is properly configured
// Note: We can't easily test the internal configuration without making actual connections,
// but we can verify the bean is created and is the right type
assertTrue(connectionFactory::class.java.name.contains("LettuceConnectionFactory"))
logger.debug("Valkey connection factory creation test passed")
}
}
@Test
fun `should handle Valkey template creation correctly`() {
contextRunner
.run { context ->
val valkeyTemplate = context.getBean<StringRedisTemplate>("eventStoreValkeyTemplate")
assertNotNull(valkeyTemplate)
// Verify the template is properly set up
assertNotNull(valkeyTemplate.connectionFactory)
logger.debug("Valkey template creation test passed")
}
}
@Test
fun `should create EventSerializer with correct type`() {
contextRunner
.run { context ->
val eventSerializer = context.getBean<EventSerializer>("eventSerializer")
assertNotNull(eventSerializer)
// Verify it's the Jackson implementation
assertTrue(eventSerializer is JacksonEventSerializer)
logger.debug("EventSerializer creation test passed - JacksonEventSerializer created")
}
}
@Test
fun `should create EventStore with correct dependencies`() {
contextRunner
.run { context ->
val eventStore = context.getBean<EventStore>("eventStore")
assertNotNull(eventStore)
// Verify it's the Valkey implementation
assertTrue(eventStore is ValkeyEventStore)
// Verify dependencies are wired correctly
val valkeyTemplate = context.getBean<StringRedisTemplate>("eventStoreValkeyTemplate")
val eventSerializer = context.getBean<EventSerializer>("eventSerializer")
val properties = context.getBean<ValkeyEventStoreProperties>()
assertNotNull(valkeyTemplate)
assertNotNull(eventSerializer)
assertNotNull(properties)
logger.debug("EventStore creation test passed - ValkeyEventStore created with dependencies")
}
}
@Test
fun `should create EventConsumer with correct dependencies`() {
contextRunner
.run { context ->
val eventConsumer = context.getBean<ValkeyEventConsumer>("eventConsumer")
assertNotNull(eventConsumer)
// Verify dependencies are available
val valkeyTemplate = context.getBean<StringRedisTemplate>("eventStoreValkeyTemplate")
val eventSerializer = context.getBean<EventSerializer>("eventSerializer")
val properties = context.getBean<ValkeyEventStoreProperties>()
assertNotNull(valkeyTemplate)
assertNotNull(eventSerializer)
assertNotNull(properties)
logger.debug("EventConsumer creation test passed - ValkeyEventConsumer created with dependencies")
}
}
@Test
fun `should handle boolean and numeric property conversion correctly`() {
contextRunner
.withPropertyValues(
"valkey.event-store.use-pooling=false",
"valkey.event-store.max-pool-size=16",
"valkey.event-store.min-pool-size=4",
"valkey.event-store.max-batch-size=25",
"valkey.event-store.create-consumer-group-if-not-exists=false"
)
.run { context ->
val properties = context.getBean<ValkeyEventStoreProperties>()
assertNotNull(properties)
// Verify boolean properties
assertFalse(properties.usePooling)
assertFalse(properties.createConsumerGroupIfNotExists)
// Verify numeric properties
assertEquals(16, properties.maxPoolSize)
assertEquals(4, properties.minPoolSize)
assertEquals(25, properties.maxBatchSize)
logger.debug("Property type conversion test passed - boolean and numeric values handled correctly")
}
}
@Test
fun `should handle Duration property conversion correctly`() {
contextRunner
.withPropertyValues(
"valkey.event-store.claim-idle-timeout=5m", // 5 minutes
"valkey.event-store.poll-timeout=500ms" // 500 milliseconds
)
.run { context ->
val properties = context.getBean<ValkeyEventStoreProperties>()
assertNotNull(properties)
// Verify Duration properties
assertEquals(Duration.ofMinutes(5), properties.claimIdleTimeout)
assertEquals(Duration.ofMillis(500), properties.pollTimeout)
logger.debug("Duration property conversion test passed")
}
}
@Test
fun `should handle ConditionalOnMissingBean annotations correctly`() {
contextRunner
.withBean("eventSerializer", EventSerializer::class.java, { JacksonEventSerializer() })
.run { context ->
// Should use the manually provided bean instead of creating a new one
val eventSerializer = context.getBean<EventSerializer>("eventSerializer")
assertNotNull(eventSerializer)
// Should still create other beans
assertTrue(context.containsBean("eventStore"))
assertTrue(context.containsBean("eventConsumer"))
logger.debug("ConditionalOnMissingBean test passed - manual bean used, others created")
}
}
@Test
@DisplayName("Should handle boundary property values correctly")
fun `should handle boundary property values correctly`() {
contextRunner
.withPropertyValues(
"valkey.event-store.port=65535", // Maximum valid port
"valkey.event-store.max-batch-size=1", // Minimum valid batch size
"valkey.event-store.connection-timeout=1", // Minimum valid timeout
"valkey.event-store.database=15" // High database number
)
.run { context ->
// Context should start with boundary values
assertTrue(context.isRunning)
val properties = context.getBean<ValkeyEventStoreProperties>()
assertNotNull(properties)
// Verify boundary values are accepted
assertEquals(65535, properties.port)
assertEquals(1, properties.maxBatchSize)
assertEquals(1L, properties.connectionTimeout)
assertEquals(15, properties.database)
logger.debug("[DEBUG_LOG] Boundary property values test passed")
}
}
@Test
@DisplayName("Should handle complex Duration configurations correctly")
fun `should handle complex Duration configurations correctly`() {
contextRunner
.withPropertyValues(
"valkey.event-store.claim-idle-timeout=PT30S", // 30 seconds
"valkey.event-store.poll-timeout=PT1.5S" // 1.5 seconds
)
.run { context ->
val properties = context.getBean<ValkeyEventStoreProperties>()
assertNotNull(properties)
// Verify complex Duration parsing
assertEquals(Duration.ofSeconds(30), properties.claimIdleTimeout)
assertEquals(Duration.ofMillis(1500), properties.pollTimeout)
// Verify all beans are still created with complex durations
assertTrue(context.containsBean("eventStore"))
assertTrue(context.containsBean("eventConsumer"))
logger.debug("[DEBUG_LOG] Complex Duration configuration test passed")
}
}
@Test
@DisplayName("Should handle special property combinations")
fun `should handle special property combinations`() {
contextRunner
.withPropertyValues(
"valkey.event-store.host=valkey.example.com", // External host
"valkey.event-store.password=", // Empty password (no auth)
"valkey.event-store.stream-prefix=custom:", // Custom prefix
"valkey.event-store.use-pooling=false", // Disable pooling
"valkey.event-store.create-consumer-group-if-not-exists=false" // Manual group management
)
.run { context ->
val properties = context.getBean<ValkeyEventStoreProperties>()
assertNotNull(properties)
// Verify special configuration combinations
assertEquals("valkey.example.com", properties.host)
assertEquals("", properties.password)
assertEquals("custom:", properties.streamPrefix)
assertFalse(properties.usePooling)
assertFalse(properties.createConsumerGroupIfNotExists)
// Beans should still be created with special combinations
assertTrue(context.containsBean("eventStoreValkeyConnectionFactory"))
assertTrue(context.containsBean("eventStore"))
logger.debug("[DEBUG_LOG] Special property combinations test passed")
}
}
}
@@ -0,0 +1,356 @@
package at.mocode.infrastructure.eventstore.valkey
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.model.AggregateId
import at.mocode.core.domain.model.EventType
import at.mocode.core.domain.model.EventVersion
import at.mocode.infrastructure.eventstore.api.ConcurrencyException
import at.mocode.infrastructure.eventstore.api.EventSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.*
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 kotlin.time.Clock
import kotlin.uuid.Uuid
/**
* Simplified error handling tests for ValkeyEventStore using Testcontainers.
* Tests real scenarios without complex mocking.
*/
@Testcontainers
class ValkeyEventStoreErrorHandlingTest {
companion object {
@Container
val valkeyContainer: GenericContainer<*> = GenericContainer(DockerImageName.parse("valkey/valkey:9-alpine"))
.withExposedPorts(6379)
}
private lateinit var valkeyTemplate: StringRedisTemplate
private lateinit var serializer: EventSerializer
private lateinit var properties: ValkeyEventStoreProperties
private lateinit var eventStore: ValkeyEventStore
@BeforeEach
fun setUp() {
val valkeyPort = valkeyContainer.getMappedPort(6379)
val valkeyHost = valkeyContainer.host
val valkeyConfig = RedisStandaloneConfiguration(valkeyHost, valkeyPort)
val connectionFactory = LettuceConnectionFactory(valkeyConfig)
connectionFactory.afterPropertiesSet()
valkeyTemplate = StringRedisTemplate(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(TestErrorEvent::class.java, "TestErrorEvent")
registerEventType(LargePayloadEvent::class.java, "LargePayloadEvent")
registerEventType(ComplexErrorEvent::class.java, "ComplexErrorEvent")
}
properties = ValkeyEventStoreProperties().apply {
streamPrefix = "test-stream:"
allEventsStream = "all-events"
}
eventStore = ValkeyEventStore(valkeyTemplate, serializer, properties)
cleanupValkey()
}
@AfterEach
fun tearDown() = cleanupValkey()
private fun cleanupValkey() {
val keys = valkeyTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
valkeyTemplate.delete(keys)
}
}
@Test
fun `should handle large event payloads correctly without memory issues`() {
val aggregateId = Uuid.random()
// Create an event with a very large payload (1MB)
val largeData = "X".repeat(1024 * 1024) // 1MB of data
val largeMetadata = (1..1000).associate { "key$it" to "value$it".repeat(100) } // Additional large metadata
val largeEvent = LargePayloadEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
largeData = largeData,
metadata = largeMetadata
)
// Should handle serialization and storage of large payloads without exception
assertDoesNotThrow {
val version = eventStore.appendToStream(largeEvent, aggregateId, 0)
assertEquals(1L, version)
}
// Should be able to read back the large event correctly
val retrievedEvents = eventStore.readFromStream(aggregateId)
assertEquals(1, retrievedEvents.size)
val retrievedEvent = retrievedEvents[0] as LargePayloadEvent
assertEquals(largeData, retrievedEvent.largeData)
assertEquals(largeMetadata, retrievedEvent.metadata)
assertEquals(EventVersion(1L), retrievedEvent.version)
}
@Test
fun `should handle multiple large events in sequence`() {
val aggregateId = Uuid.random()
val numberOfLargeEvents = 10
val sizePerEvent = 100 * 1024 // 100KB per event
// Create multiple large events
val largeEvents = (1..numberOfLargeEvents).map { i ->
LargePayloadEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(i.toLong()),
largeData = "Event$i-".repeat(sizePerEvent / 10),
metadata = mapOf("eventNumber" to "$i", "size" to "$sizePerEvent")
)
}
// Append all large events
assertDoesNotThrow {
eventStore.appendToStream(largeEvents, aggregateId, 0)
}
// Verify all events can be retrieved
val allEvents = eventStore.readFromStream(aggregateId)
assertEquals(numberOfLargeEvents, allEvents.size)
// Verify each event's integrity
allEvents.forEachIndexed { index, event ->
val largeEvent = event as LargePayloadEvent
assertEquals(EventVersion((index + 1).toLong()), largeEvent.version)
assertTrue(largeEvent.largeData.startsWith("Event${index + 1}-"))
assertEquals("${index + 1}", largeEvent.metadata["eventNumber"])
}
}
@Test
fun `should handle corrupted data gracefully during deserialization by skipping bad events`() {
val aggregateId = Uuid.random()
val streamKey = "test-stream:$aggregateId"
// First, add a valid event
val validEvent = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "valid event"
)
eventStore.appendToStream(validEvent, aggregateId, 0)
// Manually corrupt data in Valkey by adding malformed JSON
val corruptedEventData = mapOf(
"eventType" to "TestErrorEvent",
"eventData" to "{\"corrupted\":\"json\",\"missing\":", // Invalid JSON - missing closing brace
"aggregateId" to aggregateId.toString(),
"version" to "2",
"eventId" to Uuid.random().toString(),
"timestamp" to Clock.System.now().toString()
)
// Directly add corrupted data to the Valkey stream
valkeyTemplate.opsForStream<String, String>().add(streamKey, corruptedEventData)
// Add another valid event after the corrupted one
val validEvent2 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(3L),
data = "another valid event"
)
eventStore.appendToStream(validEvent2, aggregateId, 2)
// Reading should skip corrupted events and return only valid ones
val events = eventStore.readFromStream(aggregateId)
// Should return only the valid events (corrupted event should be skipped)
assertEquals(2, events.size)
val firstEvent = events[0] as TestErrorEvent
assertEquals("valid event", firstEvent.data)
assertEquals(EventVersion(1L), firstEvent.version)
val secondEvent = events[1] as TestErrorEvent
assertEquals("another valid event", secondEvent.data)
assertEquals(EventVersion(3L), secondEvent.version)
}
@Test
fun `should handle unregistered event types gracefully during read operations`() {
val aggregateId = Uuid.random()
val streamKey = "test-stream:$aggregateId"
// Add a valid registered event first
val validEvent = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "valid registered event"
)
eventStore.appendToStream(validEvent, aggregateId, 0)
// Manually add event data for an unregistered event type
val unregisteredEventData = mapOf(
"eventType" to "UnknownEventType", // Not registered in serializer
"eventData" to """{"someField": "someValue", "aggregateId": {"value": "$aggregateId"}, "version": {"value": 2}}""",
"aggregateId" to aggregateId.toString(),
"version" to "2",
"eventId" to Uuid.random().toString(),
"timestamp" to Clock.System.now().toString()
)
valkeyTemplate.opsForStream<String, String>().add(streamKey, unregisteredEventData)
// Add another valid event
val validEvent2 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(3L),
data = "final valid event"
)
eventStore.appendToStream(validEvent2, aggregateId, 2)
// Reading should skip unregistered events and return only valid ones
val events = eventStore.readFromStream(aggregateId)
assertEquals(2, events.size)
assertEquals("valid registered event", (events[0] as TestErrorEvent).data)
assertEquals("final valid event", (events[1] as TestErrorEvent).data)
}
@Test
fun `should handle concurrent version conflicts properly with retry logic`() {
val aggregateId = Uuid.random()
// Create an initial event
val event1 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "initial event"
)
eventStore.appendToStream(event1, aggregateId, 0)
// Try to append two events with the same expected version (simulating concurrent access)
val event2 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(2L),
data = "concurrent event 1"
)
val event3 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(2L), // Same version - will conflict
data = "concurrent event 2"
)
// First append should succeed
val version2 = eventStore.appendToStream(event2, aggregateId, 1)
assertEquals(2L, version2)
// Second append with the same expected version should fail
assertThrows<ConcurrencyException> {
eventStore.appendToStream(event3, aggregateId, 1) // Still expecting version 1
}
// But should succeed with a correct expected version
val correctedEvent3 = event3.copy(version = EventVersion(3L))
val version3 = eventStore.appendToStream(correctedEvent3, aggregateId, 2)
assertEquals(3L, version3)
// Verify all events are in the stream
val allEvents = eventStore.readFromStream(aggregateId)
assertEquals(3, allEvents.size)
assertEquals(listOf(1L, 2L, 3L), allEvents.map { it.version.value })
}
@Test
fun `should handle complex nested object serialization correctly`() {
val aggregateId = Uuid.random()
val complexEvent = ComplexErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
nestedData = ComplexNestedData(
id = 42,
name = "Complex Test",
subObjects = listOf(
SubObject("sub1", 1, mapOf("key1" to "value1")),
SubObject("sub2", 2, mapOf("key2" to "value2", "key3" to "value3"))
),
metadata = mapOf(
"level1" to mapOf("level2" to mapOf("level3" to "deep value")),
"array" to listOf("item1", "item2", "item3")
)
)
)
// Should handle complex serialization without issues
assertDoesNotThrow {
eventStore.appendToStream(complexEvent, aggregateId, 0)
}
// Should deserialize a complex object correctly
val retrievedEvents = eventStore.readFromStream(aggregateId)
assertEquals(1, retrievedEvents.size)
val retrievedEvent = retrievedEvents[0] as ComplexErrorEvent
assertEquals(42, retrievedEvent.nestedData.id)
assertEquals("Complex Test", retrievedEvent.nestedData.name)
assertEquals(2, retrievedEvent.nestedData.subObjects.size)
assertEquals("sub1", retrievedEvent.nestedData.subObjects[0].name)
assertEquals(2, retrievedEvent.nestedData.subObjects[1].value)
assertTrue(retrievedEvent.nestedData.metadata.containsKey("level1"))
}
// Test event classes
@Serializable
data class TestErrorEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val data: String
) : BaseDomainEvent(aggregateId, EventType("TestErrorEvent"), version)
@Serializable
data class LargePayloadEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val largeData: String,
val metadata: Map<String, String>
) : BaseDomainEvent(aggregateId, EventType("LargePayloadEvent"), version)
@Serializable
data class ComplexErrorEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val nestedData: ComplexNestedData
) : BaseDomainEvent(aggregateId, EventType("ComplexErrorEvent"), version)
@Serializable
data class ComplexNestedData(
val id: Int,
val name: String,
val subObjects: List<SubObject>,
val metadata: Map<String, Any>
)
@Serializable
data class SubObject(
val name: String,
val value: Int,
val properties: Map<String, String>
)
}
@@ -0,0 +1,149 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.infrastructure.eventstore.valkey
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.event.DomainEvent
import at.mocode.core.domain.model.*
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
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.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
@Testcontainers
class ValkeyEventStoreIntegrationTest {
companion object {
@Container
val valkeyContainer: GenericContainer<*> = GenericContainer(DockerImageName.parse("valkey/valkey:9-alpine"))
.withExposedPorts(6379)
}
private lateinit var valkeyTemplate: StringRedisTemplate
private lateinit var serializer: EventSerializer
private lateinit var properties: ValkeyEventStoreProperties
private lateinit var eventStore: EventStore
private lateinit var eventConsumer: ValkeyEventConsumer
@BeforeEach
fun setUp() {
val valkeyPort = valkeyContainer.getMappedPort(6379)
val valkeyHost = valkeyContainer.host
val valkeyConfig = RedisStandaloneConfiguration(valkeyHost, valkeyPort)
val connectionFactory = LettuceConnectionFactory(valkeyConfig)
connectionFactory.afterPropertiesSet()
valkeyTemplate = StringRedisTemplate(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(TestCreatedEvent::class.java, "TestCreated")
registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
}
properties = ValkeyEventStoreProperties().apply {
streamPrefix = "test-stream:"
allEventsStream = "all-events"
consumerGroup = "test-group"
consumerName = "test-consumer"
}
eventStore = ValkeyEventStore(valkeyTemplate, serializer, properties)
eventConsumer = ValkeyEventConsumer(valkeyTemplate, serializer, properties)
cleanupValkey()
}
@AfterEach
fun tearDown() {
eventConsumer.shutdown()
cleanupValkey()
}
private fun cleanupValkey() {
val keys = valkeyTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
valkeyTemplate.delete(keys)
}
val allEventsStreamKey = "${properties.streamPrefix}${properties.allEventsStream}"
valkeyTemplate.delete(allEventsStreamKey)
}
@Test
fun `event publishing and consuming with consumer groups should work`() {
val aggregateId = Uuid.random()
val event1 =
TestCreatedEvent(aggregateId = AggregateId(aggregateId), version = EventVersion(1L), name = "Test Entity")
val event2 =
TestUpdatedEvent(aggregateId = AggregateId(aggregateId), version = EventVersion(2L), name = "Updated Test Entity")
val latch = CountDownLatch(2)
val receivedEvents = mutableListOf<DomainEvent>()
eventConsumer.registerEventHandler("TestCreated") { event ->
receivedEvents.add(event)
latch.countDown()
}
eventConsumer.registerEventHandler("TestUpdated") { event ->
receivedEvents.add(event)
latch.countDown()
}
eventConsumer.init()
eventStore.appendToStream(listOf(event1, event2), aggregateId, 0)
// KORREKTUR: Manuelles Auslösen des Polling's, da @Scheduled im Test nicht aktiv ist.
eventConsumer.pollEvents()
// Der Latch sollte jetzt fast sofort herunterzählen. Wir warten zur Sicherheit eine kurze Zeit.
assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for events. Latch count: ${latch.count}")
assertEquals(2, receivedEvents.size)
val receivedEvent1 = receivedEvents.find { it.version == EventVersion(1L) } as TestCreatedEvent
assertEquals(AggregateId(aggregateId), receivedEvent1.aggregateId)
assertEquals("Test Entity", receivedEvent1.name)
val receivedEvent2 = receivedEvents.find { it.version == EventVersion(2L) } as TestUpdatedEvent
assertEquals(AggregateId(aggregateId), receivedEvent2.aggregateId)
assertEquals("Updated Test Entity", receivedEvent2.name)
}
data class TestCreatedEvent(
override val aggregateId: AggregateId,
override val version: EventVersion,
val name: String,
override val eventType: EventType = EventType("TestCreated"),
override val eventId: EventId = EventId(Uuid.random()),
override val timestamp: Instant = Clock.System.now(),
override val correlationId: CorrelationId? = null,
override val causationId: CausationId? = null
) : BaseDomainEvent(aggregateId, eventType, version, eventId, timestamp, correlationId, causationId)
data class TestUpdatedEvent(
override val aggregateId: AggregateId,
override val version: EventVersion,
val name: String,
override val eventType: EventType = EventType("TestUpdated"),
override val eventId: EventId = EventId(Uuid.random()),
override val timestamp: Instant = Clock.System.now(),
override val correlationId: CorrelationId? = null,
override val causationId: CausationId? = null
) : BaseDomainEvent(aggregateId, eventType, version, eventId, timestamp, correlationId, causationId)
}
@@ -0,0 +1,346 @@
package at.mocode.infrastructure.eventstore.valkey
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.model.AggregateId
import at.mocode.core.domain.model.EventType
import at.mocode.core.domain.model.EventVersion
import at.mocode.infrastructure.eventstore.api.EventSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
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 kotlin.uuid.Uuid
/**
* Stream-specific tests for ValkeyEventStore - Core functionality validation.
*/
@Testcontainers
class ValkeyEventStoreStreamTest {
private val logger = LoggerFactory.getLogger(ValkeyEventStoreStreamTest::class.java)
companion object {
@Container
val valkeyContainer: GenericContainer<*> = GenericContainer(DockerImageName.parse("valkey/valkey:9-alpine"))
.withExposedPorts(6379)
}
private lateinit var valkeyTemplate: StringRedisTemplate
private lateinit var serializer: EventSerializer
private lateinit var properties: ValkeyEventStoreProperties
private lateinit var eventStore: ValkeyEventStore
@BeforeEach
fun setUp() {
val valkeyPort = valkeyContainer.getMappedPort(6379)
val valkeyHost = valkeyContainer.host
val valkeyConfig = RedisStandaloneConfiguration(valkeyHost, valkeyPort)
val connectionFactory = LettuceConnectionFactory(valkeyConfig)
connectionFactory.afterPropertiesSet()
valkeyTemplate = StringRedisTemplate(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(StreamTestEvent::class.java, "StreamTestEvent")
registerEventType(OrderTestEvent::class.java, "OrderTestEvent")
}
properties = ValkeyEventStoreProperties().apply {
streamPrefix = "test-stream:"
}
eventStore = ValkeyEventStore(valkeyTemplate, serializer, properties)
cleanupValkey()
}
@AfterEach
fun tearDown() = cleanupValkey()
private fun cleanupValkey() {
val keys = valkeyTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
valkeyTemplate.delete(keys)
}
}
@Test
fun `readFromStream should respect fromVersion and toVersion parameters`() {
val aggregateId = Uuid.random()
val events = (1..10).map { i ->
StreamTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(i.toLong()),
data = "Event $i"
)
}
// Append all events
eventStore.appendToStream(events, aggregateId, 0)
// Test reading from a specific version
val eventsFromVersion3 = eventStore.readFromStream(aggregateId, fromVersion = 3)
assertEquals(8, eventsFromVersion3.size) // Events 3-10
assertEquals(EventVersion(3L), eventsFromVersion3.first().version)
assertEquals(EventVersion(10L), eventsFromVersion3.last().version)
// Test reading with both fromVersion and toVersion
val eventsRange = eventStore.readFromStream(aggregateId, fromVersion = 4, toVersion = 7)
assertEquals(4, eventsRange.size) // Events 4-7
assertEquals(EventVersion(4L), eventsRange.first().version)
assertEquals(EventVersion(7L), eventsRange.last().version)
// Test reading a single event
val singleEvent = eventStore.readFromStream(aggregateId, fromVersion = 5, toVersion = 5)
assertEquals(1, singleEvent.size)
assertEquals(EventVersion(5L), singleEvent.first().version)
// Test reading beyond the available range
val beyondRange = eventStore.readFromStream(aggregateId, fromVersion = 15, toVersion = 20)
assertEquals(0, beyondRange.size)
}
@Test
fun `readAllEvents should handle pagination correctly`() {
val aggregateId1 = Uuid.random()
val aggregateId2 = Uuid.random()
val events1 = (1..5).map { i ->
StreamTestEvent(
aggregateId = AggregateId(aggregateId1),
version = EventVersion(i.toLong()),
data = "Stream1 Event $i"
)
}
val events2 = (1..5).map { i ->
StreamTestEvent(
aggregateId = AggregateId(aggregateId2),
version = EventVersion(i.toLong()),
data = "Stream2 Event $i"
)
}
// Append events to both streams
eventStore.appendToStream(events1, aggregateId1, 0)
eventStore.appendToStream(events2, aggregateId2, 0)
// Test reading all events
val allEvents = eventStore.readAllEvents()
assertEquals(10, allEvents.size)
// Test reading with fromPosition
val eventsFromPosition3 = eventStore.readAllEvents(fromPosition = 3)
assertEquals(7, eventsFromPosition3.size)
// Test reading with maxCount
val limitedEvents = eventStore.readAllEvents(maxCount = 4)
assertEquals(4, limitedEvents.size)
// Test reading with both fromPosition and maxCount
val paginatedEvents = eventStore.readAllEvents(fromPosition = 2, maxCount = 3)
assertEquals(3, paginatedEvents.size)
// Test reading beyond available events
val beyondEvents = eventStore.readAllEvents(fromPosition = 20)
assertEquals(0, beyondEvents.size)
}
@Test
fun `getStreamVersion should return -1 for non-existent streams`() {
val nonExistentStreamId = Uuid.random()
val version = eventStore.getStreamVersion(nonExistentStreamId)
assertEquals(0L, version) // Valkey streams return 0 for non-existent streams, not -1
}
@Test
fun `should handle empty streams correctly`() {
val emptyStreamId = Uuid.random()
// Reading from an empty stream should return an empty list
val emptyEvents = eventStore.readFromStream(emptyStreamId)
assertEquals(0, emptyEvents.size)
// Version of an empty stream should be 0
val emptyVersion = eventStore.getStreamVersion(emptyStreamId)
assertEquals(0L, emptyVersion)
// Reading with version range on an empty stream should return an empty list
val rangeEvents = eventStore.readFromStream(emptyStreamId, fromVersion = 1, toVersion = 5)
assertEquals(0, rangeEvents.size)
}
@Test
fun `should handle concurrent version conflicts properly using optimistic locking`() {
val aggregateId = Uuid.random()
// Add initial event
val initialEvent = OrderTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
threadId = 0,
eventIndex = 0,
data = "Initial event"
)
eventStore.appendToStream(initialEvent, aggregateId, 0)
// Simulate simplified concurrent access with manual version handling
val event1 = OrderTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(2L),
threadId = 1,
eventIndex = 1,
data = "Concurrent event 1"
)
val event2 = OrderTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(3L),
threadId = 2,
eventIndex = 1,
data = "Concurrent event 2"
)
// First append should succeed
val version1 = eventStore.appendToStream(event1, aggregateId, 1)
assertEquals(2L, version1)
// The second appending should succeed with an updated expected version
val version2 = eventStore.appendToStream(event2, aggregateId, 2)
assertEquals(3L, version2)
// Verify the final stream state
val allEvents = eventStore.readFromStream(aggregateId)
assertEquals(3, allEvents.size)
assertEquals(3L, eventStore.getStreamVersion(aggregateId))
// Verify events are in correct order
val versions = allEvents.map { it.version.value }
assertEquals(listOf(1L, 2L, 3L), versions)
}
@Test
fun `should handle version gaps correctly in stream reading`() {
val aggregateId = Uuid.random()
// Create events with non-sequential versions (simulating gaps)
val event1 = StreamTestEvent(AggregateId(aggregateId), EventVersion(1L), "Event 1")
val event5 =
StreamTestEvent(AggregateId(aggregateId), EventVersion(2L), "Event 5") // Actual version 2, but data says 5
val event10 = StreamTestEvent(AggregateId(aggregateId), EventVersion(3L), "Event 10")
eventStore.appendToStream(event1, aggregateId, 0)
eventStore.appendToStream(event5, aggregateId, 1)
eventStore.appendToStream(event10, aggregateId, 2)
// Reading should work despite data content suggesting gaps
val allEvents = eventStore.readFromStream(aggregateId)
assertEquals(3, allEvents.size)
assertEquals(listOf(1L, 2L, 3L), allEvents.map { it.version.value })
// Range reading should work correctly
val rangeEvents = eventStore.readFromStream(aggregateId, fromVersion = 2, toVersion = 3)
assertEquals(2, rangeEvents.size)
assertEquals(listOf(2L, 3L), rangeEvents.map { it.version.value })
}
@Test
fun `should handle large streams efficiently`() {
val aggregateId = Uuid.random()
val numberOfEvents = 1000
// Create and append a large number of events
val events = (1..numberOfEvents).map { i ->
StreamTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(i.toLong()),
data = "Large stream event $i with some additional data to make it more realistic"
)
}
// Measure appends time
val startAppend = System.currentTimeMillis()
eventStore.appendToStream(events, aggregateId, 0)
val appendTime = System.currentTimeMillis() - startAppend
logger.debug("Appended {} events in {}ms", numberOfEvents, appendTime)
// Verify version
assertEquals(numberOfEvents.toLong(), eventStore.getStreamVersion(aggregateId))
// Measure read time for full stream
val startRead = System.currentTimeMillis()
val allReadEvents = eventStore.readFromStream(aggregateId)
val readTime = System.currentTimeMillis() - startRead
logger.debug("Read {} events in {}ms", numberOfEvents, readTime)
assertEquals(numberOfEvents, allReadEvents.size)
// Measure time for range reading
val startRange = System.currentTimeMillis()
val rangeEvents = eventStore.readFromStream(aggregateId, fromVersion = 500, toVersion = 600)
val rangeTime = System.currentTimeMillis() - startRange
logger.debug("Read 101 events from range in {}ms", rangeTime)
assertEquals(101, rangeEvents.size)
// Verify performance is reasonable (should be under 5 seconds for 1000 events)
assertTrue(appendTime < 5000, "Append time too slow: ${appendTime}ms")
assertTrue(readTime < 5000, "Read time too slow: ${readTime}ms")
}
@Test
fun `subscribeToStream and subscribeToAll should return working subscriptions`() {
val aggregateId = Uuid.random()
var streamEventReceived = false
var allEventReceived = false
// Test stream subscription
val streamSubscription = eventStore.subscribeToStream(aggregateId, 0) { event ->
streamEventReceived = true
}
assertTrue(streamSubscription.isActive())
// Test all-events subscription
val allSubscription = eventStore.subscribeToAll(0) { event ->
allEventReceived = true
}
assertTrue(allSubscription.isActive())
// Test unsubscribing
streamSubscription.unsubscribe()
assertFalse(streamSubscription.isActive())
allSubscription.unsubscribe()
assertFalse(allSubscription.isActive())
// Note: These are basic implementation subscriptions that don't process events
// The focus here is testing that they return proper subscription objects
}
// Test event classes
@Serializable
data class StreamTestEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val data: String
) : BaseDomainEvent(aggregateId, EventType("StreamTestEvent"), version)
@Serializable
data class OrderTestEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val threadId: Int,
val eventIndex: Int,
val data: String
) : BaseDomainEvent(aggregateId, EventType("OrderTestEvent"), version)
}
@@ -1,4 +1,4 @@
package at.mocode.infrastructure.eventstore.redis
package at.mocode.infrastructure.eventstore.valkey
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.model.AggregateId
@@ -23,49 +23,49 @@ import org.testcontainers.utility.DockerImageName
import kotlin.uuid.Uuid
@Testcontainers
class RedisEventStoreTest {
class ValkeyEventStoreTest {
companion object {
@Container
val redisContainer: GenericContainer<*> = GenericContainer(DockerImageName.parse("redis:7-alpine"))
val valkeyContainer: GenericContainer<*> = GenericContainer(DockerImageName.parse("valkey/valkey:9-alpine"))
.withExposedPorts(6379)
}
private lateinit var redisTemplate: StringRedisTemplate
private lateinit var valkeyTemplate: StringRedisTemplate
private lateinit var serializer: EventSerializer
private lateinit var properties: RedisEventStoreProperties
private lateinit var eventStore: RedisEventStore
private lateinit var properties: ValkeyEventStoreProperties
private lateinit var eventStore: ValkeyEventStore
@BeforeEach
fun setUp() {
val redisPort = redisContainer.getMappedPort(6379)
val redisHost = redisContainer.host
val valkeyPort = valkeyContainer.getMappedPort(6379)
val valkeyHost = valkeyContainer.host
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
val connectionFactory = LettuceConnectionFactory(redisConfig)
val valkeyConfig = RedisStandaloneConfiguration(valkeyHost, valkeyPort)
val connectionFactory = LettuceConnectionFactory(valkeyConfig)
connectionFactory.afterPropertiesSet()
redisTemplate = StringRedisTemplate(connectionFactory)
valkeyTemplate = StringRedisTemplate(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(TestCreatedEvent::class.java, "TestCreated")
registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
}
properties = RedisEventStoreProperties().apply {
properties = ValkeyEventStoreProperties().apply {
streamPrefix = "test-stream:"
}
eventStore = RedisEventStore(redisTemplate, serializer, properties)
cleanupRedis()
eventStore = ValkeyEventStore(valkeyTemplate, serializer, properties)
cleanupValkey()
}
@AfterEach
fun tearDown() = cleanupRedis()
fun tearDown() = cleanupValkey()
private fun cleanupRedis() {
val keys = redisTemplate.keys("${properties.streamPrefix}*")
private fun cleanupValkey() {
val keys = valkeyTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
redisTemplate.delete(keys)
valkeyTemplate.delete(keys)
}
}
@@ -0,0 +1,120 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.infrastructure.eventstore.valkey
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.event.DomainEvent
import at.mocode.core.domain.model.AggregateId
import at.mocode.core.domain.model.EventType
import at.mocode.core.domain.model.EventVersion
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
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 kotlin.uuid.Uuid
@Testcontainers
class ValkeyIntegrationTest {
companion object {
@Container
val valkeyContainer: GenericContainer<*> = GenericContainer(DockerImageName.parse("valkey/valkey:9-alpine"))
.withExposedPorts(6379)
}
private lateinit var valkeyTemplate: StringRedisTemplate
private lateinit var serializer: EventSerializer
private lateinit var properties: ValkeyEventStoreProperties
private lateinit var eventStore: EventStore
private lateinit var eventConsumer: ValkeyEventConsumer
@BeforeEach
fun setUp() {
val valkeyPort = valkeyContainer.getMappedPort(6379)
val valkeyHost = valkeyContainer.host
val valkeyConfig = RedisStandaloneConfiguration(valkeyHost, valkeyPort)
val connectionFactory = LettuceConnectionFactory(valkeyConfig)
connectionFactory.afterPropertiesSet()
valkeyTemplate = StringRedisTemplate(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(TestCreatedEvent::class.java, "TestCreated")
registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
}
properties = ValkeyEventStoreProperties().apply {
streamPrefix = "test-stream:"
allEventsStream = "all-events"
consumerGroup = "test-group"
consumerName = "test-consumer"
}
eventStore = ValkeyEventStore(valkeyTemplate, serializer, properties)
eventConsumer = ValkeyEventConsumer(valkeyTemplate, serializer, properties)
cleanupValkey()
eventConsumer.init()
}
@AfterEach
fun tearDown() {
eventConsumer.shutdown()
cleanupValkey()
}
private fun cleanupValkey() {
val allEventsStreamKey = "${properties.streamPrefix}${properties.allEventsStream}"
val keys = valkeyTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
valkeyTemplate.delete(keys)
}
valkeyTemplate.delete(allEventsStreamKey)
}
@Test
fun `event publishing and consuming should be fast and reliable`() {
val aggregateId = Uuid.random()
val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity")
val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity")
val receivedEvents = mutableListOf<DomainEvent>()
eventConsumer.registerEventHandler("TestCreated") { receivedEvents.add(it) }
eventConsumer.registerEventHandler("TestUpdated") { receivedEvents.add(it) }
eventStore.appendToStream(listOf(event1, event2), aggregateId, 0)
eventConsumer.pollEvents()
assertEquals(2, receivedEvents.size)
val receivedEvent1 = receivedEvents.find { it.version == EventVersion(1L) } as TestCreatedEvent
assertEquals(AggregateId(aggregateId), receivedEvent1.aggregateId)
assertEquals("Test Entity", receivedEvent1.name)
val receivedEvent2 = receivedEvents.find { it.version == EventVersion(2L) } as TestUpdatedEvent
assertEquals(AggregateId(aggregateId), receivedEvent2.aggregateId)
assertEquals("Updated Test Entity", receivedEvent2.name)
}
@Serializable
data class TestCreatedEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val name: String
) : BaseDomainEvent(aggregateId, EventType("TestCreated"), version)
@Serializable
data class TestUpdatedEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val name: String
) : BaseDomainEvent(aggregateId, EventType("TestUpdated"), version)
}