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:
+240
@@ -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}"
|
||||
}
|
||||
}
|
||||
+99
@@ -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()
|
||||
}
|
||||
}
|
||||
+287
@@ -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}"
|
||||
}
|
||||
}
|
||||
+313
@@ -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
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package at.mocode.infrastructure.eventstore.redis
|
||||
|
||||
import at.mocode.infrastructure.eventstore.api.EventSerializer
|
||||
import at.mocode.infrastructure.eventstore.api.EventStore
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory
|
||||
import org.springframework.data.redis.connection.RedisPassword
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
|
||||
import org.springframework.data.redis.core.StringRedisTemplate
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* Redis Event Store 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)
|
||||
}
|
||||
}
|
||||
+278
@@ -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>
|
||||
)
|
||||
}
|
||||
+251
@@ -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
|
||||
}
|
||||
+509
@@ -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)
|
||||
}
|
||||
+385
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+356
@@ -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>
|
||||
)
|
||||
}
|
||||
+146
@@ -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)
|
||||
}
|
||||
+345
@@ -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)
|
||||
}
|
||||
+117
@@ -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)
|
||||
}
|
||||
+119
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user