chore(ci): Align GH Workflows with Docker SSoT, new paths; minimal SSoT guard; staticAnalysis (#23)

* chore(MP-21): snapshot pre-refactor state (Epic 1)

* chore(MP-22): scaffold new repo structure, relocate Docker Compose, move frontend/backend modules, update Makefile; add docs mapping and env template

* MP-22 Epic 2: Erfolgreich umgesetzt und verifiziert

* MP-23 Epic 3: Gradle/Build Governance zentralisieren

* MP-23 Epic 3: Gradle/Build Governance zentralisieren

* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt

- ENV Single Source of Truth
  - docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
  - config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])

- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
  - Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
    und Start mit -c config_file=/etc/postgresql/postgresql.conf
  - Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
    und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
  - Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
  - Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT

- Frontend/DI/Network (MP-23 Grundlage)
  - :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
  - Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
  - Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)

- Build/Gradle & Module-Refs
  - settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
  - Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
  - Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht

- Dockerfiles angepasst
  - backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
  - backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service

- Static Analysis / Guards
  - config/detekt/detekt.yml hinzugefügt
  - Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet

- Doku
  - docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
  - docs/adr/README.md angelegt

BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)

Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`

Refs: MP-22 (Epic 2), MP-23 (Epic 3)

* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt

- ENV Single Source of Truth
  - docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
  - config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])

- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
  - Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
    und Start mit -c config_file=/etc/postgresql/postgresql.conf
  - Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
    und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
  - Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
  - Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT

- Frontend/DI/Network (MP-23 Grundlage)
  - :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
  - Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
  - Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)

- Build/Gradle & Module-Refs
  - settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
  - Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
  - Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht

- Dockerfiles angepasst
  - backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
  - backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service

- Static Analysis / Guards
  - config/detekt/detekt.yml hinzugefügt
  - Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet

- Doku
  - docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
  - docs/adr/README.md angelegt

BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)

Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`

Refs: MP-22 (Epic 2), MP-23 (Epic 3)

* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt

- ENV Single Source of Truth
  - docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
  - config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])

- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
  - Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
    und Start mit -c config_file=/etc/postgresql/postgresql.conf
  - Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
    und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
  - Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
  - Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT

- Frontend/DI/Network (MP-23 Grundlage)
  - :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
  - Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
  - Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)

- Build/Gradle & Module-Refs
  - settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
  - Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
  - Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht

- Dockerfiles angepasst
  - backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
  - backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service

- Static Analysis / Guards
  - config/detekt/detekt.yml hinzugefügt
  - Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet

- Doku
  - docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
  - docs/adr/README.md angelegt

BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)

Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`

Refs: MP-22 (Epic 2), MP-23 (Epic 3)

* chore(ci): Workflows an Docker-SSoT & neue Struktur angepasst, minimaler SSoT-Guard

- ssot-guard.yml: Option B (minimal) → `docker compose -f docker/docker-compose.yaml config` als Lint
- integration-tests.yml: `./gradlew staticAnalysis` vor Integrationstests
- docs-kdoc-sync.yml: Dokka-Task Fallback (dokkaGfmAll || dokkaGfm), YouTrack-Sync nur wenn Script vorhanden
- deploy-proxmox.yml: Compose-Pfade auf docker/docker-compose.yaml + `--env-file docker/.env`; Build/Test Schritte vereinheitlicht
- ci-main.yml: SSoT-Skripte per `if: hashFiles(...)` guarded, Compose-Lint Fallback; OpenAPI‑Pfad → backend/gateway; ADR‑Pfade → docs/adr/**; `staticAnalysis` in Build integriert
- youtrack-sync.yml: unverändert (funktional)

Refs: MP-22, MP-23

* chore(ci): Workflows an Docker-SSoT & neue Struktur angepasst, minimaler SSoT-Guard

- ssot-guard.yml: Option B (minimal) → `docker compose -f docker/docker-compose.yaml config` als Lint
- integration-tests.yml: `./gradlew staticAnalysis` vor Integrationstests
- docs-kdoc-sync.yml: Dokka-Task Fallback (dokkaGfmAll || dokkaGfm), YouTrack-Sync nur wenn Script vorhanden
- deploy-proxmox.yml: Compose-Pfade auf docker/docker-compose.yaml + `--env-file docker/.env`; Build/Test Schritte vereinheitlicht
- ci-main.yml: SSoT-Skripte per `if: hashFiles(...)` guarded, Compose-Lint Fallback; OpenAPI‑Pfad → backend/gateway; ADR‑Pfade → docs/adr/**; `staticAnalysis` in Build integriert
- youtrack-sync.yml: unverändert (funktional)

Refs: MP-22, MP-23

* fix(ci): create .env from example before validating compose config

* fix(ci): update ssot-guard filename (.yaml) and sync workflow state

* fixing

* fix(webpack): correct sql.js fallback configuration for webpack 5
This commit is contained in:
StefanMo
2025-12-03 12:03:40 +01:00
committed by GitHub
parent 034892e890
commit 95fe3e0573
365 changed files with 2283 additions and 15142 deletions
@@ -0,0 +1,240 @@
package at.mocode.infrastructure.eventstore.redis
import org.slf4j.LoggerFactory
import java.time.Duration
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.LongAdder
/**
* Umfassende Metriken-Verfolgung für Redis Event-Store-Operationen.
*
* Verfolgt Performance-Metriken, Fehlerquoten und Betriebsstatistiken,
* um Einblicke in die Gesundheit und Performance des Event-Stores zu geben.
*/
class EventStoreMetrics {
private val logger = LoggerFactory.getLogger(EventStoreMetrics::class.java)
// Operation counters
private val appendOperations = LongAdder()
private val appendBatchOperations = LongAdder()
private val readOperations = LongAdder()
private val subscriptionOperations = LongAdder()
// Success/Error tracking
private val successfulOperations = LongAdder()
private val failedOperations = LongAdder()
private val concurrencyExceptions = LongAdder()
// Performance metrics
private val totalOperationTime = LongAdder()
private val maxOperationTime = AtomicLong(0)
private val operationTimestamps = ConcurrentHashMap<String, Instant>()
// Cache metrics
private val cacheHits = LongAdder()
private val cacheMisses = LongAdder()
// Event statistics
private val totalEventsAppended = LongAdder()
private val totalEventsRead = LongAdder()
private val lastMetricsReport = AtomicLong(System.currentTimeMillis())
/**
* Records the start of an operation for timing purposes.
*/
fun startOperation(operationId: String) {
operationTimestamps[operationId] = Instant.now()
}
/**
* Records a successful append operation.
*/
fun recordAppendSuccess(operationId: String, eventCount: Int = 1, isBatch: Boolean = false) {
recordOperationEnd(operationId, true)
appendOperations.increment()
if (isBatch) appendBatchOperations.increment()
totalEventsAppended.add(eventCount.toLong())
logger.debug("[METRICS] Append operation completed successfully. Events: {}, Batch: {}", eventCount, isBatch)
}
/**
* Records a failed append operation.
*/
fun recordAppendFailure(operationId: String, error: Throwable? = null, isConcurrencyException: Boolean = false) {
recordOperationEnd(operationId, false)
if (isConcurrencyException) {
concurrencyExceptions.increment()
}
logger.debug("[METRICS] Append operation failed. Concurrency conflict: {}, Error: {}",
isConcurrencyException, error?.message ?: "Unknown")
}
/**
* Records a successful read operation.
*/
fun recordReadSuccess(operationId: String, eventCount: Int) {
recordOperationEnd(operationId, true)
readOperations.increment()
totalEventsRead.add(eventCount.toLong())
logger.debug("[METRICS] Read operation completed successfully. Events: {}", eventCount)
}
/**
* Records a failed read operation.
*/
fun recordReadFailure(operationId: String, error: Throwable? = null) {
recordOperationEnd(operationId, false)
logger.debug("[METRICS] Read operation failed. Error: {}", error?.message ?: "Unknown")
}
/**
* Records a cache hit.
*/
fun recordCacheHit() {
cacheHits.increment()
}
/**
* Records a cache miss.
*/
fun recordCacheMiss() {
cacheMisses.increment()
}
/**
* Records a subscription operation.
*/
fun recordSubscription() {
subscriptionOperations.increment()
logger.debug("[METRICS] New subscription created")
}
private fun recordOperationEnd(operationId: String, success: Boolean) {
val startTime = operationTimestamps.remove(operationId)
if (startTime != null) {
val duration = Duration.between(startTime, Instant.now())
val durationMs = duration.toMillis()
totalOperationTime.add(durationMs)
maxOperationTime.updateAndGet { current -> maxOf(current, durationMs) }
if (success) {
successfulOperations.increment()
} else {
failedOperations.increment()
}
}
}
/**
* Gets comprehensive metrics summary.
*/
fun getMetrics(): EventStoreMetricsSnapshot {
val totalOps = successfulOperations.sum() + failedOperations.sum()
val successRate = if (totalOps > 0) (successfulOperations.sum().toDouble() / totalOps * 100) else 0.0
val avgOperationTime = if (totalOps > 0) (totalOperationTime.sum().toDouble() / totalOps) else 0.0
val cacheHitRate = run {
val totalCacheOps = cacheHits.sum() + cacheMisses.sum()
if (totalCacheOps > 0) (cacheHits.sum().toDouble() / totalCacheOps * 100) else 0.0
}
return EventStoreMetricsSnapshot(
totalOperations = totalOps,
successfulOperations = successfulOperations.sum(),
failedOperations = failedOperations.sum(),
successRate = successRate,
appendOperations = appendOperations.sum(),
batchAppendOperations = appendBatchOperations.sum(),
readOperations = readOperations.sum(),
subscriptionOperations = subscriptionOperations.sum(),
concurrencyExceptions = concurrencyExceptions.sum(),
totalEventsAppended = totalEventsAppended.sum(),
totalEventsRead = totalEventsRead.sum(),
averageOperationTimeMs = avgOperationTime,
maxOperationTimeMs = maxOperationTime.get(),
cacheHits = cacheHits.sum(),
cacheMisses = cacheMisses.sum(),
cacheHitRate = cacheHitRate
)
}
/**
* Logs performance metrics if enough time has passed since the last report.
*/
fun logPerformanceMetrics() {
val now = System.currentTimeMillis()
val lastReport = lastMetricsReport.get()
// Log metrics every 5 minutes
if (now - lastReport > 300_000) {
if (lastMetricsReport.compareAndSet(lastReport, now)) {
val metrics = getMetrics()
logger.info("[PERFORMANCE_METRICS] {}", metrics.toLogString())
}
}
}
/**
* Resets all metrics. Useful for testing.
*/
internal fun reset() {
appendOperations.reset()
appendBatchOperations.reset()
readOperations.reset()
subscriptionOperations.reset()
successfulOperations.reset()
failedOperations.reset()
concurrencyExceptions.reset()
totalOperationTime.reset()
maxOperationTime.set(0)
operationTimestamps.clear()
cacheHits.reset()
cacheMisses.reset()
totalEventsAppended.reset()
totalEventsRead.reset()
lastMetricsReport.set(System.currentTimeMillis())
}
}
/**
* Immutable snapshot of event store metrics at a point in time.
*/
data class EventStoreMetricsSnapshot(
val totalOperations: Long,
val successfulOperations: Long,
val failedOperations: Long,
val successRate: Double,
val appendOperations: Long,
val batchAppendOperations: Long,
val readOperations: Long,
val subscriptionOperations: Long,
val concurrencyExceptions: Long,
val totalEventsAppended: Long,
val totalEventsRead: Long,
val averageOperationTimeMs: Double,
val maxOperationTimeMs: Long,
val cacheHits: Long,
val cacheMisses: Long,
val cacheHitRate: Double
) {
fun toLogString(): String {
return "EventStore Metrics: " +
"Operations=${totalOperations}, " +
"Success Rate=${String.format("%.1f%%", successRate)}, " +
"Appends=${appendOperations} (${batchAppendOperations} batches), " +
"Reads=${readOperations}, " +
"Subscriptions=${subscriptionOperations}, " +
"Events Appended=${totalEventsAppended}, " +
"Events Read=${totalEventsRead}, " +
"Avg Time=${String.format("%.1fms", averageOperationTimeMs)}, " +
"Max Time=${maxOperationTimeMs}ms, " +
"Cache Hit Rate=${String.format("%.1f%%", cacheHitRate)}, " +
"Concurrency Conflicts=${concurrencyExceptions}"
}
}
@@ -0,0 +1,99 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.DomainEvent
import at.mocode.infrastructure.eventstore.api.EventSerializer
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.kotlinModule
import org.slf4j.LoggerFactory
import java.util.concurrent.ConcurrentHashMap
import kotlin.uuid.Uuid
/**
* Jackson-basierte Implementierung des EventSerializer.
*/
class JacksonEventSerializer : EventSerializer {
private val logger = LoggerFactory.getLogger(JacksonEventSerializer::class.java)
private val objectMapper: ObjectMapper = ObjectMapper().apply {
registerModule(kotlinModule())
registerModule(JavaTimeModule())
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
}
private val eventTypeToClass = ConcurrentHashMap<String, Class<out DomainEvent>>()
private val eventClassToType = ConcurrentHashMap<Class<out DomainEvent>, String>()
companion object {
const val EVENT_TYPE_FIELD = "eventType"
const val EVENT_ID_FIELD = "eventId"
const val AGGREGATE_ID_FIELD = "aggregateId"
const val VERSION_FIELD = "version"
const val TIMESTAMP_FIELD = "timestamp"
const val EVENT_DATA_FIELD = "eventData"
}
override fun serialize(event: DomainEvent): Map<String, String> {
val eventType = getEventType(event)
if (!eventClassToType.containsKey(event.javaClass)) {
registerEventType(event.javaClass, eventType)
}
val eventData = objectMapper.writeValueAsString(event)
return mapOf(
EVENT_TYPE_FIELD to eventType,
EVENT_ID_FIELD to event.eventId.value.toString(),
AGGREGATE_ID_FIELD to event.aggregateId.value.toString(),
VERSION_FIELD to event.version.value.toString(),
TIMESTAMP_FIELD to event.timestamp.toString(),
EVENT_DATA_FIELD to eventData
)
}
override fun deserialize(data: Map<String, String>): DomainEvent {
val eventType = getEventType(data)
val eventClass = eventTypeToClass[eventType]
?: throw IllegalArgumentException("Unknown event type: $eventType")
val eventData = data[EVENT_DATA_FIELD]
?: throw IllegalArgumentException("Event data is missing")
return objectMapper.readValue(eventData, eventClass)
}
override fun getEventType(event: DomainEvent): String {
return eventClassToType[event.javaClass] ?: event.javaClass.simpleName
}
override fun getEventType(data: Map<String, String>): String {
return data[EVENT_TYPE_FIELD] ?: throw IllegalArgumentException("Event type is missing")
}
// KORRIGIERT: Parameterreihenfolge umgedreht
override fun registerEventType(eventClass: Class<out DomainEvent>, eventType: String) {
eventTypeToClass[eventType] = eventClass
eventClassToType[eventClass] = eventType
logger.debug("Registered event type: {} for class: {}", eventType, eventClass.name)
}
override fun getAggregateId(data: Map<String, String>): Uuid {
val aggregateIdStr = data[AGGREGATE_ID_FIELD]
?: throw IllegalArgumentException("Aggregate ID is missing")
return Uuid.parse(aggregateIdStr)
}
override fun getEventId(data: Map<String, String>): Uuid {
val eventIdStr = data[EVENT_ID_FIELD]
?: throw IllegalArgumentException("Event ID is missing")
return Uuid.parse(eventIdStr)
}
override fun getVersion(data: Map<String, String>): Long {
val versionStr = data[VERSION_FIELD]
?: throw IllegalArgumentException("Version is missing")
return versionStr.toLong()
}
}
@@ -0,0 +1,287 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.DomainEvent
import at.mocode.infrastructure.eventstore.api.EventSerializer
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import org.slf4j.LoggerFactory
import org.springframework.data.domain.Range
import org.springframework.data.redis.connection.stream.*
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.scheduling.annotation.Scheduled
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
/**
* Consumer for Redis Streams that processes events using consumer groups.
*/
class RedisEventConsumer(
private val redisTemplate: StringRedisTemplate,
private val serializer: EventSerializer,
private val properties: RedisEventStoreProperties
) {
private val logger = LoggerFactory.getLogger(RedisEventConsumer::class.java)
private val eventTypeHandlers = ConcurrentHashMap<String, CopyOnWriteArrayList<(DomainEvent) -> Unit>>()
private val allEventHandlers = CopyOnWriteArrayList<(DomainEvent) -> Unit>()
private var running = false
/**
* Initializes the consumer.
*/
@PostConstruct
fun init() {
if (properties.createConsumerGroupIfNotExists) {
createConsumerGroupsIfNotExist()
}
}
/**
* Stops the consumer.
*/
@PreDestroy
fun shutdown() {
running = false
}
/**
* Registers a handler for a specific event type.
*
* @param eventType The type of event to handle
* @param handler The handler to call when an event of the specified type is received
*/
fun registerEventHandler(eventType: String, handler: (DomainEvent) -> Unit) {
eventTypeHandlers.computeIfAbsent(eventType) { CopyOnWriteArrayList() }.add(handler)
logger.debug("Registered handler for event type: $eventType")
}
/**
* Registers a handler for all events.
*
* @param handler The handler to call when any event is received
*/
fun registerAllEventsHandler(handler: (DomainEvent) -> Unit) {
allEventHandlers.add(handler)
logger.debug("Registered handler for all events")
}
/**
* Unregisters a handler for a specific event type.
*
* @param eventType The type of event
* @param handler The handler to unregister
*/
fun unregisterEventHandler(eventType: String, handler: (DomainEvent) -> Unit) {
eventTypeHandlers[eventType]?.remove(handler)
logger.debug("Unregistered handler for event type: $eventType")
}
/**
* Unregisters a handler for all events.
*
* @param handler The handler to unregister
*/
fun unregisterAllEventsHandler(handler: (DomainEvent) -> Unit) {
allEventHandlers.remove(handler)
logger.debug("Unregistered handler for all events")
}
/**
* Creates consumer groups for all streams if they don't exist.
*/
private fun createConsumerGroupsIfNotExist() {
try {
val allEventsStreamKey = getAllEventsStreamKey()
try {
redisTemplate.opsForStream<String, String>()
.add(allEventsStreamKey, mapOf("init" to "init"))
logger.debug("Ensured all-events stream has messages: $allEventsStreamKey")
} catch (e: Exception) {
logger.debug("All-events stream might already have messages: ${e.message}")
}
createConsumerGroupIfNotExists(allEventsStreamKey)
val streamKeys = redisTemplate.keys("${properties.streamPrefix}*")
for (streamKey in streamKeys) {
if (streamKey != allEventsStreamKey) {
createConsumerGroupIfNotExists(streamKey)
}
}
} catch (e: Exception) {
logger.error("Error creating consumer groups: ${e.message}", e)
}
}
/**
* Creates a consumer group for a stream if it doesn't exist.
*
* @param streamKey The key of the stream
*/
private fun createConsumerGroupIfNotExists(streamKey: String) {
try {
try {
redisTemplate.opsForStream<String, String>()
.add(streamKey, mapOf("init" to "init"))
logger.debug("Ensured stream has messages: $streamKey")
} catch (e: Exception) {
logger.debug("Stream $streamKey might already have messages: ${e.message}")
}
try {
redisTemplate.opsForStream<String, String>()
.createGroup(streamKey, ReadOffset.latest(), properties.consumerGroup)
logger.debug("Created consumer group ${properties.consumerGroup} for stream: $streamKey")
} catch (e: Exception) {
logger.debug("Could not create consumer group ${properties.consumerGroup} for stream: $streamKey: ${e.message}")
}
} catch (e: Exception) {
logger.error("Error creating consumer group for stream $streamKey: ${e.message}", e)
}
}
/**
* Periodic polls for new events from all streams.
*/
@Scheduled(fixedDelayString = $$"${redis.event-store.poll-interval:100}")
fun pollEvents() {
if (!running) {
running = true
}
try {
pollStream(getAllEventsStreamKey())
claimPendingMessages()
} catch (e: Exception) {
logger.error("Error polling events: ${e.message}", e)
}
}
/**
* Polls a stream for new events.
*
* @param streamKey The key of the stream to poll
*/
private fun pollStream(streamKey: String) {
try {
val options = StreamReadOptions.empty()
.count(properties.maxBatchSize.toLong())
.block(properties.pollTimeout)
val records = redisTemplate.opsForStream<String, String>()
.read(
Consumer.from(properties.consumerGroup, properties.consumerName),
options,
StreamOffset.create(streamKey, ReadOffset.lastConsumed())
)
if (records != null) {
for (record in records) {
processRecord(record)
}
}
} catch (e: Exception) {
val message = e.message
if (message == null || !message.contains("NOGROUP")) {
logger.error("Error polling stream $streamKey: ${e.message}", e)
}
}
}
/**
* Claims pending messages that have been idle for too long.
*/
private fun claimPendingMessages() {
try {
val streamKey = getAllEventsStreamKey()
val pendingSummary = redisTemplate.opsForStream<String, String>()
.pending(streamKey, properties.consumerGroup)
if (pendingSummary != null && pendingSummary.totalPendingMessages > 0) {
val pendingMessages = redisTemplate.opsForStream<String, String>()
.pending(
streamKey,
Consumer.from(properties.consumerGroup, properties.consumerName),
Range.unbounded<String>(),
properties.maxBatchSize.toLong()
)
if (pendingMessages.size() > 0) {
val messageIdsList = pendingMessages.map { it.id }.toList()
if (messageIdsList.isNotEmpty()) {
val messageIds = messageIdsList.toTypedArray()
val records = redisTemplate.opsForStream<String, String>()
.claim(
streamKey,
properties.consumerGroup,
properties.consumerName,
properties.claimIdleTimeout,
*messageIds
)
for (record in records) {
processRecord(record)
}
}
}
}
} catch (e: Exception) {
logger.error("Error claiming pending messages: ${e.message}", e)
}
}
/**
* Processes a record from a stream.
*
* @param record The record to process
*/
private fun processRecord(record: MapRecord<String, String, String>) {
try {
val data = record.value
if (data.size == 1 && data.containsKey("init") && data["init"] == "init") {
logger.debug("Skipping init message")
redisTemplate.opsForStream<String, String>()
.acknowledge(properties.consumerGroup, record)
return
}
val event = serializer.deserialize(data)
val eventType = serializer.getEventType(data)
eventTypeHandlers[eventType]?.forEach { handler ->
try {
handler(event)
} catch (e: Exception) {
logger.error("Error handling event of type $eventType: ${e.message}", e)
}
}
allEventHandlers.forEach { handler ->
try {
handler(event)
} catch (e: Exception) {
logger.error("Error handling event: ${e.message}", e)
}
}
redisTemplate.opsForStream<String, String>()
.acknowledge(properties.consumerGroup, record)
} catch (e: Exception) {
logger.error("Error processing record: ${e.message}", e)
}
}
/**
* Gets the Redis key for the all-events stream.
*
* @return The Redis key for the all-events stream
*/
private fun getAllEventsStreamKey(): String {
return "${properties.streamPrefix}${properties.allEventsStream}"
}
}
@@ -0,0 +1,313 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.DomainEvent
import at.mocode.core.domain.model.EventVersion
import at.mocode.infrastructure.eventstore.api.ConcurrencyException
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore
import at.mocode.infrastructure.eventstore.api.Subscription
import org.slf4j.LoggerFactory
import org.springframework.dao.DataAccessException
import org.springframework.data.domain.Range
import org.springframework.data.redis.core.SessionCallback
import org.springframework.data.redis.core.StringRedisTemplate
import java.util.concurrent.ConcurrentHashMap
import kotlin.uuid.Uuid
class RedisEventStore(
private val redisTemplate: StringRedisTemplate,
private val serializer: EventSerializer,
private val properties: RedisEventStoreProperties
) : EventStore {
private val logger = LoggerFactory.getLogger(RedisEventStore::class.java)
private val streamVersionCache = ConcurrentHashMap<Uuid, Long>()
private val metrics = EventStoreMetrics()
override fun appendToStream(events: List<DomainEvent>, streamId: Uuid, expectedVersion: Long): Long {
val operationId = "batch-append-${System.nanoTime()}"
metrics.startOperation(operationId)
try {
if (events.isEmpty()) {
logger.debug("Empty event list provided for stream {}, returning current version", streamId)
val version = getStreamVersion(streamId)
metrics.recordAppendSuccess(operationId, 0, true)
return version
}
val aggregateId = events.first().aggregateId
require(events.all { it.aggregateId == aggregateId }) {
"All events must belong to the same aggregate. Expected: $aggregateId, but found mixed aggregate IDs"
}
require(streamId == aggregateId.value) {
"Stream ID $streamId must match aggregate ID ${aggregateId.value}"
}
logger.debug("Appending {} events to stream {} with expected version {}", events.size, streamId, expectedVersion)
val currentVersion = validateAndGetCurrentVersion(streamId, expectedVersion)
val newVersion = appendEventsInBatch(events, streamId, currentVersion)
logger.info("Successfully appended {} events to stream {}. New version: {}", events.size, streamId, newVersion)
metrics.recordAppendSuccess(operationId, events.size, true)
metrics.logPerformanceMetrics()
return newVersion
} catch (e: ConcurrencyException) {
metrics.recordAppendFailure(operationId, e, true)
throw e
} catch (e: Exception) {
metrics.recordAppendFailure(operationId, e, false)
throw e
}
}
override fun appendToStream(event: DomainEvent, streamId: Uuid, expectedVersion: Long): Long {
val operationId = "single-append-${System.nanoTime()}"
metrics.startOperation(operationId)
try {
logger.debug("Appending single event to stream {} with expected version {}", streamId, expectedVersion)
val currentVersion = validateAndGetCurrentVersion(streamId, expectedVersion)
val newVersion = appendToStreamInternal(event, streamId, currentVersion)
logger.info("Successfully appended event to stream {}. New version: {}", streamId, newVersion)
metrics.recordAppendSuccess(operationId, 1, false)
metrics.logPerformanceMetrics()
return newVersion
} catch (e: ConcurrencyException) {
metrics.recordAppendFailure(operationId, e, true)
throw e
} catch (e: Exception) {
metrics.recordAppendFailure(operationId, e, false)
throw e
}
}
/**
* Validiert die erwartete Version und gibt die aktuelle Version zurück, behandelt Cache-Invalidierung bei Konflikten.
*/
private fun validateAndGetCurrentVersion(streamId: Uuid, expectedVersion: Long): Long {
var currentVersion = getStreamVersion(streamId)
if (currentVersion != expectedVersion) {
logger.warn("Version conflict detected for stream {}. Expected: {}, current: {}", streamId, expectedVersion, currentVersion)
streamVersionCache.remove(streamId) // Invalidate cache on conflict
val actualVersion = getStreamVersion(streamId) // Re-fetch from Redis
if (actualVersion != expectedVersion) {
throw ConcurrencyException("Concurrency conflict for stream $streamId: expected version $expectedVersion but got $actualVersion")
}
currentVersion = actualVersion
}
return currentVersion
}
/**
* Fügt mehrere Events in einer einzigen Redis-Transaktion hinzu für optimale Performance.
*/
private fun appendEventsInBatch(events: List<DomainEvent>, streamId: Uuid, currentVersion: Long): Long {
val streamKey = getStreamKey(streamId)
val allEventsStreamKey = getAllEventsStreamKey()
// Validate all events have correct sequential versions
events.forEachIndexed { index, event ->
val expectedVersion = currentVersion + index + 1
require(event.version.value == expectedVersion) {
"Event ${index} version ${event.version.value} does not match expected version $expectedVersion for stream $streamId"
}
}
logger.debug("Writing {} events to stream {} and all-events stream in single transaction", events.size, streamId)
try {
redisTemplate.execute(object : SessionCallback<List<Any>> {
@Throws(DataAccessException::class)
override fun <K : Any?, V : Any?> execute(operations: org.springframework.data.redis.core.RedisOperations<K, V>): List<Any> {
val streamOps = (operations as StringRedisTemplate).opsForStream<String, String>()
operations.multi()
// Add all events to both streams in a single transaction
events.forEach { event ->
val eventData = serializer.serialize(event)
streamOps.add(streamKey, eventData)
streamOps.add(allEventsStreamKey, eventData)
}
return operations.exec()
}
})
val newVersion = currentVersion + events.size
streamVersionCache[streamId] = newVersion
logger.debug("Successfully wrote {} events to Redis streams in batch, updated cache version to {}", events.size, newVersion)
return newVersion
} catch (e: Exception) {
logger.error("Failed to append {} events in batch for stream {}: {}", events.size, streamId, e.message, e)
streamVersionCache.remove(streamId)
throw e
}
}
private fun appendToStreamInternal(event: DomainEvent, streamId: Uuid, currentVersion: Long): Long {
val newVersion = currentVersion + 1
require(event.version.value == newVersion) {
"Event version ${event.version.value} does not match expected new version $newVersion for stream $streamId"
}
val streamKey = getStreamKey(streamId)
val allEventsStreamKey = getAllEventsStreamKey()
val eventData = serializer.serialize(event)
logger.debug("Writing event {} to stream {} and all-events stream atomically", event.eventId, streamId)
try {
redisTemplate.execute(object : SessionCallback<List<Any>> {
@Throws(DataAccessException::class)
override fun <K : Any?, V : Any?> execute(operations: org.springframework.data.redis.core.RedisOperations<K, V>): List<Any> {
val streamOps = (operations as StringRedisTemplate).opsForStream<String, String>()
operations.multi()
streamOps.add(streamKey, eventData)
streamOps.add(allEventsStreamKey, eventData)
return operations.exec()
}
})
streamVersionCache[streamId] = newVersion
logger.debug("Successfully wrote event {} to Redis streams, updated cache version to {}", event.eventId, newVersion)
return newVersion
} catch (e: Exception) {
logger.error("Failed to append event {} transactionally for stream {}: {}", event.eventId, streamId, e.message, e)
streamVersionCache.remove(streamId)
throw e
}
}
override fun readFromStream(streamId: Uuid, fromVersion: Long, toVersion: Long?): List<DomainEvent> {
val operationId = "read-stream-${System.nanoTime()}"
metrics.startOperation(operationId)
try {
val streamKey = getStreamKey(streamId)
val range = Range.of(Range.Bound.inclusive("-"), Range.Bound.unbounded())
val records = redisTemplate.opsForStream<String, String>().range(streamKey, range)
val events = records?.mapNotNull { record ->
try {
serializer.deserialize(record.value)
} catch (e: Exception) {
logger.error("Error deserializing event from stream {}: {}", streamId, e.message, e)
null
}
} ?: emptyList()
val filteredEvents = events.filter { it.version >= EventVersion(fromVersion) && (toVersion == null || it.version <= EventVersion(toVersion)) }
metrics.recordReadSuccess(operationId, filteredEvents.size)
return filteredEvents
} catch (e: Exception) {
metrics.recordReadFailure(operationId, e)
throw e
}
}
override fun getStreamVersion(streamId: Uuid): Long {
streamVersionCache[streamId]?.let {
metrics.recordCacheHit()
return it
}
metrics.recordCacheMiss()
val streamKey = getStreamKey(streamId)
val size = redisTemplate.opsForStream<String, String>().size(streamKey) ?: 0L
streamVersionCache[streamId] = size
return size
}
private fun getStreamKey(streamId: Uuid): String {
return "${properties.streamPrefix}$streamId"
}
private fun getAllEventsStreamKey(): String {
return "${properties.streamPrefix}${properties.allEventsStream}"
}
override fun readAllEvents(fromPosition: Long, maxCount: Int?): List<DomainEvent> {
val operationId = "read-all-events-${System.nanoTime()}"
metrics.startOperation(operationId)
try {
val allEventsStreamKey = getAllEventsStreamKey()
val range = Range.of(Range.Bound.inclusive("-"), Range.Bound.unbounded())
val records = redisTemplate.opsForStream<String, String>().range(allEventsStreamKey, range)
val events = records?.mapNotNull { record ->
try {
serializer.deserialize(record.value)
} catch (e: Exception) {
logger.error("Error deserializing event from all events stream: {}", e.message, e)
null
}
} ?: emptyList()
val filteredEvents = events.drop(fromPosition.toInt())
val result = if (maxCount != null && maxCount > 0) {
filteredEvents.take(maxCount)
} else {
filteredEvents
}
metrics.recordReadSuccess(operationId, result.size)
return result
} catch (e: Exception) {
metrics.recordReadFailure(operationId, e)
throw e
}
}
override fun subscribeToStream(streamId: Uuid, fromVersion: Long, handler: (DomainEvent) -> Unit): Subscription {
// Basic implementation - for full functionality, integrate with RedisEventConsumer
logger.info("Stream subscription for streamId {} from version {} - basic implementation", streamId, fromVersion)
metrics.recordSubscription()
return BasicSubscription {
logger.info("Unsubscribed from stream {}", streamId)
}
}
override fun subscribeToAll(fromPosition: Long, handler: (DomainEvent) -> Unit): Subscription {
// Basic implementation - for full functionality, integrate with RedisEventConsumer
logger.info("All events subscription from position {} - basic implementation", fromPosition)
metrics.recordSubscription()
return BasicSubscription {
logger.info("Unsubscribed from all events")
}
}
}
/**
* Basic subscription implementation.
*/
private class BasicSubscription(
private val unsubscribeAction: () -> Unit
) : Subscription {
@Volatile
private var active = true
override fun unsubscribe() {
if (active) {
active = false
unsubscribeAction()
}
}
override fun isActive(): Boolean = active
}
@@ -0,0 +1,136 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.connection.RedisPassword
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.StringRedisTemplate
import java.time.Duration
/**
* Redis Event Store 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)
}
}
@@ -0,0 +1,278 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.model.*
import kotlinx.serialization.Serializable
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 kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Tests for JacksonEventSerializer - Critical for data integrity.
*/
class JacksonEventSerializerTest {
private lateinit var serializer: JacksonEventSerializer
@BeforeEach
fun setUp() {
serializer = JacksonEventSerializer()
// Register test event types
serializer.registerEventType(ComplexTestEvent::class.java, "ComplexTestEvent")
serializer.registerEventType(SimpleTestEvent::class.java, "SimpleTestEvent")
}
@Test
fun `should serialize and deserialize simple event correctly`() {
val aggregateId = Uuid.random()
val eventId = Uuid.random()
val timestamp = Clock.System.now()
val event = SimpleTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
name = "Test Event",
eventId = EventId(eventId),
timestamp = timestamp
)
val serialized = serializer.serialize(event)
val deserialized = serializer.deserialize(serialized) as SimpleTestEvent
assertEquals(event.aggregateId, deserialized.aggregateId)
assertEquals(event.version, deserialized.version)
assertEquals(event.name, deserialized.name)
assertEquals(event.eventId, deserialized.eventId)
assertEquals(event.timestamp, deserialized.timestamp)
}
@Test
fun `should handle serialization of complex event types with nested objects`() {
val aggregateId = Uuid.random()
val eventId = Uuid.random()
val timestamp = Clock.System.now()
val correlationId = Uuid.random()
val event = ComplexTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(5L),
complexData = ComplexData(
id = 42,
name = "Complex Name",
values = listOf("value1", "value2", "value3"),
metadata = mapOf("key1" to "value1", "key2" to "value2")
),
eventId = EventId(eventId),
timestamp = timestamp,
correlationId = CorrelationId(correlationId)
)
val serialized = serializer.serialize(event)
val deserialized = serializer.deserialize(serialized) as ComplexTestEvent
assertEquals(event.aggregateId, deserialized.aggregateId)
assertEquals(event.version, deserialized.version)
assertEquals(event.complexData.id, deserialized.complexData.id)
assertEquals(event.complexData.name, deserialized.complexData.name)
assertEquals(event.complexData.values, deserialized.complexData.values)
assertEquals(event.complexData.metadata, deserialized.complexData.metadata)
assertEquals(event.correlationId, deserialized.correlationId)
}
@Test
fun `should throw exception for unregistered event types during deserialization`() {
val aggregateId = Uuid.random()
val unregisteredEvent = UnregisteredTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "unregistered data"
)
// Serialization should work (auto-registration)
val serialized = serializer.serialize(unregisteredEvent)
// Create a new serializer without the event type registered
val newSerializer = JacksonEventSerializer()
// Deserialization should fail
assertThrows<IllegalArgumentException> {
newSerializer.deserialize(serialized)
}
}
@Test
fun `should handle null optional values gracefully`() {
val aggregateId = Uuid.random()
val event = SimpleTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
name = "Test Event",
correlationId = null, // Null correlation ID
causationId = null // Null causation ID
)
val serialized = serializer.serialize(event)
val deserialized = serializer.deserialize(serialized) as SimpleTestEvent
assertEquals(event.aggregateId, deserialized.aggregateId)
assertEquals(event.version, deserialized.version)
assertEquals(event.name, deserialized.name)
assertNull(deserialized.correlationId)
assertNull(deserialized.causationId)
}
@Test
fun `should preserve event metadata correctly in serialization`() {
val aggregateId = Uuid.random()
val eventId = Uuid.random()
val timestamp = Clock.System.now()
val correlationId = Uuid.random()
val causationId = Uuid.random()
val event = SimpleTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(3L),
name = "Metadata Test",
eventId = EventId(eventId),
timestamp = timestamp,
correlationId = CorrelationId(correlationId),
causationId = CausationId(causationId)
)
val serialized = serializer.serialize(event)
// Verify metadata fields are present in serialized form
assertEquals("SimpleTestEvent", serialized[JacksonEventSerializer.EVENT_TYPE_FIELD])
assertEquals(eventId.toString(), serialized[JacksonEventSerializer.EVENT_ID_FIELD])
assertEquals(aggregateId.toString(), serialized[JacksonEventSerializer.AGGREGATE_ID_FIELD])
assertEquals("3", serialized[JacksonEventSerializer.VERSION_FIELD])
assertEquals(timestamp.toString(), serialized[JacksonEventSerializer.TIMESTAMP_FIELD])
assertNotNull(serialized[JacksonEventSerializer.EVENT_DATA_FIELD])
// Verify metadata extraction methods work
assertEquals(aggregateId, serializer.getAggregateId(serialized))
assertEquals(eventId, serializer.getEventId(serialized))
assertEquals(3L, serializer.getVersion(serialized))
assertEquals("SimpleTestEvent", serializer.getEventType(serialized))
}
@Test
fun `should handle missing required metadata fields by throwing exceptions`() {
val incompleteData = mapOf("someField" to "someValue")
assertThrows<IllegalArgumentException> {
serializer.getEventType(incompleteData)
}
assertThrows<IllegalArgumentException> {
serializer.getAggregateId(incompleteData)
}
assertThrows<IllegalArgumentException> {
serializer.getEventId(incompleteData)
}
assertThrows<IllegalArgumentException> {
serializer.getVersion(incompleteData)
}
}
@Test
fun `should auto-register event types during serialization`() {
val newSerializer = JacksonEventSerializer()
val aggregateId = Uuid.random()
val event = SimpleTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
name = "Auto-registration Test"
)
// First, serialization should auto-register the event type
val serialized = newSerializer.serialize(event)
// Later deserialization should work
val deserialized = newSerializer.deserialize(serialized) as SimpleTestEvent
assertEquals(event.name, deserialized.name)
}
@Test
fun `should handle UUID conversion correctly`() {
val testMap = mapOf(
JacksonEventSerializer.AGGREGATE_ID_FIELD to "123e4567-e89b-12d3-a456-426614174000",
JacksonEventSerializer.EVENT_ID_FIELD to "987fcdeb-51a2-43d1-9f12-123456789abc",
JacksonEventSerializer.VERSION_FIELD to "42"
)
val aggregateId = serializer.getAggregateId(testMap)
val eventId = serializer.getEventId(testMap)
val version = serializer.getVersion(testMap)
assertEquals(Uuid.parse("123e4567-e89b-12d3-a456-426614174000"), aggregateId)
assertEquals(Uuid.parse("987fcdeb-51a2-43d1-9f12-123456789abc"), eventId)
assertEquals(42L, version)
}
@Test
fun `should throw exception for invalid UUID formats`() {
val invalidUuidMap = mapOf(
JacksonEventSerializer.AGGREGATE_ID_FIELD to "invalid-uuid-format",
JacksonEventSerializer.EVENT_ID_FIELD to "also-invalid",
JacksonEventSerializer.VERSION_FIELD to "42"
)
assertThrows<IllegalArgumentException> {
serializer.getAggregateId(invalidUuidMap)
}
assertThrows<IllegalArgumentException> {
serializer.getEventId(invalidUuidMap)
}
}
// Test event classes
data class SimpleTestEvent(
override val aggregateId: AggregateId,
override val version: EventVersion,
val name: String,
override val eventType: EventType = EventType("SimpleTestEvent"),
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 ComplexTestEvent(
override val aggregateId: AggregateId,
override val version: EventVersion,
val complexData: ComplexData,
override val eventType: EventType = EventType("ComplexTestEvent"),
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 UnregisteredTestEvent(
override val aggregateId: AggregateId,
override val version: EventVersion,
val data: String,
override val eventType: EventType = EventType("UnregisteredTestEvent"),
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)
@Serializable
data class ComplexData(
val id: Int,
val name: String,
val values: List<String>,
val metadata: Map<String, String>
)
}
@@ -0,0 +1,251 @@
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.redis.JacksonCacheSerializer
import at.mocode.infrastructure.cache.redis.RedisConfiguration
import at.mocode.infrastructure.cache.redis.RedisDistributedCache
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(
RedisConfiguration::class,
RedisEventStoreConfiguration::class
)
class TestConfig {
@Bean
fun distributedCache(
@Qualifier("redisTemplate") redisTemplate: RedisTemplate<String, ByteArray>,
cacheConfiguration: CacheConfiguration
): DistributedCache {
return RedisDistributedCache(
redisTemplate = redisTemplate,
serializer = JacksonCacheSerializer(),
config = cacheConfiguration
)
}
}
@Autowired
private lateinit var cache: DistributedCache
@Autowired
private lateinit var eventStore: EventStore
// Verify separate ConnectionFactories
@Autowired
@Qualifier("redisConnectionFactory")
private lateinit var cacheConnectionFactory: RedisConnectionFactory
@Autowired
@Qualifier("eventStoreRedisConnectionFactory")
private lateinit var eventStoreConnectionFactory: RedisConnectionFactory
@Test
fun `test both modules can be used simultaneously without conflicts`(): Unit = runBlocking {
println("[DEBUG_LOG] Testing simultaneous usage of cache and event store")
// Test Cache Operations
val cacheKey = "test-user-${Uuid.random()}"
val cacheData = TestUser("John Doe", 30)
println("[DEBUG_LOG] Cache: Storing data with key=$cacheKey")
cache.set(cacheKey, cacheData, ttl = 5.minutes)
val retrievedCacheData = cache.get(cacheKey, TestUser::class.java)
println("[DEBUG_LOG] Cache: Retrieved data=$retrievedCacheData")
assertNotNull(retrievedCacheData)
assertEquals(cacheData.name, retrievedCacheData!!.name)
assertEquals(cacheData.age, retrievedCacheData.age)
// Test Event Store Operations
val aggregateId = Uuid.random()
val event = TestEvent(
aggregateId = AggregateId(aggregateId),
eventType = EventType("UserCreated"),
data = mapOf("userId" to aggregateId.toString(), "name" to "Jane Doe")
)
println("[DEBUG_LOG] EventStore: Appending event for aggregateId=$aggregateId")
eventStore.appendToStream(event, aggregateId, 0L)
val loadedEvents = eventStore.readFromStream(aggregateId)
println("[DEBUG_LOG] EventStore: Loaded ${loadedEvents.size} events")
assertEquals(1, loadedEvents.size)
assertEquals(event.eventType, (loadedEvents[0] as TestEvent).eventType)
// Verify Cache and Event Store are independent
println("[DEBUG_LOG] Verifying cache and event store are independent")
// Cache should still work after event operations
val cacheStillWorks = cache.get(cacheKey, TestUser::class.java)
assertNotNull(cacheStillWorks)
println("[DEBUG_LOG] Cache still works: key=$cacheKey exists")
// Event store should still work after cache operations
val eventsStillWork = eventStore.readFromStream(aggregateId)
assertEquals(1, eventsStillWork.size)
println("[DEBUG_LOG] Event store still works: aggregateId=$aggregateId has ${eventsStillWork.size} events")
println("[DEBUG_LOG] Test completed successfully - Both modules work independently")
}
@Test
fun `test separate connection factories are used`() {
println("[DEBUG_LOG] Testing separate connection factories")
assertNotNull(cacheConnectionFactory)
assertNotNull(eventStoreConnectionFactory)
// The connection factories should be different instances
println("[DEBUG_LOG] Cache ConnectionFactory: ${cacheConnectionFactory.javaClass.simpleName}")
println("[DEBUG_LOG] EventStore ConnectionFactory: ${eventStoreConnectionFactory.javaClass.simpleName}")
// Both should be functional
val cacheConnection = cacheConnectionFactory.connection
val eventStoreConnection = eventStoreConnectionFactory.connection
assertNotNull(cacheConnection)
assertNotNull(eventStoreConnection)
// Different databases
println("[DEBUG_LOG] Cache uses database: ${cacheConnection.nativeConnection}")
println("[DEBUG_LOG] EventStore uses database: ${eventStoreConnection.nativeConnection}")
cacheConnection.close()
eventStoreConnection.close()
println("[DEBUG_LOG] Both connection factories are functional and independent")
}
@Test
fun `test data isolation between cache and event store`(): Unit = runBlocking {
println("[DEBUG_LOG] Testing data isolation between cache and event store")
val sharedKey = "shared-key-${Uuid.random()}"
// Store data in cache
cache.set(sharedKey, TestUser("Cache User", 25), ttl = 5.minutes)
println("[DEBUG_LOG] Stored data in cache with key=$sharedKey")
// Store event with same UUID in event store
val aggregateId = Uuid.random()
val event = TestEvent(
aggregateId = AggregateId(aggregateId),
eventType = EventType("TestEvent"),
data = mapOf("key" to sharedKey)
)
eventStore.appendToStream(event, aggregateId, 0L)
println("[DEBUG_LOG] Stored event in event store with aggregateId=$aggregateId")
// Both should be retrievable independently
val cachedUser = cache.get(sharedKey, TestUser::class.java)
val storedEvents = eventStore.readFromStream(aggregateId)
assertNotNull(cachedUser)
assertEquals(1, storedEvents.size)
println("[DEBUG_LOG] Data isolation verified:")
println("[DEBUG_LOG] - Cache retrieved: ${cachedUser?.name}")
println("[DEBUG_LOG] - Event store retrieved: ${storedEvents.size} events")
println("[DEBUG_LOG] Cache and Event Store use separate databases - no conflicts!")
}
// Test data classes
data class TestUser(
val name: String,
val age: Int
)
data class TestEvent(
override val aggregateId: AggregateId,
override val eventType: EventType,
val data: Map<String, String>,
override val eventId: EventId = EventId(Uuid.random()),
override val timestamp: kotlin.time.Instant = kotlin.time.Clock.System.now(),
override val version: EventVersion = EventVersion(1),
override val correlationId: CorrelationId? = null,
override val causationId: CausationId? = null
) : DomainEvent
}
@@ -0,0 +1,509 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.event.DomainEvent
import at.mocode.core.domain.model.AggregateId
import at.mocode.core.domain.model.EventType
import at.mocode.core.domain.model.EventVersion
import at.mocode.infrastructure.eventstore.api.EventSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.StringRedisTemplate
import org.testcontainers.containers.GenericContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName
import java.util.concurrent.*
import java.util.concurrent.atomic.AtomicInteger
import kotlin.uuid.Uuid
/**
* Consumer Resilience Tests - Important for Event-Processing reliability.
*/
@Testcontainers
class RedisEventConsumerResilienceTest {
private val logger = LoggerFactory.getLogger(RedisEventConsumerResilienceTest::class.java)
companion object {
@Container
val redisContainer: GenericContainer<*> = GenericContainer(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379)
}
private lateinit var redisTemplate: StringRedisTemplate
private lateinit var serializer: EventSerializer
private lateinit var properties: RedisEventStoreProperties
private lateinit var eventStore: RedisEventStore
private lateinit var consumer1: RedisEventConsumer
private lateinit var consumer2: RedisEventConsumer
@BeforeEach
fun setUp() {
val redisPort = redisContainer.getMappedPort(6379)
val redisHost = redisContainer.host
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
val connectionFactory = LettuceConnectionFactory(redisConfig)
connectionFactory.afterPropertiesSet()
redisTemplate = StringRedisTemplate(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(ResilienceTestEvent::class.java, "ResilienceTestEvent")
registerEventType(SlowTestEvent::class.java, "SlowTestEvent")
registerEventType(FailingTestEvent::class.java, "FailingTestEvent")
}
properties = RedisEventStoreProperties().apply {
streamPrefix = "test-stream:"
allEventsStream = "all-events"
consumerGroup = "resilience-test-group"
consumerName = "resilience-consumer-1"
claimIdleTimeout = java.time.Duration.ofMillis(100) // Short timeout for testing
pollTimeout = java.time.Duration.ofMillis(50)
maxBatchSize = 10
}
eventStore = RedisEventStore(redisTemplate, serializer, properties)
consumer1 = RedisEventConsumer(redisTemplate, serializer, properties)
// Create second consumer with different name for testing multiple consumers
val properties2 = RedisEventStoreProperties().apply {
streamPrefix = properties.streamPrefix
allEventsStream = properties.allEventsStream
consumerGroup = properties.consumerGroup
consumerName = "resilience-consumer-2"
claimIdleTimeout = properties.claimIdleTimeout
pollTimeout = properties.pollTimeout
maxBatchSize = properties.maxBatchSize
}
consumer2 = RedisEventConsumer(redisTemplate, serializer, properties2)
cleanupRedis()
}
@AfterEach
fun tearDown() {
try {
consumer1.shutdown()
consumer2.shutdown()
} catch (_: Exception) {
// Ignore shutdown errors in tests
}
cleanupRedis()
}
private fun cleanupRedis() {
try {
val streamKey = "${properties.streamPrefix}${properties.allEventsStream}"
// First, try to destroy the consumer group multiple times with retry logic
var attempts = 0
while (attempts < 3) {
try {
redisTemplate.opsForStream<String, String>()
.destroyGroup(streamKey, properties.consumerGroup)
logger.debug("Successfully destroyed consumer group: ${properties.consumerGroup}")
break
} catch (e: Exception) {
attempts++
if (e.message?.contains("NOGROUP") == true) {
// Group doesn't exist, which is fine
break
}
if (attempts < 3) {
Thread.sleep(100) // Wait before retry
} else {
logger.debug("Could not destroy consumer group after 3 attempts: ${e.message}")
}
}
}
// Wait for group destruction to complete
Thread.sleep(100)
// Then delete all stream-related keys
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
redisTemplate.delete(keys)
logger.debug("Deleted ${keys.size} Redis keys with prefix: ${properties.streamPrefix}")
}
// Wait for Redis operations to complete
Thread.sleep(200)
// Verify cleanup by checking if keys still exist
val remainingKeys = redisTemplate.keys("${properties.streamPrefix}*")
if (!remainingKeys.isNullOrEmpty()) {
logger.warn("Some keys still exist after cleanup: $remainingKeys")
// Force delete remaining keys
redisTemplate.delete(remainingKeys)
Thread.sleep(100)
}
} catch (e: Exception) {
logger.warn("Error during Redis cleanup: ${e.message}", e)
// Additional cleanup attempt
try {
Thread.sleep(200)
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
redisTemplate.delete(keys)
}
} catch (retryException: Exception) {
logger.warn("Retry cleanup also failed: ${retryException.message}")
}
}
}
@Test
fun `should handle multiple consumers processing events without conflicts`() {
val aggregateId = Uuid.random()
val latch = CountDownLatch(2)
val processedEvents = CopyOnWriteArrayList<DomainEvent>()
// Both consumers will process events
consumer1.registerEventHandler("ResilienceTestEvent") { event ->
processedEvents.add(event)
logger.debug("Consumer1 processed: {}", (event as ResilienceTestEvent).data)
latch.countDown()
}
consumer2.registerEventHandler("ResilienceTestEvent") { event ->
processedEvents.add(event)
logger.debug("Consumer2 processed: {}", (event as ResilienceTestEvent).data)
latch.countDown()
}
// Initialize both consumers
consumer1.init()
consumer2.init()
// Publish test events
val event1 = ResilienceTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "Multi consumer event 1"
)
val event2 = ResilienceTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(2L),
data = "Multi consumer event 2"
)
eventStore.appendToStream(listOf(event1, event2), aggregateId, 0)
// Let both consumers poll multiple times to ensure all events are processed
val executor = Executors.newFixedThreadPool(2)
executor.submit {
repeat(5) {
consumer1.pollEvents()
Thread.sleep(50)
}
}
executor.submit {
repeat(5) {
consumer2.pollEvents()
Thread.sleep(50)
}
}
// Wait for processing with increased timeout
assertTrue(latch.await(10, TimeUnit.SECONDS), "Events were not processed within timeout")
executor.shutdown()
// Verify that events were processed (by either consumer due to consumer groups)
assertTrue(processedEvents.size >= 2, "Expected at least 2 processed events, got ${processedEvents.size}")
println("[DEBUG_LOG] Processed ${processedEvents.size} events total")
}
@Test
fun `should handle consumer group creation and recovery`() {
// Test that a consumer group is created automatically during init()
val aggregateId = Uuid.random()
val latch = CountDownLatch(1)
val receivedEvents = CopyOnWriteArrayList<DomainEvent>()
// Register handler before init
consumer1.registerEventHandler("ResilienceTestEvent") { receivedEvent ->
receivedEvents.add(receivedEvent)
latch.countDown()
}
// Init should create consumer groups automatically
consumer1.init()
// Add an event after initialization
val event = ResilienceTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "Group creation test"
)
eventStore.appendToStream(event, aggregateId, 0)
// Consumer should be able to process events from the automatically created group
consumer1.pollEvents()
assertTrue(latch.await(3, TimeUnit.SECONDS), "Event was not processed")
assertEquals(1, receivedEvents.size)
assertEquals("Group creation test", (receivedEvents[0] as ResilienceTestEvent).data)
}
@Test
fun `should process events exactly once in consumer group`() {
val aggregateId = Uuid.random()
val numberOfEvents = 10
val processedEvents = ConcurrentHashMap<String, AtomicInteger>()
val latch = CountDownLatch(numberOfEvents)
// Register the same handler on both consumers
val handler = { event: DomainEvent ->
val testEvent = event as ResilienceTestEvent
processedEvents.computeIfAbsent(testEvent.data) { AtomicInteger(0) }.incrementAndGet()
logger.debug("Processed: {}", testEvent.data)
latch.countDown()
}
consumer1.registerEventHandler("ResilienceTestEvent", handler)
consumer2.registerEventHandler("ResilienceTestEvent", handler)
// Initialize both consumers
consumer1.init()
consumer2.init()
// Create and append events
val events = (1..numberOfEvents).map { i ->
ResilienceTestEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(i.toLong()),
data = "Exactly-once event $i"
)
}
eventStore.appendToStream(events, aggregateId, 0)
// Start polling from both consumers simultaneously
val executor = Executors.newFixedThreadPool(2)
executor.submit {
repeat(5) {
consumer1.pollEvents()
Thread.sleep(50)
}
}
executor.submit {
repeat(5) {
consumer2.pollEvents()
Thread.sleep(50)
}
}
// Wait for all events to be processed
assertTrue(latch.await(10, TimeUnit.SECONDS), "Not all events were processed in time")
executor.shutdown()
// Verify each event was processed exactly once across both consumers
assertEquals(numberOfEvents, processedEvents.size)
processedEvents.forEach { (eventData, count) ->
assertEquals(1, count.get(), "Event '$eventData' was processed ${count.get()} times instead of exactly once")
}
logger.debug("All {} events processed exactly once", numberOfEvents)
}
@Test
fun `should handle slow event handlers gracefully`() {
val aggregateId = Uuid.random()
val processedEvents = CopyOnWriteArrayList<String>()
val latch = CountDownLatch(3)
// Register a slow handler
consumer1.registerEventHandler("SlowTestEvent") { event ->
val slowEvent = event as SlowTestEvent
processedEvents.add("Started: ${slowEvent.data}")
Thread.sleep(slowEvent.processingTimeMs) // Simulate slow processing
processedEvents.add("Completed: ${slowEvent.data}")
latch.countDown()
}
consumer1.init()
// Create events with different processing times
val events = listOf(
SlowTestEvent(AggregateId(aggregateId), EventVersion(1L), "Fast event", 10),
SlowTestEvent(AggregateId(aggregateId), EventVersion(2L), "Medium event", 100),
SlowTestEvent(AggregateId(aggregateId), EventVersion(3L), "Slow event", 200)
)
eventStore.appendToStream(events, aggregateId, 0)
// Start processing
val startTime = System.currentTimeMillis()
consumer1.pollEvents()
// Wait for processing to complete
assertTrue(latch.await(10, TimeUnit.SECONDS), "Slow events were not processed within timeout")
val totalTime = System.currentTimeMillis() - startTime
// Verify all events were processed
assertEquals(6, processedEvents.size) // 3 started + 3 completed
assertTrue(processedEvents.contains("Started: Fast event"))
assertTrue(processedEvents.contains("Completed: Fast event"))
assertTrue(processedEvents.contains("Started: Medium event"))
assertTrue(processedEvents.contains("Completed: Medium event"))
assertTrue(processedEvents.contains("Started: Slow event"))
assertTrue(processedEvents.contains("Completed: Slow event"))
logger.debug("Processed {} slow events in {}ms", events.size, totalTime)
processedEvents.forEach { logger.debug("Event: {}", it) }
}
@Test
fun `should handle consumer restart correctly`() {
val aggregateId = Uuid.random()
val firstPhaseEvents = mutableListOf<DomainEvent>()
val secondPhaseEvents = mutableListOf<DomainEvent>()
// First processing session
val firstLatch = CountDownLatch(1)
consumer1.registerEventHandler("ResilienceTestEvent") { event ->
firstPhaseEvents.add(event)
firstLatch.countDown()
}
consumer1.init()
// Add and process the first event
val event1 = ResilienceTestEvent(AggregateId(aggregateId), EventVersion(1L), "Before restart")
eventStore.appendToStream(event1, aggregateId, 0)
consumer1.pollEvents()
assertTrue(firstLatch.await(3, TimeUnit.SECONDS), "First event not processed")
// Verify the first phase
assertEquals(1, firstPhaseEvents.size)
assertEquals("Before restart", (firstPhaseEvents[0] as ResilienceTestEvent).data)
// Simulate shutdown and restart - create new consumer to ensure a clean state
consumer1.shutdown()
// Create a fresh consumer instance for restart simulation
val restartedConsumer = RedisEventConsumer(redisTemplate, serializer, properties)
val secondLatch = CountDownLatch(1)
restartedConsumer.registerEventHandler("ResilienceTestEvent") { event ->
secondPhaseEvents.add(event)
secondLatch.countDown()
}
restartedConsumer.init()
// Add and process a second event after restart
val event2 = ResilienceTestEvent(AggregateId(aggregateId), EventVersion(2L), "After restart")
eventStore.appendToStream(event2, aggregateId, 1)
restartedConsumer.pollEvents()
assertTrue(secondLatch.await(3, TimeUnit.SECONDS), "Second event not processed after restart")
// Verify the second phase
assertEquals(1, secondPhaseEvents.size)
assertEquals("After restart", (secondPhaseEvents[0] as ResilienceTestEvent).data)
// Cleanup
restartedConsumer.shutdown()
logger.debug("Successfully handled consumer restart")
logger.debug("First phase events: {}", firstPhaseEvents.map { (it as ResilienceTestEvent).data })
logger.debug("Second phase events: {}", secondPhaseEvents.map { (it as ResilienceTestEvent).data })
}
@Test
fun `should handle event handler exceptions gracefully without stopping processing`() {
// Ensure clean state for this test
cleanupRedis()
val aggregateId = Uuid.random()
val processedEvents = CopyOnWriteArrayList<String>()
val latch = CountDownLatch(3) // Expecting 3 events to be processed (2 success + 1 failure)
// Register a handler that fails on specific events
consumer1.registerEventHandler("FailingTestEvent") { event ->
val failingEvent = event as FailingTestEvent
if (failingEvent.shouldFail) {
processedEvents.add("Failed: ${failingEvent.data}")
latch.countDown()
throw RuntimeException("Simulated handler failure for: ${failingEvent.data}")
} else {
processedEvents.add("Success: ${failingEvent.data}")
latch.countDown()
}
}
consumer1.init()
// Create events - some that will fail, some that will succeed
val events = listOf(
FailingTestEvent(AggregateId(aggregateId), EventVersion(1L), "Event 1", false),
FailingTestEvent(AggregateId(aggregateId), EventVersion(2L), "Event 2", true), // Will fail
FailingTestEvent(AggregateId(aggregateId), EventVersion(3L), "Event 3", false)
)
eventStore.appendToStream(events, aggregateId, 0)
// Poll multiple times to ensure all events are processed
// This is necessary because Redis streams might not deliver all events in a single poll
for (i in 1..10) {
consumer1.pollEvents()
Thread.sleep(100)
if (latch.count == 0L) break
}
// Wait for processing
assertTrue(latch.await(5, TimeUnit.SECONDS), "Events were not processed within timeout")
// Verify that both successful and failed events were attempted
assertEquals(3, processedEvents.size)
assertTrue(processedEvents.contains("Success: Event 1"))
assertTrue(processedEvents.contains("Failed: Event 2"))
assertTrue(processedEvents.contains("Success: Event 3"))
logger.debug("Handler exceptions handled gracefully:")
processedEvents.forEach { logger.debug("Event result: {}", it) }
}
// Test event classes
@Serializable
data class ResilienceTestEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val data: String
) : BaseDomainEvent(aggregateId, EventType("ResilienceTestEvent"), version)
@Serializable
data class SlowTestEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val data: String,
val processingTimeMs: Long
) : BaseDomainEvent(aggregateId, EventType("SlowTestEvent"), version)
@Serializable
data class FailingTestEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val data: String,
val shouldFail: Boolean
) : BaseDomainEvent(aggregateId, EventType("FailingTestEvent"), version)
}
@@ -0,0 +1,385 @@
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")
}
}
}
@@ -0,0 +1,356 @@
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.model.AggregateId
import at.mocode.core.domain.model.EventType
import at.mocode.core.domain.model.EventVersion
import at.mocode.infrastructure.eventstore.api.ConcurrencyException
import at.mocode.infrastructure.eventstore.api.EventSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.StringRedisTemplate
import org.testcontainers.containers.GenericContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName
import kotlin.time.Clock
import kotlin.uuid.Uuid
/**
* Simplified error handling tests for RedisEventStore using Testcontainers.
* Tests real scenarios without complex mocking.
*/
@Testcontainers
class RedisEventStoreErrorHandlingTest {
companion object {
@Container
val redisContainer: GenericContainer<*> = GenericContainer(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379)
}
private lateinit var redisTemplate: StringRedisTemplate
private lateinit var serializer: EventSerializer
private lateinit var properties: RedisEventStoreProperties
private lateinit var eventStore: RedisEventStore
@BeforeEach
fun setUp() {
val redisPort = redisContainer.getMappedPort(6379)
val redisHost = redisContainer.host
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
val connectionFactory = LettuceConnectionFactory(redisConfig)
connectionFactory.afterPropertiesSet()
redisTemplate = StringRedisTemplate(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(TestErrorEvent::class.java, "TestErrorEvent")
registerEventType(LargePayloadEvent::class.java, "LargePayloadEvent")
registerEventType(ComplexErrorEvent::class.java, "ComplexErrorEvent")
}
properties = RedisEventStoreProperties().apply {
streamPrefix = "test-stream:"
allEventsStream = "all-events"
}
eventStore = RedisEventStore(redisTemplate, serializer, properties)
cleanupRedis()
}
@AfterEach
fun tearDown() = cleanupRedis()
private fun cleanupRedis() {
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
redisTemplate.delete(keys)
}
}
@Test
fun `should handle large event payloads correctly without memory issues`() {
val aggregateId = Uuid.random()
// Create an event with a very large payload (1MB)
val largeData = "X".repeat(1024 * 1024) // 1MB of data
val largeMetadata = (1..1000).associate { "key$it" to "value$it".repeat(100) } // Additional large metadata
val largeEvent = LargePayloadEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
largeData = largeData,
metadata = largeMetadata
)
// Should handle serialization and storage of large payloads without exception
assertDoesNotThrow {
val version = eventStore.appendToStream(largeEvent, aggregateId, 0)
assertEquals(1L, version)
}
// Should be able to read back the large event correctly
val retrievedEvents = eventStore.readFromStream(aggregateId)
assertEquals(1, retrievedEvents.size)
val retrievedEvent = retrievedEvents[0] as LargePayloadEvent
assertEquals(largeData, retrievedEvent.largeData)
assertEquals(largeMetadata, retrievedEvent.metadata)
assertEquals(EventVersion(1L), retrievedEvent.version)
}
@Test
fun `should handle multiple large events in sequence`() {
val aggregateId = Uuid.random()
val numberOfLargeEvents = 10
val sizePerEvent = 100 * 1024 // 100KB per event
// Create multiple large events
val largeEvents = (1..numberOfLargeEvents).map { i ->
LargePayloadEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(i.toLong()),
largeData = "Event$i-".repeat(sizePerEvent / 10),
metadata = mapOf("eventNumber" to "$i", "size" to "$sizePerEvent")
)
}
// Append all large events
assertDoesNotThrow {
eventStore.appendToStream(largeEvents, aggregateId, 0)
}
// Verify all events can be retrieved
val allEvents = eventStore.readFromStream(aggregateId)
assertEquals(numberOfLargeEvents, allEvents.size)
// Verify each event's integrity
allEvents.forEachIndexed { index, event ->
val largeEvent = event as LargePayloadEvent
assertEquals(EventVersion((index + 1).toLong()), largeEvent.version)
assertTrue(largeEvent.largeData.startsWith("Event${index + 1}-"))
assertEquals("${index + 1}", largeEvent.metadata["eventNumber"])
}
}
@Test
fun `should handle corrupted data gracefully during deserialization by skipping bad events`() {
val aggregateId = Uuid.random()
val streamKey = "test-stream:$aggregateId"
// First, add a valid event
val validEvent = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "valid event"
)
eventStore.appendToStream(validEvent, aggregateId, 0)
// Manually corrupt data in Redis by adding malformed JSON
val corruptedEventData = mapOf(
"eventType" to "TestErrorEvent",
"eventData" to "{\"corrupted\":\"json\",\"missing\":", // Invalid JSON - missing closing brace
"aggregateId" to aggregateId.toString(),
"version" to "2",
"eventId" to Uuid.random().toString(),
"timestamp" to Clock.System.now().toString()
)
// Directly add corrupted data to the Redis stream
redisTemplate.opsForStream<String, String>().add(streamKey, corruptedEventData)
// Add another valid event after the corrupted one
val validEvent2 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(3L),
data = "another valid event"
)
eventStore.appendToStream(validEvent2, aggregateId, 2)
// Reading should skip corrupted events and return only valid ones
val events = eventStore.readFromStream(aggregateId)
// Should return only the valid events (corrupted event should be skipped)
assertEquals(2, events.size)
val firstEvent = events[0] as TestErrorEvent
assertEquals("valid event", firstEvent.data)
assertEquals(EventVersion(1L), firstEvent.version)
val secondEvent = events[1] as TestErrorEvent
assertEquals("another valid event", secondEvent.data)
assertEquals(EventVersion(3L), secondEvent.version)
}
@Test
fun `should handle unregistered event types gracefully during read operations`() {
val aggregateId = Uuid.random()
val streamKey = "test-stream:$aggregateId"
// Add a valid registered event first
val validEvent = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "valid registered event"
)
eventStore.appendToStream(validEvent, aggregateId, 0)
// Manually add event data for an unregistered event type
val unregisteredEventData = mapOf(
"eventType" to "UnknownEventType", // Not registered in serializer
"eventData" to """{"someField": "someValue", "aggregateId": {"value": "$aggregateId"}, "version": {"value": 2}}""",
"aggregateId" to aggregateId.toString(),
"version" to "2",
"eventId" to Uuid.random().toString(),
"timestamp" to Clock.System.now().toString()
)
redisTemplate.opsForStream<String, String>().add(streamKey, unregisteredEventData)
// Add another valid event
val validEvent2 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(3L),
data = "final valid event"
)
eventStore.appendToStream(validEvent2, aggregateId, 2)
// Reading should skip unregistered events and return only valid ones
val events = eventStore.readFromStream(aggregateId)
assertEquals(2, events.size)
assertEquals("valid registered event", (events[0] as TestErrorEvent).data)
assertEquals("final valid event", (events[1] as TestErrorEvent).data)
}
@Test
fun `should handle concurrent version conflicts properly with retry logic`() {
val aggregateId = Uuid.random()
// Create an initial event
val event1 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
data = "initial event"
)
eventStore.appendToStream(event1, aggregateId, 0)
// Try to append two events with the same expected version (simulating concurrent access)
val event2 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(2L),
data = "concurrent event 1"
)
val event3 = TestErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(2L), // Same version - will conflict
data = "concurrent event 2"
)
// First append should succeed
val version2 = eventStore.appendToStream(event2, aggregateId, 1)
assertEquals(2L, version2)
// Second append with the same expected version should fail
assertThrows<ConcurrencyException> {
eventStore.appendToStream(event3, aggregateId, 1) // Still expecting version 1
}
// But should succeed with a correct expected version
val correctedEvent3 = event3.copy(version = EventVersion(3L))
val version3 = eventStore.appendToStream(correctedEvent3, aggregateId, 2)
assertEquals(3L, version3)
// Verify all events are in the stream
val allEvents = eventStore.readFromStream(aggregateId)
assertEquals(3, allEvents.size)
assertEquals(listOf(1L, 2L, 3L), allEvents.map { it.version.value })
}
@Test
fun `should handle complex nested object serialization correctly`() {
val aggregateId = Uuid.random()
val complexEvent = ComplexErrorEvent(
aggregateId = AggregateId(aggregateId),
version = EventVersion(1L),
nestedData = ComplexNestedData(
id = 42,
name = "Complex Test",
subObjects = listOf(
SubObject("sub1", 1, mapOf("key1" to "value1")),
SubObject("sub2", 2, mapOf("key2" to "value2", "key3" to "value3"))
),
metadata = mapOf(
"level1" to mapOf("level2" to mapOf("level3" to "deep value")),
"array" to listOf("item1", "item2", "item3")
)
)
)
// Should handle complex serialization without issues
assertDoesNotThrow {
eventStore.appendToStream(complexEvent, aggregateId, 0)
}
// Should deserialize a complex object correctly
val retrievedEvents = eventStore.readFromStream(aggregateId)
assertEquals(1, retrievedEvents.size)
val retrievedEvent = retrievedEvents[0] as ComplexErrorEvent
assertEquals(42, retrievedEvent.nestedData.id)
assertEquals("Complex Test", retrievedEvent.nestedData.name)
assertEquals(2, retrievedEvent.nestedData.subObjects.size)
assertEquals("sub1", retrievedEvent.nestedData.subObjects[0].name)
assertEquals(2, retrievedEvent.nestedData.subObjects[1].value)
assertTrue(retrievedEvent.nestedData.metadata.containsKey("level1"))
}
// Test event classes
@Serializable
data class TestErrorEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val data: String
) : BaseDomainEvent(aggregateId, EventType("TestErrorEvent"), version)
@Serializable
data class LargePayloadEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val largeData: String,
val metadata: Map<String, String>
) : BaseDomainEvent(aggregateId, EventType("LargePayloadEvent"), version)
@Serializable
data class ComplexErrorEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val nestedData: ComplexNestedData
) : BaseDomainEvent(aggregateId, EventType("ComplexErrorEvent"), version)
@Serializable
data class ComplexNestedData(
val id: Int,
val name: String,
val subObjects: List<SubObject>,
val metadata: Map<String, Any>
)
@Serializable
data class SubObject(
val name: String,
val value: Int,
val properties: Map<String, String>
)
}
@@ -0,0 +1,146 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.event.DomainEvent
import at.mocode.core.domain.model.*
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.StringRedisTemplate
import org.testcontainers.containers.GenericContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
@Testcontainers
class RedisEventStoreIntegrationTest {
companion object {
@Container
val redisContainer: GenericContainer<*> = GenericContainer(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379)
}
private lateinit var redisTemplate: StringRedisTemplate
private lateinit var serializer: EventSerializer
private lateinit var properties: RedisEventStoreProperties
private lateinit var eventStore: EventStore
private lateinit var eventConsumer: RedisEventConsumer
@BeforeEach
fun setUp() {
val redisPort = redisContainer.getMappedPort(6379)
val redisHost = redisContainer.host
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
val connectionFactory = LettuceConnectionFactory(redisConfig)
connectionFactory.afterPropertiesSet()
redisTemplate = StringRedisTemplate(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(TestCreatedEvent::class.java, "TestCreated")
registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
}
properties = RedisEventStoreProperties().apply {
streamPrefix = "test-stream:"
allEventsStream = "all-events"
consumerGroup = "test-group"
consumerName = "test-consumer"
}
eventStore = RedisEventStore(redisTemplate, serializer, properties)
eventConsumer = RedisEventConsumer(redisTemplate, serializer, properties)
cleanupRedis()
}
@AfterEach
fun tearDown() {
eventConsumer.shutdown()
cleanupRedis()
}
private fun cleanupRedis() {
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
redisTemplate.delete(keys)
}
val allEventsStreamKey = "${properties.streamPrefix}${properties.allEventsStream}"
redisTemplate.delete(allEventsStreamKey)
}
@Test
fun `event publishing and consuming with consumer groups should work`() {
val aggregateId = Uuid.random()
val event1 = TestCreatedEvent(aggregateId = AggregateId(aggregateId), version = EventVersion(1L), name = "Test Entity")
val event2 = TestUpdatedEvent(aggregateId = AggregateId(aggregateId), version = EventVersion(2L), name = "Updated Test Entity")
val latch = CountDownLatch(2)
val receivedEvents = mutableListOf<DomainEvent>()
eventConsumer.registerEventHandler("TestCreated") { event ->
receivedEvents.add(event)
latch.countDown()
}
eventConsumer.registerEventHandler("TestUpdated") { event ->
receivedEvents.add(event)
latch.countDown()
}
eventConsumer.init()
eventStore.appendToStream(listOf(event1, event2), aggregateId, 0)
// KORREKTUR: Manuelles Auslösen des Pollings, da @Scheduled im Test nicht aktiv ist.
eventConsumer.pollEvents()
// Der Latch sollte jetzt fast sofort herunterzählen. Wir warten zur Sicherheit eine kurze Zeit.
assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for events. Latch count: ${latch.count}")
assertEquals(2, receivedEvents.size)
val receivedEvent1 = receivedEvents.find { it.version == EventVersion(1L) } as TestCreatedEvent
assertEquals(AggregateId(aggregateId), receivedEvent1.aggregateId)
assertEquals("Test Entity", receivedEvent1.name)
val receivedEvent2 = receivedEvents.find { it.version == EventVersion(2L) } as TestUpdatedEvent
assertEquals(AggregateId(aggregateId), receivedEvent2.aggregateId)
assertEquals("Updated Test Entity", receivedEvent2.name)
}
data class TestCreatedEvent(
override val aggregateId: AggregateId,
override val version: EventVersion,
val name: String,
override val eventType: EventType = EventType("TestCreated"),
override val eventId: EventId = EventId(Uuid.random()),
override val timestamp: Instant = Clock.System.now(),
override val correlationId: CorrelationId? = null,
override val causationId: CausationId? = null
) : BaseDomainEvent(aggregateId, eventType, version, eventId, timestamp, correlationId, causationId)
data class TestUpdatedEvent(
override val aggregateId: AggregateId,
override val version: EventVersion,
val name: String,
override val eventType: EventType = EventType("TestUpdated"),
override val eventId: EventId = EventId(Uuid.random()),
override val timestamp: Instant = Clock.System.now(),
override val correlationId: CorrelationId? = null,
override val causationId: CausationId? = null
) : BaseDomainEvent(aggregateId, eventType, version, eventId, timestamp, correlationId, causationId)
}
@@ -0,0 +1,345 @@
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)
}
@@ -0,0 +1,117 @@
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.assertEquals
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.uuid.Uuid
@Testcontainers
class RedisEventStoreTest {
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(TestCreatedEvent::class.java, "TestCreated")
registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
}
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 `append and read events should work correctly for new stream`() {
val aggregateId = Uuid.random()
val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity")
val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity")
eventStore.appendToStream(listOf(event1, event2), aggregateId, 0)
val events = eventStore.readFromStream(aggregateId)
assertEquals(2, events.size)
val firstEvent = events[0] as TestCreatedEvent
assertEquals(EventVersion(1L), firstEvent.version)
assertEquals("Test Entity", firstEvent.name)
val secondEvent = events[1] as TestUpdatedEvent
assertEquals(EventVersion(2L), secondEvent.version)
assertEquals("Updated Test Entity", secondEvent.name)
}
@Test
fun `appending with wrong expected version should throw ConcurrencyException`() {
val aggregateId = Uuid.random()
val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity")
eventStore.appendToStream(listOf(event1), aggregateId, 0) // Stream is now at version 1
val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity")
assertThrows<ConcurrencyException> {
eventStore.appendToStream(listOf(event2), aggregateId, 0)
}
}
@Serializable
data class TestCreatedEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val name: String
) : BaseDomainEvent(aggregateId, EventType("TestCreated"), version)
@Serializable
data class TestUpdatedEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val name: String
) : BaseDomainEvent(aggregateId, EventType("TestUpdated"), version)
}
@@ -0,0 +1,119 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.infrastructure.eventstore.redis
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.event.DomainEvent
import at.mocode.core.domain.model.AggregateId
import at.mocode.core.domain.model.EventType
import at.mocode.core.domain.model.EventVersion
import at.mocode.infrastructure.eventstore.api.EventSerializer
import at.mocode.infrastructure.eventstore.api.EventStore
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.StringRedisTemplate
import org.testcontainers.containers.GenericContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName
import kotlin.uuid.Uuid
@Testcontainers
class RedisIntegrationTest {
companion object {
@Container
val redisContainer: GenericContainer<*> = GenericContainer(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379)
}
private lateinit var redisTemplate: StringRedisTemplate
private lateinit var serializer: EventSerializer
private lateinit var properties: RedisEventStoreProperties
private lateinit var eventStore: EventStore
private lateinit var eventConsumer: RedisEventConsumer
@BeforeEach
fun setUp() {
val redisPort = redisContainer.getMappedPort(6379)
val redisHost = redisContainer.host
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
val connectionFactory = LettuceConnectionFactory(redisConfig)
connectionFactory.afterPropertiesSet()
redisTemplate = StringRedisTemplate(connectionFactory)
serializer = JacksonEventSerializer().apply {
registerEventType(TestCreatedEvent::class.java, "TestCreated")
registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
}
properties = RedisEventStoreProperties().apply {
streamPrefix = "test-stream:"
allEventsStream = "all-events"
consumerGroup = "test-group"
consumerName = "test-consumer"
}
eventStore = RedisEventStore(redisTemplate, serializer, properties)
eventConsumer = RedisEventConsumer(redisTemplate, serializer, properties)
cleanupRedis()
eventConsumer.init()
}
@AfterEach
fun tearDown() {
eventConsumer.shutdown()
cleanupRedis()
}
private fun cleanupRedis() {
val allEventsStreamKey = "${properties.streamPrefix}${properties.allEventsStream}"
val keys = redisTemplate.keys("${properties.streamPrefix}*")
if (!keys.isNullOrEmpty()) {
redisTemplate.delete(keys)
}
redisTemplate.delete(allEventsStreamKey)
}
@Test
fun `event publishing and consuming should be fast and reliable`() {
val aggregateId = Uuid.random()
val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity")
val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity")
val receivedEvents = mutableListOf<DomainEvent>()
eventConsumer.registerEventHandler("TestCreated") { receivedEvents.add(it) }
eventConsumer.registerEventHandler("TestUpdated") { receivedEvents.add(it) }
eventStore.appendToStream(listOf(event1, event2), aggregateId, 0)
eventConsumer.pollEvents()
assertEquals(2, receivedEvents.size)
val receivedEvent1 = receivedEvents.find { it.version == EventVersion(1L) } as TestCreatedEvent
assertEquals(AggregateId(aggregateId), receivedEvent1.aggregateId)
assertEquals("Test Entity", receivedEvent1.name)
val receivedEvent2 = receivedEvents.find { it.version == EventVersion(2L) } as TestUpdatedEvent
assertEquals(AggregateId(aggregateId), receivedEvent2.aggregateId)
assertEquals("Updated Test Entity", receivedEvent2.name)
}
@Serializable
data class TestCreatedEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val name: String
) : BaseDomainEvent(aggregateId, EventType("TestCreated"), version)
@Serializable
data class TestUpdatedEvent(
@Transient override val aggregateId: AggregateId = AggregateId(Uuid.random()),
@Transient override val version: EventVersion = EventVersion(0),
val name: String
) : BaseDomainEvent(aggregateId, EventType("TestUpdated"), version)
}