diff --git a/.env b/.env index 954813c4..7324389e 100644 --- a/.env +++ b/.env @@ -7,10 +7,10 @@ # --- PROJECT --- PROJECT_NAME=meldestelle -RESTART_POLICY=no # Docker build versions (optional overrides) DOCKER_VERSION=1.0.0-SNAPSHOT +DOCKER_REGISTRY=git.mo-code.at/Mocode-Software DOCKER_BUILD_DATE=2026-02-02T15:00:00Z DOCKER_GRADLE_VERSION=9.3.1 # Check if 25 is intended (Early Access) or if LTS 21 was meant @@ -27,19 +27,27 @@ POSTGRES_PORT=5432:5432 POSTGRES_DB_URL=jdbc:postgresql://postgres:5432/pg-meldestelle-db # --- VALKEY (formerly Redis) --- -VALKEY_IMAGE=valkey/valkey:9.0 -REDIS_PASSWORD=redis-password -REDIS_PORT=6379:6379 -REDIS_SERVER_HOSTNAME=redis -REDIS_SERVER_PORT=6379 -REDIS_SERVER_CONNECT_TIMEOUT=5s +VALKEY_IMAGE=valkey/valkey:9-alpine +VALKEY_PASSWORD=valkey-password +VALKEY_PORT=6379:6379 +VALKEY_SERVER_HOSTNAME=valkey +VALKEY_SERVER_PORT=6379 +VALKEY_SERVER_CONNECT_TIMEOUT=5s +VALKEY_POLICY=allkeys-lru +VALKEY_MAXMEMORY=256mb +SPRING_DATA_VALKEY_HOST=localhost +SPRING_DATA_VALKEY_PORT=6379 +SPRING_DATA_VALKEY_PASSWORD=valkey-password # --- KEYCLOAK --- KEYCLOAK_IMAGE_TAG=26.4 +KC_HEAP_MAX=1024m +KC_COMMAND=start-dev --import-realm KC_ADMIN_USERNAME=kc-admin KC_ADMIN_PASSWORD=kc-password KC_DB=postgres KC_DB_SCHEMA=keycloak +KC_DB_PASSWORD=meldestelle KC_HOSTNAME=localhost KC_PORT=8180:8080 KC_DEBUG_PORT=9000:9000 @@ -50,6 +58,27 @@ KC_ISSUER_URI=http://localhost:8180/realms/meldestelle # Internal JWK Set URI (for service-to-service communication within Docker) KC_JWK_SET_URI=http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs +# --- CONSUL --- +CONSUL_IMAGE=hashicorp/consul:1.22.1 +CONSUL_PORT=8500:8500 +CONSUL_UDP_PORT=8600:8600/udp +CONSUL_HOST=consul +CONSUL_HTTP_PORT=8500 +SCLOUD_CONSUL_HOSTNAME=consul +SCLOUD_CONSUL_PORT=8500 + +# --- Zipkin --- +ZIPKIN_IMAGE=openzipkin/zipkin:3 +ZIPKIN_HEAP=256m +ZIPKIN_PORT=9411:9411 +ZIPKIN_ENDPOINT=http://zipkin:9411/api/v2/spans +ZIPKIN_SAMPLING_PROBABILITY=1.0 + +# --- Mailpit --- +MAILPIT_IMAGE=axllent/mailpit:v1.29 +MAILPIT_WEB_PORT=8025:8025 +MAILPIT_SMTP_PORT=1025:1025 + # --- PGADMIN --- PGADMIN_IMAGE=dpage/pgadmin4:8 PGADMIN_EMAIL=meldestelle@mo-code.at @@ -73,15 +102,6 @@ GF_ADMIN_USER=gf-admin GF_ADMIN_PASSWORD=gf-password GF_PORT=3000:3000 -# --- CONSUL --- -CONSUL_IMAGE=hashicorp/consul:1.22.1 -CONSUL_PORT=8500:8500 -CONSUL_UDP_PORT=8600:8600/udp -CONSUL_HOST=consul -CONSUL_HTTP_PORT=8500 -SCLOUD_CONSUL_HOSTNAME=consul -SCLOUD_CONSUL_PORT=8500 - # --- API-GATEWAY --- GATEWAY_PORT=8081:8081 GATEWAY_DEBUG_PORT=5005:5005 diff --git a/.env.example b/.env.example index b7fc6145..37a0e16b 100644 --- a/.env.example +++ b/.env.example @@ -2,51 +2,122 @@ # Meldestelle – Docker Compose Environment # Single Source of Truth (SSoT) # ========================================== +# WARNING: This file contains secrets (passwords). +# Do NOT commit this file to version control if it contains production secrets. # --- PROJECT --- -# COMPOSE_PROJECT_NAME=meldestelle - -# --- PORT MAPPINGS (host:container) --- -POSTGRES_PORT=5432:5432 -REDIS_PORT=6379:6379 -KC_PORT=8180:8080 -CONSUL_PORT=8500:8500 -PROMETHEUS_PORT=9090:9090 -GF_PORT=3000:3000 -WEB_APP_PORT=4000:80 -PING_SERVICE_PORT=8082:8082 -PING_DEBUG_PORT=5006:5006 -GATEWAY_PORT=8081:8081 -GATEWAY_DEBUG_PORT=5005:5005 -GATEWAY_SERVER_PORT=8081 -DESKTOP_APP_VNC_PORT=5900:5900 -DESKTOP_APP_NOVNC_PORT=6080:6080 - -# Postgres -POSTGRES_USER=meldestelle -POSTGRES_PASSWORD=meldestelle -POSTGRES_DB=meldestelle - -# --- VALKEY (formerly Redis) --- -# Optional password for Valkey/Redis; leave empty to disable authentication in dev -REDIS_PASSWORD= - -# --- KEYCLOAK --- -KC_ADMIN_USER=admin -KC_ADMIN_PASSWORD=admin -KC_HOSTNAME=localhost - -# --- PGADMIN --- -PGADMIN_EMAIL=admin@example.com -PGADMIN_PASSWORD=admin - -# --- GRAFANA --- -GF_ADMIN_USER=admin -GF_ADMIN_PASSWORD=admin +PROJECT_NAME=meldestelle # Docker build versions (optional overrides) +DOCKER_VERSION=1.0.0-SNAPSHOT +DOCKER_BUILD_DATE=2026-02-02T15:00:00Z DOCKER_GRADLE_VERSION=9.3.1 +# Check if 25 is intended (Early Access) or if LTS 21 was meant DOCKER_JAVA_VERSION=25 DOCKER_NODE_VERSION=24.12.0 DOCKER_NGINX_VERSION=1.28.0-alpine + +# Postgres +POSTGRES_IMAGE=postgres:16-alpine +POSTGRES_USER=pg-user +POSTGRES_PASSWORD=pg-password +POSTGRES_DB=pg-meldestelle-db +POSTGRES_PORT=5432:5432 +POSTGRES_DB_URL=jdbc:postgresql://postgres:5432/pg-meldestelle-db + +# --- VALKEY (formerly Redis) --- +VALKEY_IMAGE=valkey/valkey:9-alpine +VALKEY_PASSWORD= +VALKEY_PORT=6379:6379 +VALKEY_SERVER_HOSTNAME=valkey +VALKEY_SERVER_PORT=6379 +VALKEY_SERVER_CONNECT_TIMEOUT=5s +VALKEY_POLICY=allkeys-lru +VALKEY_MAXMEMORY=256mb + +# --- KEYCLOAK --- +KEYCLOAK_IMAGE_TAG=26.4 +KC_HEAP_MAX=1024m +KC_COMMAND=start-dev --import-realm +KC_ADMIN_USERNAME=kc-admin +KC_ADMIN_PASSWORD=kc-password +KC_DB=postgres +KC_DB_SCHEMA=keycloak +KC_DB_PASSWORD=meldestelle +KC_HOSTNAME=localhost +KC_PORT=8180:8080 +KC_DEBUG_PORT=9000:9000 + +# --- KEYCLOAK TOKEN VALIDATION --- +# Public Issuer URI (must match the token issuer from browser/postman) +KC_ISSUER_URI=http://localhost:8180/realms/meldestelle +# Internal JWK Set URI (for service-to-service communication within Docker) +KC_JWK_SET_URI=http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs + +# --- CONSUL --- +CONSUL_IMAGE=hashicorp/consul:1.22.1 +CONSUL_PORT=8500:8500 +CONSUL_UDP_PORT=8600:8600/udp +CONSUL_HOST=consul + +# --- Zipkin --- +ZIPKIN_IMAGE=openzipkin/zipkin:3 +ZIPKIN_HEAP=256m +ZIPKIN_PORT=9411:9411 + +# --- Mailpit --- +MAILPIT_IMAGE=axllent/mailpit:v1.29 +MAILPIT_WEB_PORT=8025:8025 +MAILPIT_SMTP_PORT=1025:1025 + +# --- PGADMIN --- +PGADMIN_IMAGE=dpage/pgadmin4:8 +PGADMIN_EMAIL=meldestelle@mo-code.at +PGADMIN_PASSWORD=pgadmin +PGADMIN_PORT=8888:80 + +# --- POSTGRES-EXPORTER --- +POSTGRES_EXPORTER_IMAGE=prometheuscommunity/postgres-exporter:v0.18.0 + +# --- ALERTMANAGER --- +ALERTMANAGER_IMAGE=prom/alertmanager:v0.29.0 +ALERTMANAGER_PORT=9093:9093 + +# --- PROMETHEUS --- +PROMETHEUS_IMAGE=prom/prometheus:v3.7.3 +PROMETHEUS_PORT=9090:9090 + +# --- GRAFANA --- +GF_IMAGE=grafana/grafana:12.3 +GF_ADMIN_USER=gf-admin +GF_ADMIN_PASSWORD=gf-password +GF_PORT=3000:3000 + +# --- API-GATEWAY --- +GATEWAY_PORT=8081:8081 +GATEWAY_DEBUG_PORT=5005:5005 +GATEWAY_SERVER_PORT=8081 +GATEWAY_SPRING_PROFILES_ACTIVE=docker +GATEWAY_DEBUG=true +GATEWAY_SERVICE_NAME=api-gateway +GATEWAY_CONSUL_HOSTNAME=api-gateway +GATEWAY_CONSUL_PREFER_IP=true + +# --- PING-SERVICE --- +PING_SPRING_PROFILES_ACTIVE=docker +PING_PORT=8082:8082 +PING_DEBUG_PORT=5006:5006 +PING_SERVER_PORT=8082 +PING_DEBUG=true +PING_SERVICE_NAME=ping-service +PING_CONSUL_HOSTNAME=ping-service +PING_CONSUL_PREFER_IP=true + +# --- WEB-APP --- +CADDY_VERSION=2.11-alpine +WEB_APP_PORT=4000:4000 WEB_BUILD_PROFILE=dev + +# --- DESKTOP-APP --- +DESKTOP_APP_VNC_PORT=5901:5901 +DESKTOP_APP_NOVNC_PORT=6080:6080 diff --git a/backend/infrastructure/event-store/event-store-api/src/main/kotlin/at/mocode/infrastructure/eventstore/api/EventSerializer.kt b/backend/infrastructure/event-store/event-store-api/src/main/kotlin/at/mocode/infrastructure/eventstore/api/EventSerializer.kt index 0a4440e0..b0a35e0b 100644 --- a/backend/infrastructure/event-store/event-store-api/src/main/kotlin/at/mocode/infrastructure/eventstore/api/EventSerializer.kt +++ b/backend/infrastructure/event-store/event-store-api/src/main/kotlin/at/mocode/infrastructure/eventstore/api/EventSerializer.kt @@ -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 diff --git a/backend/infrastructure/event-store/redis-event-store/build.gradle.kts b/backend/infrastructure/event-store/redis-event-store/build.gradle.kts deleted file mode 100644 index f2628d10..00000000 --- a/backend/infrastructure/event-store/redis-event-store/build.gradle.kts +++ /dev/null @@ -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) -} diff --git a/backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/EventStoreMetrics.kt b/backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/EventStoreMetrics.kt deleted file mode 100644 index ea196158..00000000 --- a/backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/EventStoreMetrics.kt +++ /dev/null @@ -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() - - // 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}" - } -} diff --git a/backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/JacksonEventSerializer.kt b/backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/JacksonEventSerializer.kt deleted file mode 100644 index 7994d3a5..00000000 --- a/backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/JacksonEventSerializer.kt +++ /dev/null @@ -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>() - private val eventClassToType = ConcurrentHashMap, 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 { - 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): 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 { - return data[EVENT_TYPE_FIELD] ?: throw IllegalArgumentException("Event type is missing") - } - - // KORRIGIERT: Parameterreihenfolge umgedreht - override fun registerEventType(eventClass: Class, eventType: String) { - eventTypeToClass[eventType] = eventClass - eventClassToType[eventClass] = eventType - logger.debug("Registered event type: {} for class: {}", eventType, eventClass.name) - } - - override fun getAggregateId(data: Map): Uuid { - val aggregateIdStr = data[AGGREGATE_ID_FIELD] - ?: throw IllegalArgumentException("Aggregate ID is missing") - return Uuid.parse(aggregateIdStr) - } - - override fun getEventId(data: Map): Uuid { - val eventIdStr = data[EVENT_ID_FIELD] - ?: throw IllegalArgumentException("Event ID is missing") - return Uuid.parse(eventIdStr) - } - - override fun getVersion(data: Map): Long { - val versionStr = data[VERSION_FIELD] - ?: throw IllegalArgumentException("Version is missing") - return versionStr.toLong() - } -} diff --git a/backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStore.kt b/backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStore.kt deleted file mode 100644 index 10951940..00000000 --- a/backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStore.kt +++ /dev/null @@ -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() - private val metrics = EventStoreMetrics() - - override fun appendToStream(events: List, 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, 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> { - @Throws(DataAccessException::class) - override fun execute(operations: org.springframework.data.redis.core.RedisOperations): List { - val streamOps = (operations as StringRedisTemplate).opsForStream() - - 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> { - @Throws(DataAccessException::class) - override fun execute(operations: org.springframework.data.redis.core.RedisOperations): List { - val streamOps = (operations as StringRedisTemplate).opsForStream() - - 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 { - 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().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().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 { - 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().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 -} diff --git a/backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreConfiguration.kt b/backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreConfiguration.kt deleted file mode 100644 index 9f3a86a8..00000000 --- a/backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreConfiguration.kt +++ /dev/null @@ -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) - } -} diff --git a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisCacheAndEventStoreIntegrationTest.kt b/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisCacheAndEventStoreIntegrationTest.kt deleted file mode 100644 index 29fefa2f..00000000 --- a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisCacheAndEventStoreIntegrationTest.kt +++ /dev/null @@ -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, - 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, - 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 -} diff --git a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventConsumerResilienceTest.kt b/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventConsumerResilienceTest.kt deleted file mode 100644 index 9cb2e228..00000000 --- a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventConsumerResilienceTest.kt +++ /dev/null @@ -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() - .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() - - // 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() - - // 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() - 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() - 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() - val secondPhaseEvents = mutableListOf() - - // 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() - 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) -} diff --git a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreConfigurationTest.kt b/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreConfigurationTest.kt deleted file mode 100644 index f5c8fae6..00000000 --- a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreConfigurationTest.kt +++ /dev/null @@ -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") - } - } -} diff --git a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreErrorHandlingTest.kt b/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreErrorHandlingTest.kt deleted file mode 100644 index b021b4a0..00000000 --- a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreErrorHandlingTest.kt +++ /dev/null @@ -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().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().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 { - 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 - ) : 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, - val metadata: Map - ) - - @Serializable - data class SubObject( - val name: String, - val value: Int, - val properties: Map - ) -} diff --git a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreIntegrationTest.kt b/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreIntegrationTest.kt deleted file mode 100644 index 8a0362e0..00000000 --- a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreIntegrationTest.kt +++ /dev/null @@ -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() - - 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) -} diff --git a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreStreamTest.kt b/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreStreamTest.kt deleted file mode 100644 index 59b784d6..00000000 --- a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreStreamTest.kt +++ /dev/null @@ -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) -} diff --git a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisIntegrationTest.kt b/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisIntegrationTest.kt deleted file mode 100644 index 15073033..00000000 --- a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisIntegrationTest.kt +++ /dev/null @@ -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() - 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) -} diff --git a/backend/infrastructure/event-store/valkey-event-store/build.gradle.kts b/backend/infrastructure/event-store/valkey-event-store/build.gradle.kts new file mode 100644 index 00000000..2b4c85ac --- /dev/null +++ b/backend/infrastructure/event-store/valkey-event-store/build.gradle.kts @@ -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) +} diff --git a/backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/EventStoreMetrics.kt b/backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/EventStoreMetrics.kt new file mode 100644 index 00000000..41809fd4 --- /dev/null +++ b/backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/EventStoreMetrics.kt @@ -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() + + // 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}" + } +} diff --git a/backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/JacksonEventSerializer.kt b/backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/JacksonEventSerializer.kt new file mode 100644 index 00000000..ec6ee80b --- /dev/null +++ b/backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/JacksonEventSerializer.kt @@ -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>() + private val eventClassToType = ConcurrentHashMap, 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 { + 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): 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 { + return data[EVENT_TYPE_FIELD] ?: throw IllegalArgumentException("Event type is missing") + } + + // KORRIGIERT: Parameterreihenfolge umgedreht + override fun registerEventType(eventClass: Class, eventType: String) { + eventTypeToClass[eventType] = eventClass + eventClassToType[eventClass] = eventType + logger.debug("Registered event type: {} for class: {}", eventType, eventClass.name) + } + + override fun getAggregateId(data: Map): Uuid { + val aggregateIdStr = data[AGGREGATE_ID_FIELD] + ?: throw IllegalArgumentException("Aggregate ID is missing") + return Uuid.parse(aggregateIdStr) + } + + override fun getEventId(data: Map): Uuid { + val eventIdStr = data[EVENT_ID_FIELD] + ?: throw IllegalArgumentException("Event ID is missing") + return Uuid.parse(eventIdStr) + } + + override fun getVersion(data: Map): Long { + val versionStr = data[VERSION_FIELD] + ?: throw IllegalArgumentException("Version is missing") + return versionStr.toLong() + } +} diff --git a/backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventConsumer.kt b/backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventConsumer.kt similarity index 87% rename from backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventConsumer.kt rename to backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventConsumer.kt index 911b57d9..777dcada 100644 --- a/backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventConsumer.kt +++ b/backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventConsumer.kt @@ -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 Unit>>() private val allEventHandlers = CopyOnWriteArrayList<(DomainEvent) -> Unit>() private var running = false @@ -92,7 +92,7 @@ class RedisEventConsumer( try { val allEventsStreamKey = getAllEventsStreamKey() try { - redisTemplate.opsForStream() + valkeyTemplate.opsForStream() .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() + valkeyTemplate.opsForStream() .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() + valkeyTemplate.opsForStream() .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() + val records = valkeyTemplate.opsForStream() .read( Consumer.from(properties.consumerGroup, properties.consumerName), options, @@ -195,11 +195,11 @@ class RedisEventConsumer( try { val streamKey = getAllEventsStreamKey() - val pendingSummary = redisTemplate.opsForStream() + val pendingSummary = valkeyTemplate.opsForStream() .pending(streamKey, properties.consumerGroup) if (pendingSummary != null && pendingSummary.totalPendingMessages > 0) { - val pendingMessages = redisTemplate.opsForStream() + val pendingMessages = valkeyTemplate.opsForStream() .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() + val records = valkeyTemplate.opsForStream() .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() + valkeyTemplate.opsForStream() .acknowledge(properties.consumerGroup, record) return } @@ -268,7 +268,7 @@ class RedisEventConsumer( } } - redisTemplate.opsForStream() + valkeyTemplate.opsForStream() .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}" diff --git a/backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStore.kt b/backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStore.kt new file mode 100644 index 00000000..6b1fff10 --- /dev/null +++ b/backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStore.kt @@ -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() + private val metrics = EventStoreMetrics() + + override fun appendToStream(events: List, 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, 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> { + @Throws(DataAccessException::class) + override fun execute(operations: org.springframework.data.redis.core.RedisOperations): List { + val streamOps = (operations as StringRedisTemplate).opsForStream() + + 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> { + @Throws(DataAccessException::class) + override fun execute(operations: org.springframework.data.redis.core.RedisOperations): List { + val streamOps = (operations as StringRedisTemplate).opsForStream() + + 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 { + 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().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().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 { + 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().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 +} diff --git a/backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreConfiguration.kt b/backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreConfiguration.kt new file mode 100644 index 00000000..a3dd114c --- /dev/null +++ b/backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreConfiguration.kt @@ -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) + } +} diff --git a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/JacksonEventSerializerTest.kt b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/JacksonEventSerializerTest.kt similarity index 99% rename from backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/JacksonEventSerializerTest.kt rename to backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/JacksonEventSerializerTest.kt index 68b8018d..00017e98 100644 --- a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/JacksonEventSerializerTest.kt +++ b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/JacksonEventSerializerTest.kt @@ -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.* diff --git a/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyCacheAndEventStoreIntegrationTest.kt b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyCacheAndEventStoreIntegrationTest.kt new file mode 100644 index 00000000..da044d0c --- /dev/null +++ b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyCacheAndEventStoreIntegrationTest.kt @@ -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, + 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, + 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 +} diff --git a/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventConsumerResilienceTest.kt b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventConsumerResilienceTest.kt new file mode 100644 index 00000000..cee8a4e7 --- /dev/null +++ b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventConsumerResilienceTest.kt @@ -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() + .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() + + // 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() + + // 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() + 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() + 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() + val secondPhaseEvents = mutableListOf() + + // 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() + 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) +} diff --git a/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreConfigurationTest.kt b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreConfigurationTest.kt new file mode 100644 index 00000000..63c0b02c --- /dev/null +++ b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreConfigurationTest.kt @@ -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() + 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("eventStoreValkeyConnectionFactory")) + assertNotNull(context.getBean("eventStoreValkeyTemplate")) + assertNotNull(context.getBean("eventSerializer")) + assertNotNull(context.getBean("eventStore")) + assertNotNull(context.getBean("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() + 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() + 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("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("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") + 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") + assertNotNull(eventStore) + + // Verify it's the Valkey implementation + assertTrue(eventStore is ValkeyEventStore) + + // Verify dependencies are wired correctly + val valkeyTemplate = context.getBean("eventStoreValkeyTemplate") + val eventSerializer = context.getBean("eventSerializer") + val properties = context.getBean() + + 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("eventConsumer") + assertNotNull(eventConsumer) + + // Verify dependencies are available + val valkeyTemplate = context.getBean("eventStoreValkeyTemplate") + val eventSerializer = context.getBean("eventSerializer") + val properties = context.getBean() + + 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() + 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() + 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") + 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() + 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() + 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() + 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") + } + } +} diff --git a/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreErrorHandlingTest.kt b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreErrorHandlingTest.kt new file mode 100644 index 00000000..d9009290 --- /dev/null +++ b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreErrorHandlingTest.kt @@ -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().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().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 { + 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 + ) : 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, + val metadata: Map + ) + + @Serializable + data class SubObject( + val name: String, + val value: Int, + val properties: Map + ) +} diff --git a/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreIntegrationTest.kt b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreIntegrationTest.kt new file mode 100644 index 00000000..f3bb3dc7 --- /dev/null +++ b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreIntegrationTest.kt @@ -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() + + 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) +} diff --git a/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreStreamTest.kt b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreStreamTest.kt new file mode 100644 index 00000000..c8e85ad9 --- /dev/null +++ b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreStreamTest.kt @@ -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) +} diff --git a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreTest.kt b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreTest.kt similarity index 78% rename from backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreTest.kt rename to backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreTest.kt index 73257ba0..298ef2f4 100644 --- a/backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreTest.kt +++ b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreTest.kt @@ -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) } } diff --git a/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyIntegrationTest.kt b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyIntegrationTest.kt new file mode 100644 index 00000000..e292f850 --- /dev/null +++ b/backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyIntegrationTest.kt @@ -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() + 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) +} diff --git a/backend/infrastructure/gateway/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisReactiveAutoConfiguration.java b/backend/infrastructure/gateway/src/main/java/org/springframework/boot/data/valkey/autoconfigure/DataValkeyReactiveAutoConfiguration.java similarity index 69% rename from backend/infrastructure/gateway/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisReactiveAutoConfiguration.java rename to backend/infrastructure/gateway/src/main/java/org/springframework/boot/data/valkey/autoconfigure/DataValkeyReactiveAutoConfiguration.java index 4cdce0c8..c266fab4 100644 --- a/backend/infrastructure/gateway/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisReactiveAutoConfiguration.java +++ b/backend/infrastructure/gateway/src/main/java/org/springframework/boot/data/valkey/autoconfigure/DataValkeyReactiveAutoConfiguration.java @@ -1,4 +1,4 @@ -package org.springframework.boot.data.redis.autoconfigure; +package org.springframework.boot.data.valkey.autoconfigure; import org.springframework.context.annotation.Configuration; @@ -7,5 +7,5 @@ import org.springframework.context.annotation.Configuration; * to be present at this location, even though Spring Boot 3.5.9 moved it. */ @Configuration -public class DataRedisReactiveAutoConfiguration { +public class DataValkeyReactiveAutoConfiguration { } diff --git a/backend/infrastructure/gateway/src/main/resources/application.yaml b/backend/infrastructure/gateway/src/main/resources/application.yaml index b3577e8c..0883c49d 100644 --- a/backend/infrastructure/gateway/src/main/resources/application.yaml +++ b/backend/infrastructure/gateway/src/main/resources/application.yaml @@ -8,12 +8,12 @@ spring: exclude: - "org.springframework.cloud.client.loadbalancer.LoadBalancerAutoConfiguration" - # --- REDIS (für Rate Limiting) --- + # --- VALKEY (für Rate Limiting) --- data: - redis: - host: ${SPRING_DATA_REDIS_HOST:localhost} - port: ${SPRING_DATA_REDIS_PORT:6379} - password: ${SPRING_DATA_REDIS_PASSWORD:redis-password} + valkey: + host: ${SPRING_DATA_VALKEY_HOST:localhost} + port: ${SPRING_DATA_VALKEY_PORT:6379} + password: ${SPRING_DATA_VALKEY_PASSWORD:valkey-password} # --- CONSUL (Service Discovery) --- cloud: @@ -27,7 +27,7 @@ spring: # Aber für den Anfang reicht es, wenn wir Consul finden. gateway: - httpclient: {} + httpclient: { } # Routen sind in GatewayConfig.kt definiert # --- SECURITY (OAuth2 Resource Server) --- diff --git a/dc-backend.yaml b/dc-backend.yaml index 73712a7d..2ef61478 100644 --- a/dc-backend.yaml +++ b/dc-backend.yaml @@ -43,11 +43,11 @@ services: SPRING_DATASOURCE_USERNAME: "${POSTGRES_USER:-pg-user}" SPRING_DATASOURCE_PASSWORD: "${POSTGRES_PASSWORD:-pg-password}" - # --- REDIS --- - SPRING_DATA_REDIS_HOST: "${REDIS_SERVER_HOSTNAME:-redis}" - SPRING_DATA_REDIS_PORT: "${REDIS_SERVICE_PORT:-6379}" - SPRING_DATA_REDIS_PASSWORD: "${REDIS_PASSWORD:-redis-password}" - SPRING_DATA_REDIS_CONNECT_TIMEOUT: "${REDIS_SERVER_CONNECT_TIMEOUT:-5s}" + # --- VALKEY (formerly Redis) --- + SPRING_DATA_VALKEY_HOST: "${VALKEY_SERVER_HOSTNAME:-valkey}" + SPRING_DATA_VALKEY_PORT: "${VALKEY_SERVER_PORT:-6379}" + SPRING_DATA_VALKEY_PASSWORD: "${VALKEY_PASSWORD:-}" + SPRING_DATA_VALKEY_CONNECT_TIMEOUT: "${VALKEY_SERVER_CONNECT_TIMEOUT:-5s}" # --- LOGGING --- LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_GATEWAY: "DEBUG" diff --git a/dc-infra.yaml b/dc-infra.yaml index 93ddb0dc..d4d9773f 100644 --- a/dc-infra.yaml +++ b/dc-infra.yaml @@ -7,7 +7,7 @@ services: # --- DATABASE: PostgreSQL --- postgres: - image: "${POSTGRES_IMAGE:-postgres:16-alpine}" + image: "${DOCKER_REGISTRY:-git.mo-code.at/Mocode-Software}/postgres:${POSTGRES_IMAGE:-postgres:16-alpine}" container_name: "${PROJECT_NAME:-meldestelle}-postgres" # OPTIMIERUNG: Automatischer Neustart bei System-Reboot restart: unless-stopped @@ -46,7 +46,7 @@ services: # --- CACHE: Valkey (formerly Redis) --- valkey: # Valkey 9.0 (User Request) - image: "${VALKEY_IMAGE:-valkey/valkey:9-alpine}" + image: "${DOCKER_REGISTRY:-git.mo-code.at/Mocode-Software}/valkey:${VALKEY_IMAGE:-valkey/valkey:9-alpine}" container_name: "${PROJECT_NAME:-meldestelle}-valkey" restart: unless-stopped ports: @@ -77,11 +77,53 @@ services: aliases: - "valkey" + # --- IAM: Keycloak (DEBUG MODE) --- + keycloak: + # Wir nutzen jetzt dein optimiertes Image statt des Standard-Images + build: + context: . + dockerfile: config/docker/keycloak/Dockerfile + args: + KEYCLOAK_IMAGE_TAG: "${KEYCLOAK_IMAGE_TAG:-26.4}" + image: "${DOCKER_REGISTRY:-git.mo-code.at/Mocode-Software}/keycloak:${KEYCLOAK_IMAGE_TAG:-26.4}" + container_name: "${PROJECT_NAME:-meldestelle}-keycloak" + restart: unless-stopped # Wichtig für Zora! + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: "${KC_ADMIN_USERNAME:-kc-admin}" + KC_BOOTSTRAP_ADMIN_PASSWORD: "${KC_ADMIN_PASSWORD:-kc-password}" + KC_DB: "${KC_DB:-postgres}" + KC_DB_SCHEMA: "${KC_DB_SCHEMA:-keycloak}" + # SSoT: DB-URL dynamisch halten + KC_DB_URL: "jdbc:postgresql://postgres:5432/${POSTGRES_DB:-meldestelle}" + KC_DB_USERNAME: "${POSTGRES_USER:-meldestelle}" + KC_DB_PASSWORD: "${POSTGRES_PASSWORD:-meldestelle}" + KC_HOSTNAME: "${KC_HOSTNAME:-localhost}" + KC_HTTP_ENABLED: "true" + KC_PROXY_HEADERS: "xforwarded" + KC_HEALTH_ENABLED: "true" + KC_METRICS_ENABLED: "true" + # OPTIMIERUNG: Java Heap Einstellungen + JAVA_OPTS_APPEND: "-Xms${KC_HEAP_MIN:-512m} -Xmx${KC_HEAP_MAX:-1024m}" + ports: + - "${KC_PORT:-8180:8080}" + depends_on: + postgres: + condition: "service_healthy" + volumes: + - "./config/docker/keycloak:/opt/keycloak/data/import:Z" + # DYNAMISCH: start-dev für Dev, start für Zora + command: "${KC_COMMAND:-start-dev --import-realm}" + networks: + meldestelle-network: + aliases: + - "keycloak" + profiles: [ "infra", "all" ] + # --- SERVICE DISCOVERY: Consul --- consul: - image: "${CONSUL_IMAGE:-hashicorp/consul:1.22.1}" + image: "${DOCKER_REGISTRY:-git.mo-code.at/Mocode-Software}/consul:${CONSUL_IMAGE:-hashicorp/consul:1.22.1}" container_name: "${PROJECT_NAME:-meldestelle}-consul" - restart: no + restart: unless-stopped ports: - "${CONSUL_PORT:-8500:8500}" - "${CONSUL_UDP_PORT:-8600:8600/udp}" @@ -89,8 +131,6 @@ services: healthcheck: test: [ "CMD", "curl", "-f", "http://localhost:8500/v1/status/leader" ] interval: "30s" - timeout: "10s" - retries: "3" networks: meldestelle-network: aliases: @@ -99,73 +139,29 @@ services: # --- TRACING: Zipkin --- zipkin: - image: "${ZIPKIN_IMAGE:-openzipkin/zipkin:3}" + image: "${DOCKER_REGISTRY:-git.mo-code.at/Mocode-Software}/zipkin:${ZIPKIN_IMAGE:-openzipkin/zipkin:3}" container_name: "${PROJECT_NAME:-meldestelle}-zipkin" - restart: no + restart: unless-stopped # Geändert für Zora + environment: + # OPTIMIERUNG: Speicherbegrenzung für Zora (Zipkin ist Java) + JAVA_OPTS: "-Xms${ZIPKIN_HEAP:-256m} -Xmx${ZIPKIN_HEAP:-512m}" ports: - "${ZIPKIN_PORT:-9411:9411}" - profiles: [ "infra", "all" ] + profiles: [ "ops", "all" ] # Geändert auf 'ops', um es optionaler zu machen networks: meldestelle-network: - aliases: - - "zipkin" # --- EMAIL TESTING: Mailpit --- mailpit: - image: "axllent/mailpit" + image: "${DOCKER_REGISTRY:-git.mo-code.at/Mocode-Software}/mailpit:${MAILPIT_IMAGE:-axllent/mailpit:v1.29}" container_name: "${PROJECT_NAME:-meldestelle}-mailpit" - restart: no + restart: unless-stopped # Geändert für Zora ports: - - "8025:8025" # Web UI - - "1025:1025" # SMTP Port - environment: - MP_MAX_MESSAGES: 5000 - MP_DATABASE: /data/mailpit.db - MP_SMTP_AUTH_ACCEPT_ANY: 1 - MP_SMTP_AUTH_ALLOW_INSECURE: 1 - volumes: - - "mailpit-data:/data" - profiles: [ "infra", "all" ] + - "${MAILPIT_WEB_PORT:-8025:8025}" # Web UI + - "${MAILPIT_SMTP_PORT:-1025:1025}" # SMTP Port + profiles: [ "dev-tools", "all" ] # Auf 'dev-tools' verschoben networks: meldestelle-network: - aliases: - - "mailpit" - - # --- IAM: Keycloak (DEBUG MODE) --- - keycloak: - image: "quay.io/keycloak/keycloak:26.4" - container_name: "${PROJECT_NAME:-meldestelle}-keycloak" - restart: no - environment: - KC_BOOTSTRAP_ADMIN_USERNAME: "${KC_ADMIN_USERNAME:-kc-admin}" - KC_BOOTSTRAP_ADMIN_PASSWORD: "${KC_ADMIN_PASSWORD:-kc-password}" - KC_DB: "${KC_DB:-postgres}" - KC_DB_SCHEMA: "${KC_DB_SCHEMA:-keycloak}" - KC_DB_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}" - KC_DB_USERNAME: "${POSTGRES_USER:-pg-user}" - KC_DB_PASSWORD: "${POSTGRES_PASSWORD:-pg-password}" - KC_HOSTNAME: "${KC_HOSTNAME:-localhost}" - KC_HTTP_ENABLED: "true" - KC_PROXY_HEADERS: "xforwarded" - KC_HEALTH_ENABLED: "true" - KC_METRICS_ENABLED: "true" - KC_LOG_LEVEL: "INFO" - ports: - - "${KC_PORT:-8180:8080}" - - "${KC_DEBUG_PORT:-9000:9000}" - depends_on: - postgres: - condition: "service_healthy" - volumes: - # Mount für den Import - - "./config/docker/keycloak:/opt/keycloak/data/import:Z" - # Import beim Start aktivieren - command: "start-dev --import-realm" - networks: - meldestelle-network: - aliases: - - "keycloak" - profiles: [ "infra", "all" ] volumes: postgres-data: diff --git a/docs/Konfig-Matrix_Dev-ProZora.md b/docs/Konfig-Matrix_Dev-ProZora.md index c3eb1a22..52ef1216 100644 --- a/docs/Konfig-Matrix_Dev-ProZora.md +++ b/docs/Konfig-Matrix_Dev-ProZora.md @@ -6,14 +6,27 @@ Konfigurations-Matrix ### Dev-Umgebungen vs. Zora (Production) -| Variable | .env / .env.example (Dev Default) | Gitea-Secrets (Zora Production) | Zweck / Beschreibung | -|:----------------------------------|:----------------------------------------------------|:----------------------------------------------|:--------------------------------------------------| -| **POSTGRES_SHARED_BUFFERS** | `256MB` (Default via Compose) | `16GB` | Hauptspeicher für DB-Caching (ca. 25% vom RAM). | -| **POSTGRES_EFFECTIVE_CACHE_SIZE** | `768MB` (Default via Compose) | `48GB` | Schätzwert für den OS-Cache (ca. 75% vom RAM). | -| **POSTGRES_USER** | `meldestelle` | `meldestelle` (oder eigener Secret-User) | Administrator-Nutzer der Datenbank. | -| **POSTGRES_PASSWORD** | `meldestelle` | `[STARKES_PASSWORT]` | Passwort für den DB-Zugriff (SSoT-Geheimnis). | -| **POSTGRES_DB** | `meldestelle` | `meldestelle` | Name der primären Datenbank-Instanz. | -| **POSTGRES_PORT** | `5432:5432` | `5432:5432` | Mapping vom Host zum Container. | -| **PROJECT_NAME** | `meldestelle` | `meldestelle` | Präfix für Container-Namen auf dem Host. | -| **KC_HOSTNAME** | `localhost` | `auth.mo-code.at` | Erreichbarkeit von Keycloak (wichtig für Tokens). | -| **KC_DB_URL** | `jdbc:postgresql://postgres:5432/pg-meldestelle-db` | `jdbc:postgresql://postgres:5432/meldestelle` | JDBC-String (muss zur POSTGRES_DB passen). | +| Variable | .env / .env.example (Dev Default) | Gitea-Secrets (Zora Production) | Zweck / Beschreibung | +|:----------------------------------|:----------------------------------------------------|:----------------------------------------------|:----------------------------------------------------------------------------------| +| **POSTGRES_SHARED_BUFFERS** | `256MB` (Default via Compose) | `16GB` | Hauptspeicher für DB-Caching (ca. 25% vom RAM). | +| **POSTGRES_EFFECTIVE_CACHE_SIZE** | `768MB` (Default via Compose) | `48GB` | Schätzwert für den OS-Cache (ca. 75% vom RAM). | +| **POSTGRES_USER** | `meldestelle` | `meldestelle` (oder eigener Secret-User) | Administrator-Nutzer der Datenbank. | +| **POSTGRES_PASSWORD** | `meldestelle` | `[STARKES_PASSWORT]` | Passwort für den DB-Zugriff (SSoT-Geheimnis). | +| **POSTGRES_DB** | `meldestelle` | `meldestelle` | Name der primären Datenbank-Instanz. | +| **POSTGRES_PORT** | `5432:5432` | `5432:5432` | Mapping vom Host zum Container. | +| **PROJECT_NAME** | `meldestelle` | `meldestelle` | Präfix für Container-Namen auf dem Host. | +| **KC_HOSTNAME** | `localhost` | `auth.mo-code.at` | Erreichbarkeit von Keycloak (wichtig für Tokens). | +| **KC_DB_URL** | `jdbc:postgresql://postgres:5432/pg-meldestelle-db` | `jdbc:postgresql://postgres:5432/meldestelle` | JDBC-String (muss zur POSTGRES_DB passen). | +| **VALKEY_MAXMEMORY** | `256mb` | `4gb` bis `8gb` | Zora hat 64 GB RAM; hier können wir großzügig cachen. | +| **VALKEY_POLICY** | `allkeys-lru` | `allkeys-lru` | Wirft die am längsten nicht genutzten Schlüssel raus, wenn der Speicher voll ist. | +| **VALKEY_PASSWORD** | `leer` oder `dev` | `[STARKES_SECRET]` | SSoT-Geheimnis aus Gitea-Secrets. | +| **VALKEY_PORT** | `6379:6379` | `6379:6379` | Standard-Port-Mapping. | +| **KC_HEAP_MAX** | `1024m` | `4096m` | Mehr Power für Zoras 64 GB RAM. | +| **KC_COMMAND** | `start-dev --import-realm` | `start --optimized` | Nutzt das im Dockerfile vor-gebaute Image. | +| **KC_HOSTNAME** | `localhost` | `auth.mo-code.at` | Wichtig für gültige Tokens im Web-Frontend. | +| **KC_DB_PASSWORD** | `meldestelle` | `[GEHEIM]` | SSoT-Passwort aus den Gitea-Secrets. | +| **KEYCLOAK_IMAGE_TAG** | `26.4` | `26.4` | Versionierung. | +| **ZIPKIN_HEAP** | `256m` | `1024m` | Mehr Puffer für Tracing-Daten auf Zora. | +| **CONSUL_IMAGE** | `hashicorp/consul:1.22.1` | `hashicorp/consul:1.22.1` | Versionierung. | +| **MP_MAX_MESSAGES** | `500` | `5000` | Mailpit Speicherlimit. | + diff --git a/settings.gradle.kts b/settings.gradle.kts index d9a7a43f..d72c70f3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,7 +47,7 @@ include(":backend:infrastructure:cache:valkey-cache") // --- EVENT STORE --- include(":backend:infrastructure:event-store:event-store-api") -include(":backend:infrastructure:event-store:redis-event-store") +include(":backend:infrastructure:event-store:valkey-event-store") // --- GATEWAY --- include(":backend:infrastructure:gateway")