From 7757684b6eb3e63c1b5df9992b9c4dabaa838803 Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Thu, 12 Feb 2026 18:52:03 +0100 Subject: [PATCH] refactor: update docker images to use custom registry and optimize configurations Switched container images in `dc-infra.yaml` to a custom Docker registry for better control and consistency across deployments. Added Keycloak with enhanced configurations and updated several container restart policies, memory allocations, and healthcheck settings for improved performance and compatibility. --- .env | 52 +- .env.example | 149 +++-- .../eventstore/api/EventSerializer.kt | 2 +- .../redis-event-store/build.gradle.kts | 69 --- .../eventstore/redis/EventStoreMetrics.kt | 240 --------- .../redis/JacksonEventSerializer.kt | 99 ---- .../eventstore/redis/RedisEventStore.kt | 313 ----------- .../redis/RedisEventStoreConfiguration.kt | 136 ----- .../RedisCacheAndEventStoreIntegrationTest.kt | 251 --------- .../redis/RedisEventConsumerResilienceTest.kt | 509 ------------------ .../redis/RedisEventStoreConfigurationTest.kt | 385 ------------- .../redis/RedisEventStoreErrorHandlingTest.kt | 356 ------------ .../redis/RedisEventStoreIntegrationTest.kt | 146 ----- .../redis/RedisEventStoreStreamTest.kt | 345 ------------ .../eventstore/redis/RedisIntegrationTest.kt | 119 ---- .../valkey-event-store/build.gradle.kts | 69 +++ .../eventstore/valkey/EventStoreMetrics.kt | 242 +++++++++ .../valkey/JacksonEventSerializer.kt | 99 ++++ .../eventstore/valkey/ValkeyEventConsumer.kt} | 40 +- .../eventstore/valkey/ValkeyEventStore.kt | 328 +++++++++++ .../valkey/ValkeyEventStoreConfiguration.kt | 136 +++++ .../valkey}/JacksonEventSerializerTest.kt | 2 +- ...ValkeyCacheAndEventStoreIntegrationTest.kt | 251 +++++++++ .../ValkeyEventConsumerResilienceTest.kt | 509 ++++++++++++++++++ .../ValkeyEventStoreConfigurationTest.kt | 388 +++++++++++++ .../ValkeyEventStoreErrorHandlingTest.kt | 356 ++++++++++++ .../valkey/ValkeyEventStoreIntegrationTest.kt | 149 +++++ .../valkey/ValkeyEventStoreStreamTest.kt | 346 ++++++++++++ .../valkey/ValkeyEventStoreTest.kt} | 36 +- .../valkey/ValkeyIntegrationTest.kt | 120 +++++ .../DataValkeyReactiveAutoConfiguration.java} | 4 +- .../src/main/resources/application.yaml | 12 +- dc-backend.yaml | 10 +- dc-infra.yaml | 118 ++-- docs/Konfig-Matrix_Dev-ProZora.md | 35 +- settings.gradle.kts | 2 +- 36 files changed, 3274 insertions(+), 3149 deletions(-) delete mode 100644 backend/infrastructure/event-store/redis-event-store/build.gradle.kts delete mode 100644 backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/EventStoreMetrics.kt delete mode 100644 backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/JacksonEventSerializer.kt delete mode 100644 backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStore.kt delete mode 100644 backend/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreConfiguration.kt delete mode 100644 backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisCacheAndEventStoreIntegrationTest.kt delete mode 100644 backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventConsumerResilienceTest.kt delete mode 100644 backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreConfigurationTest.kt delete mode 100644 backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreErrorHandlingTest.kt delete mode 100644 backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreIntegrationTest.kt delete mode 100644 backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreStreamTest.kt delete mode 100644 backend/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisIntegrationTest.kt create mode 100644 backend/infrastructure/event-store/valkey-event-store/build.gradle.kts create mode 100644 backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/EventStoreMetrics.kt create mode 100644 backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/JacksonEventSerializer.kt rename backend/infrastructure/event-store/{redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventConsumer.kt => valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventConsumer.kt} (87%) create mode 100644 backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStore.kt create mode 100644 backend/infrastructure/event-store/valkey-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreConfiguration.kt rename backend/infrastructure/event-store/{redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis => valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey}/JacksonEventSerializerTest.kt (99%) create mode 100644 backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyCacheAndEventStoreIntegrationTest.kt create mode 100644 backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventConsumerResilienceTest.kt create mode 100644 backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreConfigurationTest.kt create mode 100644 backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreErrorHandlingTest.kt create mode 100644 backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreIntegrationTest.kt create mode 100644 backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreStreamTest.kt rename backend/infrastructure/event-store/{redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreTest.kt => valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyEventStoreTest.kt} (78%) create mode 100644 backend/infrastructure/event-store/valkey-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/valkey/ValkeyIntegrationTest.kt rename backend/infrastructure/gateway/src/main/java/org/springframework/boot/data/{redis/autoconfigure/DataRedisReactiveAutoConfiguration.java => valkey/autoconfigure/DataValkeyReactiveAutoConfiguration.java} (69%) 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")