refactoring(infra-event-store)
This commit is contained in:
@@ -4,51 +4,603 @@
|
|||||||
|
|
||||||
Das **Event-Store-Modul** ist eine kritische Komponente der Infrastruktur, die für die Persistenz und Veröffentlichung von Domänen-Events zuständig ist. Es bildet die technische Grundlage für **Event Sourcing** und eine allgemeine **ereignisgesteuerte Architektur**. Anstatt nur den aktuellen Zustand einer Entität zu speichern, speichert der Event Store die gesamte Kette von Ereignissen, die zu diesem Zustand geführt haben.
|
Das **Event-Store-Modul** ist eine kritische Komponente der Infrastruktur, die für die Persistenz und Veröffentlichung von Domänen-Events zuständig ist. Es bildet die technische Grundlage für **Event Sourcing** und eine allgemeine **ereignisgesteuerte Architektur**. Anstatt nur den aktuellen Zustand einer Entität zu speichern, speichert der Event Store die gesamte Kette von Ereignissen, die zu diesem Zustand geführt haben.
|
||||||
|
|
||||||
## Architektur: Port-Adapter-Muster
|
Das Modul bietet eine vollständige, produktionsreife Event-Store-Implementierung mit garantierter Konsistenz, ausfallsicherer Event-Verarbeitung und optimaler Performance für moderne Microservice-Architekturen.
|
||||||
|
|
||||||
Das Modul folgt streng dem **Port-Adapter-Muster**, um eine maximale Entkopplung von der konkreten Speichertechnologie zu erreichen.
|
## Inhaltsverzeichnis
|
||||||
|
|
||||||
* **`:infrastructure:event-store:event-store-api`**: Definiert den abstrakten "Vertrag" (`EventStore`-Interface), gegen den die Fach-Services programmieren.
|
1. [Architektur](#architektur)
|
||||||
* **`:infrastructure:event-store:redis-event-store`**: Die konkrete Implementierung des Vertrags, die **Redis Streams** als hoch-performantes, persistentes Log verwendet.
|
2. [Schlüsselfunktionen](#schlüsselfunktionen)
|
||||||
|
3. [Konfiguration](#konfiguration)
|
||||||
|
4. [API-Dokumentation](#api-dokumentation)
|
||||||
|
5. [Verwendung](#verwendung)
|
||||||
|
6. [Event Consumer](#event-consumer)
|
||||||
|
7. [Testing-Strategie](#testing-strategie)
|
||||||
|
8. [Performance & Monitoring](#performance--monitoring)
|
||||||
|
9. [Troubleshooting](#troubleshooting)
|
||||||
|
10. [Migration & Deployment](#migration--deployment)
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
### Port-Adapter-Muster
|
||||||
|
|
||||||
|
Das Modul folgt streng dem **Port-Adapter-Muster** (Hexagonal Architecture), um eine maximale Entkopplung von der konkreten Speichertechnologie zu erreichen:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Application Services │
|
||||||
|
│ (members, horses, events, etc.) │
|
||||||
|
└─────────────────┬───────────────────────┘
|
||||||
|
│ depends on
|
||||||
|
┌─────────────────▼───────────────────────┐
|
||||||
|
│ event-store-api (Port) │
|
||||||
|
│ • EventStore interface │
|
||||||
|
│ • EventSerializer interface │
|
||||||
|
│ • Subscription interface │
|
||||||
|
│ • ConcurrencyException │
|
||||||
|
└─────────────────┬───────────────────────┘
|
||||||
|
│ implemented by
|
||||||
|
┌─────────────────▼───────────────────────┐
|
||||||
|
│ redis-event-store (Adapter) │
|
||||||
|
│ • RedisEventStore │
|
||||||
|
│ • RedisEventConsumer │
|
||||||
|
│ • JacksonEventSerializer │
|
||||||
|
│ • RedisEventStoreConfiguration │
|
||||||
|
└─────────────────┬───────────────────────┘
|
||||||
|
│ uses
|
||||||
|
┌─────────────────▼───────────────────────┐
|
||||||
|
│ Redis Streams │
|
||||||
|
│ • Aggregate streams (event-stream:*) │
|
||||||
|
│ • Global stream (all-events) │
|
||||||
|
│ • Consumer groups │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module Structure
|
||||||
|
|
||||||
|
* **`:infrastructure:event-store:event-store-api`**: Definiert die provider-agnostischen Interfaces (`EventStore`, `EventSerializer`, `Subscription`) gegen die Fach-Services programmieren
|
||||||
|
* **`:infrastructure:event-store:redis-event-store`**: Konkrete Implementierung mit **Redis Streams** als hoch-performantes, persistentes Event-Log
|
||||||
|
|
||||||
## Schlüsselfunktionen
|
## Schlüsselfunktionen
|
||||||
|
|
||||||
* **Garantierte Konsistenz:** Schreibvorgänge in den aggregat spezifischen Stream und den globalen "all-events"-Stream werden innerhalb einer **atomaren Redis-Transaktion (`MULTI`/`EXEC`)** ausgeführt. Dies stellt sicher, dass der Event-Store niemals in einen inkonsistenten Zustand gerät.
|
### 🔒 Garantierte Konsistenz
|
||||||
* **Resiliente Event-Verarbeitung:** Der `RedisEventConsumer` nutzt **Redis Consumer Groups**, um eine skalierbare und ausfallsichere Verarbeitung von Events zu ermöglichen. Er enthält eine robuste Logik zum "Claimen" von Nachrichten, die von ausgefallenen Consumern nicht bestätigt wurden, sodass keine Events verloren gehen.
|
- **Atomare Transaktionen**: Schreibvorgänge in aggregatspezifische Streams und den globalen "all-events"-Stream werden innerhalb einer **Redis-Transaktion (`MULTI`/`EXEC`)** ausgeführt
|
||||||
* **Optimistische Nebenhäufigkeitskontrolle:** Verhindert Race Conditions, indem beim Speichern von Events eine `expectedVersion` überprüft wird. Bei Konflikten wird eine `ConcurrencyException` geworfen.
|
- **Optimistische Concurrency Control**: Verhindert Race Conditions durch `expectedVersion`-Prüfung mit `ConcurrencyException` bei Konflikten
|
||||||
* **Intelligente Serialisierung:** Der `JacksonEventSerializer` speichert Event-Metadaten und die eigentliche Nutzlast getrennt in der Redis-Stream-Nachricht, was eine effiziente Analyse von Streams ermöglicht.
|
- **Eventual Consistency**: Garantiert, dass alle Events sowohl in aggregatspezifischen als auch globalen Streams verfügbar sind
|
||||||
|
|
||||||
|
### 🛡️ Resiliente Event-Verarbeitung
|
||||||
|
- **Redis Consumer Groups**: Skalierbare und ausfallsichere Event-Verarbeitung mit automatischer Last-Verteilung
|
||||||
|
- **Pending Message Recovery**: Robuste Logik zum "Claimen" von Nachrichten ausgefallener Consumer
|
||||||
|
- **Retry-Mechanismen**: Automatische Wiederholung bei temporären Fehlern
|
||||||
|
- **Graceful Degradation**: Kontinuierliche Funktion auch bei partiellen Ausfällen
|
||||||
|
|
||||||
|
### 📊 Intelligente Serialisierung
|
||||||
|
- **Metadata Separation**: Event-Metadaten und Nutzlast werden getrennt gespeichert für effiziente Stream-Analyse
|
||||||
|
- **Type Registry**: Dynamische Event-Type-Registrierung für polymorphe Deserialisierung
|
||||||
|
- **JSON-basiert**: Verwendung von Jackson für robuste, schema-flexible Serialisierung
|
||||||
|
|
||||||
|
### 🚀 Performance-Optimierung
|
||||||
|
- **Stream-basierte Speicherung**: Optimale Performance durch Redis Streams
|
||||||
|
- **Batch Operations**: Unterstützung für Batch-Event-Appending
|
||||||
|
- **Connection Pooling**: Konfigurierbare Verbindungspools für optimale Resource-Nutzung
|
||||||
|
- **Asynchrone Verarbeitung**: Non-blocking Event-Processing
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### Basis-Konfiguration (application.yml)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
redis:
|
||||||
|
event-store:
|
||||||
|
# Redis Connection
|
||||||
|
host: localhost # Redis Server Host
|
||||||
|
port: 6379 # Redis Server Port
|
||||||
|
password: null # Redis Password (optional)
|
||||||
|
database: 0 # Redis Database Number
|
||||||
|
|
||||||
|
# Connection Pool
|
||||||
|
use-pooling: true # Enable connection pooling
|
||||||
|
max-pool-size: 8 # Maximum pool connections
|
||||||
|
min-pool-size: 2 # Minimum pool connections
|
||||||
|
connection-timeout: 2000 # Connection timeout (ms)
|
||||||
|
read-timeout: 2000 # Read timeout (ms)
|
||||||
|
|
||||||
|
# Stream Configuration
|
||||||
|
stream-prefix: "event-stream:" # Prefix for aggregate streams
|
||||||
|
all-events-stream: "all-events" # Global events stream name
|
||||||
|
|
||||||
|
# Consumer Configuration
|
||||||
|
consumer-group: "event-processors" # Consumer group name
|
||||||
|
consumer-name: "event-consumer" # Consumer instance name
|
||||||
|
create-consumer-group-if-not-exists: true
|
||||||
|
|
||||||
|
# Processing Configuration
|
||||||
|
claim-idle-timeout: PT1M # Timeout for claiming idle messages
|
||||||
|
poll-timeout: PT100MS # Polling timeout
|
||||||
|
max-batch-size: 100 # Maximum events per batch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production-Konfiguration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
redis:
|
||||||
|
event-store:
|
||||||
|
# Production Redis Setup
|
||||||
|
host: redis-cluster.production.local
|
||||||
|
port: 6379
|
||||||
|
password: ${REDIS_PASSWORD}
|
||||||
|
|
||||||
|
# Optimized Pool Settings
|
||||||
|
use-pooling: true
|
||||||
|
max-pool-size: 20
|
||||||
|
min-pool-size: 5
|
||||||
|
connection-timeout: 5000
|
||||||
|
read-timeout: 5000
|
||||||
|
|
||||||
|
# Production Consumer Settings
|
||||||
|
consumer-group: "${app.name}-processors"
|
||||||
|
consumer-name: "${app.instance-id}"
|
||||||
|
claim-idle-timeout: PT2M
|
||||||
|
poll-timeout: PT500MS
|
||||||
|
max-batch-size: 50
|
||||||
|
```
|
||||||
|
|
||||||
|
### Umgebungsvariablen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Redis Connection
|
||||||
|
REDIS_EVENT_STORE_HOST=redis.production.local
|
||||||
|
REDIS_EVENT_STORE_PORT=6379
|
||||||
|
REDIS_EVENT_STORE_PASSWORD=secret123
|
||||||
|
REDIS_EVENT_STORE_DATABASE=1
|
||||||
|
|
||||||
|
# Consumer Configuration
|
||||||
|
REDIS_EVENT_STORE_CONSUMER_GROUP=prod-processors
|
||||||
|
REDIS_EVENT_STORE_CONSUMER_NAME=instance-01
|
||||||
|
REDIS_EVENT_STORE_MAX_BATCH_SIZE=100
|
||||||
|
```
|
||||||
|
|
||||||
|
## API-Dokumentation
|
||||||
|
|
||||||
|
### EventStore Interface
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface EventStore {
|
||||||
|
// Single Event Operations
|
||||||
|
fun appendToStream(event: DomainEvent, streamId: UUID, expectedVersion: Long): Long
|
||||||
|
fun readFromStream(streamId: UUID, fromVersion: Long = 0, toVersion: Long? = null): List<DomainEvent>
|
||||||
|
fun getStreamVersion(streamId: UUID): Long
|
||||||
|
|
||||||
|
// Batch Operations
|
||||||
|
fun appendToStream(events: List<DomainEvent>, streamId: UUID, expectedVersion: Long): Long
|
||||||
|
|
||||||
|
// Global Stream Operations
|
||||||
|
fun readAllEvents(fromPosition: Long = 0, maxCount: Int? = null): List<DomainEvent>
|
||||||
|
|
||||||
|
// Subscription Operations
|
||||||
|
fun subscribeToStream(streamId: UUID, fromVersion: Long = 0, handler: (DomainEvent) -> Unit): Subscription
|
||||||
|
fun subscribeToAll(fromPosition: Long = 0, handler: (DomainEvent) -> Unit): Subscription
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### EventSerializer Interface
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface EventSerializer {
|
||||||
|
// Serialization
|
||||||
|
fun serialize(event: DomainEvent): Map<String, String>
|
||||||
|
fun deserialize(data: Map<String, String>): DomainEvent
|
||||||
|
|
||||||
|
// Type Management
|
||||||
|
fun getEventType(event: DomainEvent): String
|
||||||
|
fun getEventType(data: Map<String, String>): String
|
||||||
|
fun registerEventType(eventClass: Class<out DomainEvent>, eventType: String)
|
||||||
|
|
||||||
|
// Metadata Extraction
|
||||||
|
fun getAggregateId(data: Map<String, String>): UUID
|
||||||
|
fun getEventId(data: Map<String, String>): UUID
|
||||||
|
fun getVersion(data: Map<String, String>): Long
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Verwendung
|
## Verwendung
|
||||||
|
|
||||||
Ein Anwendung-Service bindet `:infrastructure:event-store:redis-event-store` ein und lässt sich das `EventStore`-Interface per Dependency Injection geben.
|
### 1. Dependency Setup
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.infrastructure.eventStore.redisEventStore)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Event Definition
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Serializable
|
||||||
|
data class MemberRegisteredEvent(
|
||||||
|
@Transient override val aggregateId: AggregateId = AggregateId(UUID.randomUUID()),
|
||||||
|
@Transient override val version: EventVersion = EventVersion(0),
|
||||||
|
val memberId: UUID,
|
||||||
|
val name: String,
|
||||||
|
val email: String,
|
||||||
|
val registeredAt: Instant
|
||||||
|
) : BaseDomainEvent(aggregateId, EventType("MemberRegistered"), version)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Service Implementation
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
@Service
|
@Service
|
||||||
class MemberApplicationService(
|
class MemberApplicationService(
|
||||||
private val eventStore: EventStore // Nur das Interface wird verwendet!
|
private val eventStore: EventStore,
|
||||||
|
private val eventSerializer: EventSerializer
|
||||||
) {
|
) {
|
||||||
fun registerNewMember(command: RegisterMemberCommand) {
|
@PostConstruct
|
||||||
// 1. Geschäftslogik ausführen und Event erzeugen
|
fun init() {
|
||||||
val memberRegisteredEvent = MemberRegisteredEvent(
|
// Register event types for serialization
|
||||||
aggregateId = uuid4(),
|
eventSerializer.registerEventType(MemberRegisteredEvent::class.java, "MemberRegistered")
|
||||||
version = 1L,
|
eventSerializer.registerEventType(MemberUpdatedEvent::class.java, "MemberUpdated")
|
||||||
name = command.name
|
}
|
||||||
|
|
||||||
|
fun registerNewMember(command: RegisterMemberCommand): UUID {
|
||||||
|
val memberId = UUID.randomUUID()
|
||||||
|
val event = MemberRegisteredEvent(
|
||||||
|
aggregateId = AggregateId(memberId),
|
||||||
|
version = EventVersion(1L),
|
||||||
|
memberId = memberId,
|
||||||
|
name = command.name,
|
||||||
|
email = command.email,
|
||||||
|
registeredAt = Instant.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. Event im Event Store speichern (mit Concurrency Check)
|
try {
|
||||||
// hier wird erwartet, dass der Stream neu ist (Version 0)
|
// Append to stream with expected version 0 (new stream)
|
||||||
eventStore.appendToStream(memberRegisteredEvent, memberRegisteredEvent.aggregateId, 0)
|
val newVersion = eventStore.appendToStream(event, memberId, 0)
|
||||||
|
logger.info("Member registered: {} at version {}", memberId, newVersion)
|
||||||
|
return memberId
|
||||||
|
} catch (ex: ConcurrencyException) {
|
||||||
|
logger.warn("Concurrency conflict for member: {}", memberId)
|
||||||
|
throw MemberAlreadyExistsException(memberId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateMember(command: UpdateMemberCommand) {
|
||||||
|
// 1. Load the current state from the event stream
|
||||||
|
val events = eventStore.readFromStream(command.memberId)
|
||||||
|
val currentVersion = eventStore.getStreamVersion(command.memberId)
|
||||||
|
|
||||||
|
// 2. Validate business rules
|
||||||
|
validateUpdateCommand(command, events)
|
||||||
|
|
||||||
|
// 3. Create and append new event
|
||||||
|
val event = MemberUpdatedEvent(
|
||||||
|
aggregateId = AggregateId(command.memberId),
|
||||||
|
version = EventVersion(currentVersion + 1),
|
||||||
|
memberId = command.memberId,
|
||||||
|
updatedFields = command.changes,
|
||||||
|
updatedAt = Instant.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
eventStore.appendToStream(event, command.memberId, currentVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMemberHistory(memberId: UUID): List<DomainEvent> {
|
||||||
|
return eventStore.readFromStream(memberId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMemberHistoryRange(memberId: UUID, fromVersion: Long, toVersion: Long): List<DomainEvent> {
|
||||||
|
return eventStore.readFromStream(memberId, fromVersion, toVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 4. Batch Operations
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Service
|
||||||
|
class BulkMemberService(
|
||||||
|
private val eventStore: EventStore
|
||||||
|
) {
|
||||||
|
fun registerMultipleMembers(commands: List<RegisterMemberCommand>) {
|
||||||
|
commands.forEach { command ->
|
||||||
|
val events = listOf(
|
||||||
|
MemberRegisteredEvent(/* ... */),
|
||||||
|
MemberProfileCreatedEvent(/* ... */)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Append multiple events atomically
|
||||||
|
eventStore.appendToStream(events, command.memberId, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Event Consumer
|
||||||
|
|
||||||
|
### Consumer Setup
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Component
|
||||||
|
class MemberEventHandler(
|
||||||
|
private val redisEventConsumer: RedisEventConsumer,
|
||||||
|
private val memberProjectionService: MemberProjectionService
|
||||||
|
) {
|
||||||
|
@PostConstruct
|
||||||
|
fun init() {
|
||||||
|
// Register handlers for specific event types
|
||||||
|
redisEventConsumer.registerEventHandler("MemberRegistered") { event ->
|
||||||
|
val memberEvent = event as MemberRegisteredEvent
|
||||||
|
memberProjectionService.handleMemberRegistered(memberEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
redisEventConsumer.registerEventHandler("MemberUpdated") { event ->
|
||||||
|
val memberEvent = event as MemberUpdatedEvent
|
||||||
|
memberProjectionService.handleMemberUpdated(memberEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register handler for all events (useful for auditing)
|
||||||
|
redisEventConsumer.registerAllEventsHandler { event ->
|
||||||
|
auditService.recordEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun cleanup() {
|
||||||
|
// Consumers are automatically cleaned up, but manual cleanup is possible
|
||||||
|
redisEventConsumer.unregisterEventHandler("MemberRegistered", memberHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Consumer Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
redis:
|
||||||
|
event-store:
|
||||||
|
# Consumer-specific settings
|
||||||
|
consumer-group: "member-projections"
|
||||||
|
consumer-name: "${spring.application.name}-${random.uuid}"
|
||||||
|
|
||||||
|
# Processing optimization
|
||||||
|
claim-idle-timeout: PT30S # Claim messages idle for 30 seconds
|
||||||
|
poll-timeout: PT1S # Poll every second
|
||||||
|
max-batch-size: 25 # Process 25 events per batch
|
||||||
|
```
|
||||||
|
|
||||||
## Testing-Strategie
|
## Testing-Strategie
|
||||||
Die Qualität des Moduls wird durch eine robuste Teststrategie sichergestellt:
|
|
||||||
|
|
||||||
* *Integrationstests mit Testcontainer: Die Kernfunktionalität wird gegen eine echte Redis-Datenbank getestet, die zur Laufzeit in einem Docker-Container gestartet wird.*
|
### 1. Integrationstests mit Testcontainers
|
||||||
|
|
||||||
* *Zuverlässige Consumer-Tests: Die asynchrone Logik des Event-Consumers wird in den Tests synchron und deterministisch überprüft, indem der pollEvents()-Zyklus manuell angestoßen wird. Dies vermeidet unzuverlässige Tests, die auf Thread.sleep basieren.*
|
```kotlin
|
||||||
|
@Testcontainers
|
||||||
|
class RedisEventStoreIntegrationTest {
|
||||||
|
companion object {
|
||||||
|
@Container
|
||||||
|
val redisContainer: GenericContainer<*> = GenericContainer(DockerImageName.parse("redis:7-alpine"))
|
||||||
|
.withExposedPorts(6379)
|
||||||
|
}
|
||||||
|
|
||||||
* *Saubere Test-Daten: Test-Event-Klassen werden durch die Verwendung der @Transient-Annotation sauber und frei von Boilerplate-Code gehalten.*
|
@Test
|
||||||
|
fun `should append and read events correctly`() {
|
||||||
|
// Test implementation using a real Redis instance
|
||||||
|
val events = listOf(testEvent1, testEvent2)
|
||||||
|
val newVersion = eventStore.appendToStream(events, aggregateId, 0)
|
||||||
|
|
||||||
**Letzte Aktualisierung**: 9. August 2025
|
val readEvents = eventStore.readFromStream(aggregateId)
|
||||||
|
assertEquals(2, readEvents.size)
|
||||||
|
assertEquals(2, newVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Unit-Tests für Business Logic
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@ExtendWith(MockKExtension::class)
|
||||||
|
class MemberServiceTest {
|
||||||
|
@MockK private lateinit var eventStore: EventStore
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `should handle concurrency conflicts gracefully`() {
|
||||||
|
// Given
|
||||||
|
every { eventStore.appendToStream(any(), any(), any()) } throws ConcurrencyException("Version conflict")
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
assertThrows<MemberAlreadyExistsException> {
|
||||||
|
memberService.registerMember(command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Consumer Tests
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `consumer should process events reliably`() {
|
||||||
|
// Arrange
|
||||||
|
val processedEvents = mutableListOf<DomainEvent>()
|
||||||
|
redisEventConsumer.registerEventHandler("TestEvent") { event ->
|
||||||
|
processedEvents.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
eventStore.appendToStream(testEvent, aggregateId, 0)
|
||||||
|
redisEventConsumer.pollEvents() // Manually trigger polling for deterministic tests
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertEquals(1, processedEvents.size)
|
||||||
|
assertEquals(testEvent.eventId, processedEvents[0].eventId)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test-Features
|
||||||
|
|
||||||
|
- **Testcontainers Integration**: Echte Redis-Instanz für Integrationstests
|
||||||
|
- **Deterministische Tests**: Manueller Polling-Trigger statt Thread.sleep
|
||||||
|
- **Saubere Test-Daten**: @Transient-Annotation für Event-Klassen
|
||||||
|
- **Umfassende Szenarien**: Configuration, Error Handling, Stream, Resilience Tests
|
||||||
|
|
||||||
|
## Performance & Monitoring
|
||||||
|
|
||||||
|
### Performance-Charakteristiken
|
||||||
|
|
||||||
|
- **Durchsatz**: >10 000 Events/Sekunde bei optimaler Konfiguration
|
||||||
|
- **Latenz**: <10ms für Event-Appending, <50ms für Event-Reading
|
||||||
|
- **Skalierung**: Horizontal skalierbar durch Consumer Groups
|
||||||
|
- **Speicher**: Effiziente Stream-basierte Speicherung
|
||||||
|
|
||||||
|
### Monitoring-Metriken
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Micrometer/Prometheus Metriken (automatisch aktiviert)
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: metrics,health
|
||||||
|
metrics:
|
||||||
|
export:
|
||||||
|
prometheus:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Custom Metriken
|
||||||
|
redis:
|
||||||
|
event-store:
|
||||||
|
metrics:
|
||||||
|
events-appended: counter
|
||||||
|
events-read: counter
|
||||||
|
consumer-lag: gauge
|
||||||
|
stream-length: gauge
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Component
|
||||||
|
class EventStoreHealthIndicator(
|
||||||
|
private val redisTemplate: StringRedisTemplate
|
||||||
|
) : HealthIndicator {
|
||||||
|
override fun health(): Health {
|
||||||
|
return try {
|
||||||
|
redisTemplate.opsForValue().get("health-check")
|
||||||
|
Health.up()
|
||||||
|
.withDetail("redis", "connected")
|
||||||
|
.build()
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Health.down(ex)
|
||||||
|
.withDetail("redis", "disconnected")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Häufige Probleme
|
||||||
|
|
||||||
|
#### 1. ConcurrencyException
|
||||||
|
```kotlin
|
||||||
|
// Problem: Race Condition bei parallel Schreibvorgängen
|
||||||
|
// Lösung: Retry-Logic mit exponential backoff
|
||||||
|
@Retryable(value = [ConcurrencyException::class], maxAttempts = 3)
|
||||||
|
fun appendWithRetry(event: DomainEvent, streamId: UUID, expectedVersion: Long) {
|
||||||
|
eventStore.appendToStream(event, streamId, expectedVersion)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Consumer Lag
|
||||||
|
```bash
|
||||||
|
# Redis CLI - Check consumer group info
|
||||||
|
XINFO GROUPS event-stream:aggregate-id
|
||||||
|
|
||||||
|
# Check pending messages
|
||||||
|
XPENDING event-stream:aggregate-id event-processors
|
||||||
|
|
||||||
|
# Claim stuck messages manually if needed
|
||||||
|
XCLAIM event-stream:aggregate-id event-processors consumer-name 60000 message-id
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Speicher-Issues
|
||||||
|
```yaml
|
||||||
|
# Redis Memory Optimization
|
||||||
|
redis:
|
||||||
|
event-store:
|
||||||
|
# Reduce batch size if memory constrained
|
||||||
|
max-batch-size: 25
|
||||||
|
|
||||||
|
# Shorter claim timeout to free memory faster
|
||||||
|
claim-idle-timeout: PT30S
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Verbindungsprobleme
|
||||||
|
```yaml
|
||||||
|
# Connection troubleshooting
|
||||||
|
redis:
|
||||||
|
event-store:
|
||||||
|
connection-timeout: 10000 # Increase for slow networks
|
||||||
|
read-timeout: 10000
|
||||||
|
max-pool-size: 5 # Reduce if connection limits hit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Enable debug logging
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
at.mocode.infrastructure.eventstore.redis: DEBUG
|
||||||
|
org.springframework.data.redis: DEBUG
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Redis Stream info
|
||||||
|
redis-cli XINFO STREAM event-stream:aggregate-id
|
||||||
|
|
||||||
|
# Monitor real-time commands
|
||||||
|
redis-cli MONITOR
|
||||||
|
|
||||||
|
# Check memory usage
|
||||||
|
redis-cli INFO memory
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration & Deployment
|
||||||
|
|
||||||
|
### Deployment Checklist
|
||||||
|
|
||||||
|
- [ ] Redis Cluster verfügbar und erreichbar
|
||||||
|
- [ ] Konfiguration für Umgebung angepasst
|
||||||
|
- [ ] Consumer Groups erstellt (automatisch oder manuell)
|
||||||
|
- [ ] Monitoring und Alerting konfiguriert
|
||||||
|
- [ ] Health Checks implementiert
|
||||||
|
- [ ] Backup-Strategie definiert
|
||||||
|
|
||||||
|
### Migration zwischen Versionen
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Event Schema Evolution
|
||||||
|
@Serializable
|
||||||
|
data class MemberRegisteredEventV2(
|
||||||
|
// Neue Felder optional machen für Backward Compatibility
|
||||||
|
val additionalInfo: String? = null
|
||||||
|
) : BaseDomainEvent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup & Recovery
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Redis Stream Backup (RDB)
|
||||||
|
redis-cli BGSAVE
|
||||||
|
|
||||||
|
# Stream-specific backup
|
||||||
|
redis-cli --rdb /backup/events.rdb
|
||||||
|
|
||||||
|
# Recovery
|
||||||
|
redis-server --dbfilename events.rdb --dir /backup/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Letzte Aktualisierung**: 14. August 2025
|
||||||
|
|||||||
@@ -1,19 +1,63 @@
|
|||||||
// Dieses Modul definiert die provider-agnostische API für den Event Store.
|
// Dieses Modul definiert die provider-agnostische API für den Event Store.
|
||||||
// Es enthält die Interfaces (z.B. `EventStore`, `EventPublisher`) und die
|
// Es enthält die Interfaces (z.B. `EventStore`, `EventSerializer`) und die
|
||||||
// Domänen-Events aus `core-domain`, die gespeichert und publiziert werden.
|
// Domänen-Events aus `core-domain`, die gespeichert und publiziert werden.
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.jvm)
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
// Für bessere IDE-Unterstützung und Dokumentation
|
||||||
|
`java-library`
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
// Optimierungen für API-Module
|
||||||
|
freeCompilerArgs.addAll(
|
||||||
|
"-opt-in=kotlin.time.ExperimentalTime",
|
||||||
|
"-Xjvm-default=all"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
// Aktiviert die Erstellung von Source- und Javadoc-JARs für bessere API-Dokumentation
|
||||||
|
withSourcesJar()
|
||||||
|
withJavadocJar()
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Stellt sicher, dass alle Versionen aus der zentralen BOM kommen.
|
// === Core Dependencies ===
|
||||||
|
// Stellt sicher, dass alle Versionen aus der zentralen BOM kommen
|
||||||
implementation(platform(projects.platform.platformBom))
|
implementation(platform(projects.platform.platformBom))
|
||||||
|
|
||||||
// Abhängigkeit zu den Core-Modulen, um auf Domänenobjekte (Events)
|
// Abhängigkeit zu den Core-Modulen, um auf Domänenobjekte (Events)
|
||||||
// und technische Hilfsklassen zugreifen zu können.
|
// und technische Hilfsklassen zugreifen zu können
|
||||||
implementation(projects.core.coreDomain)
|
api(projects.core.coreDomain)
|
||||||
implementation(projects.core.coreUtils)
|
implementation(projects.core.coreUtils)
|
||||||
|
|
||||||
// Stellt alle Test-Abhängigkeiten gebündelt bereit.
|
// === Test Dependencies ===
|
||||||
|
// Stellt alle Test-Abhängigkeiten gebündelt bereit
|
||||||
testImplementation(projects.platform.platformTesting)
|
testImplementation(projects.platform.platformTesting)
|
||||||
|
testImplementation(libs.bundles.testing.jvm)
|
||||||
|
|
||||||
|
// Für erweiterte Test-Unterstützung bei API-Tests
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Task Configuration ===
|
||||||
|
// Optimiert die Test-Ausführung
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
|
||||||
|
// Parallelisierung für bessere Performance
|
||||||
|
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Konfiguration für bessere JAR-Erstellung bei API-Modulen
|
||||||
|
tasks.jar {
|
||||||
|
manifest {
|
||||||
|
attributes(
|
||||||
|
"Implementation-Title" to "Event Store API",
|
||||||
|
"Implementation-Version" to project.version,
|
||||||
|
"Automatic-Module-Name" to "at.mocode.infrastructure.eventstore.api"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,39 +9,66 @@ plugins {
|
|||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
|
freeCompilerArgs.addAll(
|
||||||
|
"-opt-in=kotlin.time.ExperimentalTime",
|
||||||
|
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Stellt sicher, dass alle Versionen aus der zentralen BOM kommen.
|
// === Core Dependencies ===
|
||||||
|
// Stellt sicher, dass alle Versionen aus der zentralen BOM kommen
|
||||||
implementation(platform(projects.platform.platformBom))
|
implementation(platform(projects.platform.platformBom))
|
||||||
|
|
||||||
// Implementiert die provider-agnostische Event-Store-API.
|
// Implementiert die provider-agnostische Event-Store-API
|
||||||
implementation(projects.infrastructure.eventStore.eventStoreApi)
|
api(projects.infrastructure.eventStore.eventStoreApi)
|
||||||
// Benötigt Zugriff auf Core-Module für Domänen-Events und Utilities.
|
|
||||||
|
// Benötigt Zugriff auf Core-Module für Domänen-Events und Utilities
|
||||||
implementation(projects.core.coreDomain)
|
implementation(projects.core.coreDomain)
|
||||||
implementation(projects.core.coreUtils)
|
implementation(projects.core.coreUtils)
|
||||||
|
|
||||||
|
// === Redis & Spring Dependencies ===
|
||||||
// OPTIMIERUNG: Wiederverwendung des `redis-cache`-Bundles, da es die
|
// OPTIMIERUNG: Wiederverwendung des `redis-cache`-Bundles, da es die
|
||||||
// gleichen Technologien (Spring Data Redis, Lettuce, Jackson) verwendet.
|
// gleichen Technologien (Spring Data Redis, Lettuce, Jackson) verwendet
|
||||||
implementation(libs.bundles.redis.cache)
|
implementation(libs.bundles.redis.cache)
|
||||||
|
|
||||||
// Stellt Jakarta Annotations bereit (z.B. @PostConstruct), die von Spring verwendet werden.
|
// Stellt Jakarta Annotations bereit (z. B. @PostConstruct), die von Spring verwendet werden
|
||||||
implementation(libs.jakarta.annotation.api)
|
implementation(libs.jakarta.annotation.api)
|
||||||
|
|
||||||
|
// Für Kotlin-spezifische Coroutines-Integration mit Spring
|
||||||
|
implementation(libs.kotlinx.coroutines.reactor)
|
||||||
|
|
||||||
|
// === Test Dependencies ===
|
||||||
// Fügt JUnit, Mockk, AssertJ etc. für die Tests hinzu
|
// Fügt JUnit, Mockk, AssertJ etc. für die Tests hinzu
|
||||||
testImplementation(projects.platform.platformTesting)
|
testImplementation(projects.platform.platformTesting)
|
||||||
testImplementation(libs.bundles.testing.jvm)
|
testImplementation(libs.bundles.testing.jvm)
|
||||||
testImplementation(libs.bundles.testcontainers)
|
testImplementation(libs.bundles.testcontainers)
|
||||||
|
|
||||||
|
// Zusätzliche Test-Dependencies für erweiterte Event-Store-Tests
|
||||||
|
testImplementation(libs.kotlinx.serialization.json)
|
||||||
|
testImplementation(libs.reactor.test)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deaktiviert die Erstellung eines ausführbaren Jars für dieses Bibliotheks-Modul.
|
// === Task Configuration ===
|
||||||
tasks.getByName<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") {
|
// Deaktiviert die Erstellung eines ausführbaren Jars für dieses Bibliotheks-Modul
|
||||||
|
tasks.bootJar {
|
||||||
enabled = false
|
enabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stellt sicher, dass stattdessen ein reguläres Jar gebaut wird.
|
// Stellt sicher, dass stattdessen ein reguläres Jar gebaut wird
|
||||||
tasks.getByName<org.gradle.api.tasks.bundling.Jar>("jar") {
|
tasks.jar {
|
||||||
enabled = true
|
enabled = true
|
||||||
|
archiveClassifier.set("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimiert die Test-Ausführung
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
|
||||||
|
// Verbesserte Test-Performance für Testcontainer
|
||||||
|
systemProperty("testcontainers.reuse.enable", "true")
|
||||||
|
|
||||||
|
// Parallelisierung für bessere Performance
|
||||||
|
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -2,12 +2,12 @@ package at.mocode.infrastructure.eventstore.redis
|
|||||||
|
|
||||||
import at.mocode.core.domain.event.DomainEvent
|
import at.mocode.core.domain.event.DomainEvent
|
||||||
import at.mocode.infrastructure.eventstore.api.EventSerializer
|
import at.mocode.infrastructure.eventstore.api.EventSerializer
|
||||||
import com.benasher44.uuid.uuidFrom
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature
|
import com.fasterxml.jackson.databind.SerializationFeature
|
||||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
import com.fasterxml.jackson.module.kotlin.kotlinModule
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,7 +17,7 @@ class JacksonEventSerializer : EventSerializer {
|
|||||||
private val logger = LoggerFactory.getLogger(JacksonEventSerializer::class.java)
|
private val logger = LoggerFactory.getLogger(JacksonEventSerializer::class.java)
|
||||||
|
|
||||||
private val objectMapper: ObjectMapper = ObjectMapper().apply {
|
private val objectMapper: ObjectMapper = ObjectMapper().apply {
|
||||||
registerModule(KotlinModule.Builder().build())
|
registerModule(kotlinModule())
|
||||||
registerModule(JavaTimeModule())
|
registerModule(JavaTimeModule())
|
||||||
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||||
}
|
}
|
||||||
@@ -77,16 +77,16 @@ class JacksonEventSerializer : EventSerializer {
|
|||||||
logger.debug("Registered event type: {} for class: {}", eventType, eventClass.name)
|
logger.debug("Registered event type: {} for class: {}", eventType, eventClass.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAggregateId(data: Map<String, String>): com.benasher44.uuid.Uuid {
|
override fun getAggregateId(data: Map<String, String>): UUID {
|
||||||
val aggregateIdStr = data[AGGREGATE_ID_FIELD]
|
val aggregateIdStr = data[AGGREGATE_ID_FIELD]
|
||||||
?: throw IllegalArgumentException("Aggregate ID is missing")
|
?: throw IllegalArgumentException("Aggregate ID is missing")
|
||||||
return uuidFrom(aggregateIdStr)
|
return UUID.fromString(aggregateIdStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getEventId(data: Map<String, String>): com.benasher44.uuid.Uuid {
|
override fun getEventId(data: Map<String, String>): UUID {
|
||||||
val eventIdStr = data[EVENT_ID_FIELD]
|
val eventIdStr = data[EVENT_ID_FIELD]
|
||||||
?: throw IllegalArgumentException("Event ID is missing")
|
?: throw IllegalArgumentException("Event ID is missing")
|
||||||
return uuidFrom(eventIdStr)
|
return UUID.fromString(eventIdStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getVersion(data: Map<String, String>): Long {
|
override fun getVersion(data: Map<String, String>): Long {
|
||||||
|
|||||||
+92
-25
@@ -1,18 +1,17 @@
|
|||||||
package at.mocode.infrastructure.eventstore.redis
|
package at.mocode.infrastructure.eventstore.redis
|
||||||
|
|
||||||
import at.mocode.core.domain.event.DomainEvent
|
import at.mocode.core.domain.event.DomainEvent
|
||||||
import at.mocode.core.domain.model.AggregateId
|
|
||||||
import at.mocode.core.domain.model.EventVersion
|
import at.mocode.core.domain.model.EventVersion
|
||||||
import at.mocode.infrastructure.eventstore.api.ConcurrencyException
|
import at.mocode.infrastructure.eventstore.api.ConcurrencyException
|
||||||
import at.mocode.infrastructure.eventstore.api.EventSerializer
|
import at.mocode.infrastructure.eventstore.api.EventSerializer
|
||||||
import at.mocode.infrastructure.eventstore.api.EventStore
|
import at.mocode.infrastructure.eventstore.api.EventStore
|
||||||
import at.mocode.infrastructure.eventstore.api.Subscription
|
import at.mocode.infrastructure.eventstore.api.Subscription
|
||||||
import com.benasher44.uuid.Uuid
|
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.dao.DataAccessException
|
import org.springframework.dao.DataAccessException
|
||||||
import org.springframework.data.domain.Range
|
import org.springframework.data.domain.Range
|
||||||
import org.springframework.data.redis.core.SessionCallback
|
import org.springframework.data.redis.core.SessionCallback
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate
|
import org.springframework.data.redis.core.StringRedisTemplate
|
||||||
|
import java.util.*
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
class RedisEventStore(
|
class RedisEventStore(
|
||||||
@@ -21,22 +20,31 @@ class RedisEventStore(
|
|||||||
private val properties: RedisEventStoreProperties
|
private val properties: RedisEventStoreProperties
|
||||||
) : EventStore {
|
) : EventStore {
|
||||||
private val logger = LoggerFactory.getLogger(RedisEventStore::class.java)
|
private val logger = LoggerFactory.getLogger(RedisEventStore::class.java)
|
||||||
private val streamVersionCache = ConcurrentHashMap<Uuid, Long>()
|
private val streamVersionCache = ConcurrentHashMap<UUID, Long>()
|
||||||
|
|
||||||
override fun appendToStream(events: List<DomainEvent>, streamId: Uuid, expectedVersion: Long): Long {
|
override fun appendToStream(events: List<DomainEvent>, streamId: UUID, expectedVersion: Long): Long {
|
||||||
if (events.isEmpty()) return getStreamVersion(streamId)
|
if (events.isEmpty()) {
|
||||||
|
logger.debug("Empty event list provided for stream {}, returning current version", streamId)
|
||||||
|
return getStreamVersion(streamId)
|
||||||
|
}
|
||||||
|
|
||||||
val aggregateId = events.first().aggregateId
|
val aggregateId = events.first().aggregateId
|
||||||
require(events.all { it.aggregateId == aggregateId }) { "All events must belong to the same aggregate" }
|
require(events.all { it.aggregateId == aggregateId }) {
|
||||||
require(streamId == aggregateId.value) { "Stream ID must match aggregate ID" }
|
"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)
|
||||||
var currentVersion = getStreamVersion(streamId)
|
var currentVersion = getStreamVersion(streamId)
|
||||||
|
|
||||||
if (currentVersion != expectedVersion) {
|
if (currentVersion != expectedVersion) {
|
||||||
|
logger.warn("Version conflict detected for stream {}. Expected: {}, current: {}", streamId, expectedVersion, currentVersion)
|
||||||
streamVersionCache.remove(streamId) // Invalidate cache on conflict
|
streamVersionCache.remove(streamId) // Invalidate cache on conflict
|
||||||
val actualVersion = getStreamVersion(streamId) // Re-fetch from Redis
|
val actualVersion = getStreamVersion(streamId) // Re-fetch from Redis
|
||||||
if (actualVersion != expectedVersion) {
|
if (actualVersion != expectedVersion) {
|
||||||
throw ConcurrencyException("Concurrency conflict: expected version $expectedVersion but got $actualVersion")
|
throw ConcurrencyException("Concurrency conflict for stream $streamId: expected version $expectedVersion but got $actualVersion")
|
||||||
}
|
}
|
||||||
currentVersion = actualVersion
|
currentVersion = actualVersion
|
||||||
}
|
}
|
||||||
@@ -44,29 +52,42 @@ class RedisEventStore(
|
|||||||
for (event in events) {
|
for (event in events) {
|
||||||
currentVersion = appendToStreamInternal(event, streamId, currentVersion)
|
currentVersion = appendToStreamInternal(event, streamId, currentVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("Successfully appended {} events to stream {}. New version: {}", events.size, streamId, currentVersion)
|
||||||
return currentVersion
|
return currentVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun appendToStream(event: DomainEvent, streamId: Uuid, expectedVersion: Long): Long {
|
override fun appendToStream(event: DomainEvent, streamId: UUID, expectedVersion: Long): Long {
|
||||||
val currentVersion = getStreamVersion(streamId)
|
logger.debug("Appending single event to stream {} with expected version {}", streamId, expectedVersion)
|
||||||
|
var currentVersion = getStreamVersion(streamId)
|
||||||
|
|
||||||
if (currentVersion != expectedVersion) {
|
if (currentVersion != expectedVersion) {
|
||||||
streamVersionCache.remove(streamId)
|
logger.warn("Version conflict detected for stream {}. Expected: {}, current: {}", streamId, expectedVersion, currentVersion)
|
||||||
val actualVersion = getStreamVersion(streamId)
|
streamVersionCache.remove(streamId) // Invalidate cache on conflict
|
||||||
|
val actualVersion = getStreamVersion(streamId) // Re-fetch from Redis
|
||||||
if (actualVersion != expectedVersion) {
|
if (actualVersion != expectedVersion) {
|
||||||
throw ConcurrencyException("Concurrency conflict: expected version $expectedVersion but got $actualVersion")
|
throw ConcurrencyException("Concurrency conflict for stream $streamId: expected version $expectedVersion but got $actualVersion")
|
||||||
}
|
}
|
||||||
}
|
currentVersion = actualVersion
|
||||||
return appendToStreamInternal(event, streamId, expectedVersion)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun appendToStreamInternal(event: DomainEvent, streamId: Uuid, currentVersion: Long): Long {
|
val newVersion = appendToStreamInternal(event, streamId, currentVersion)
|
||||||
|
logger.info("Successfully appended event to stream {}. New version: {}", streamId, newVersion)
|
||||||
|
return newVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendToStreamInternal(event: DomainEvent, streamId: UUID, currentVersion: Long): Long {
|
||||||
val newVersion = currentVersion + 1
|
val newVersion = currentVersion + 1
|
||||||
require(event.version.value == newVersion) { "Event version ${event.version} does not match expected new version $newVersion" }
|
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 streamKey = getStreamKey(streamId)
|
||||||
val allEventsStreamKey = getAllEventsStreamKey()
|
val allEventsStreamKey = getAllEventsStreamKey()
|
||||||
val eventData = serializer.serialize(event)
|
val eventData = serializer.serialize(event)
|
||||||
|
|
||||||
|
logger.debug("Writing event {} to stream {} and all-events stream atomically", event.eventId, streamId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
redisTemplate.execute(object : SessionCallback<List<Any>> {
|
redisTemplate.execute(object : SessionCallback<List<Any>> {
|
||||||
@Throws(DataAccessException::class)
|
@Throws(DataAccessException::class)
|
||||||
@@ -82,15 +103,16 @@ class RedisEventStore(
|
|||||||
})
|
})
|
||||||
|
|
||||||
streamVersionCache[streamId] = newVersion
|
streamVersionCache[streamId] = newVersion
|
||||||
|
logger.debug("Successfully wrote event {} to Redis streams, updated cache version to {}", event.eventId, newVersion)
|
||||||
return newVersion
|
return newVersion
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("Failed to append event transactionally for stream key: {}", streamKey, e)
|
logger.error("Failed to append event {} transactionally for stream {}: {}", event.eventId, streamId, e.message, e)
|
||||||
streamVersionCache.remove(streamId)
|
streamVersionCache.remove(streamId)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun readFromStream(streamId: Uuid, fromVersion: Long, toVersion: Long?): List<DomainEvent> {
|
override fun readFromStream(streamId: UUID, fromVersion: Long, toVersion: Long?): List<DomainEvent> {
|
||||||
val streamKey = getStreamKey(streamId)
|
val streamKey = getStreamKey(streamId)
|
||||||
val range = Range.of(Range.Bound.inclusive("-"), Range.Bound.unbounded())
|
val range = Range.of(Range.Bound.inclusive("-"), Range.Bound.unbounded())
|
||||||
|
|
||||||
@@ -107,7 +129,7 @@ class RedisEventStore(
|
|||||||
return events.filter { it.version >= EventVersion(fromVersion) && (toVersion == null || it.version <= EventVersion(toVersion)) }
|
return events.filter { it.version >= EventVersion(fromVersion) && (toVersion == null || it.version <= EventVersion(toVersion)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getStreamVersion(streamId: Uuid): Long {
|
override fun getStreamVersion(streamId: UUID): Long {
|
||||||
streamVersionCache[streamId]?.let { return it }
|
streamVersionCache[streamId]?.let { return it }
|
||||||
val streamKey = getStreamKey(streamId)
|
val streamKey = getStreamKey(streamId)
|
||||||
val size = redisTemplate.opsForStream<String, String>().size(streamKey) ?: 0L
|
val size = redisTemplate.opsForStream<String, String>().size(streamKey) ?: 0L
|
||||||
@@ -115,7 +137,7 @@ class RedisEventStore(
|
|||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getStreamKey(streamId: Uuid): String {
|
private fun getStreamKey(streamId: UUID): String {
|
||||||
return "${properties.streamPrefix}$streamId"
|
return "${properties.streamPrefix}$streamId"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,14 +146,59 @@ class RedisEventStore(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun readAllEvents(fromPosition: Long, maxCount: Int?): List<DomainEvent> {
|
override fun readAllEvents(fromPosition: Long, maxCount: Int?): List<DomainEvent> {
|
||||||
TODO("Not yet implemented")
|
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())
|
||||||
|
return if (maxCount != null && maxCount > 0) {
|
||||||
|
filteredEvents.take(maxCount)
|
||||||
|
} else {
|
||||||
|
filteredEvents
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun subscribeToStream(streamId: Uuid, fromVersion: Long, handler: (DomainEvent) -> Unit): Subscription {
|
override fun subscribeToStream(streamId: UUID, fromVersion: Long, handler: (DomainEvent) -> Unit): Subscription {
|
||||||
TODO("Not yet implemented")
|
// Basic implementation - for full functionality, integrate with RedisEventConsumer
|
||||||
|
logger.info("Stream subscription for streamId {} from version {} - basic implementation", streamId, fromVersion)
|
||||||
|
return BasicSubscription {
|
||||||
|
logger.info("Unsubscribed from stream {}", streamId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun subscribeToAll(fromPosition: Long, handler: (DomainEvent) -> Unit): Subscription {
|
override fun subscribeToAll(fromPosition: Long, handler: (DomainEvent) -> Unit): Subscription {
|
||||||
TODO("Not yet implemented")
|
// Basic implementation - for full functionality, integrate with RedisEventConsumer
|
||||||
|
logger.info("All events subscription from position {} - basic implementation", fromPosition)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
+17
-17
@@ -19,23 +19,23 @@ import java.time.Duration
|
|||||||
*/
|
*/
|
||||||
@ConfigurationProperties(prefix = "redis.event-store")
|
@ConfigurationProperties(prefix = "redis.event-store")
|
||||||
data class RedisEventStoreProperties(
|
data class RedisEventStoreProperties(
|
||||||
val host: String = "localhost",
|
var host: String = "localhost",
|
||||||
val port: Int = 6379,
|
var port: Int = 6379,
|
||||||
val password: String? = null,
|
var password: String? = null,
|
||||||
val database: Int = 0,
|
var database: Int = 0,
|
||||||
val connectionTimeout: Long = 2000,
|
var connectionTimeout: Long = 2000,
|
||||||
val readTimeout: Long = 2000,
|
var readTimeout: Long = 2000,
|
||||||
val usePooling: Boolean = true,
|
var usePooling: Boolean = true,
|
||||||
val maxPoolSize: Int = 8,
|
var maxPoolSize: Int = 8,
|
||||||
val minPoolSize: Int = 2,
|
var minPoolSize: Int = 2,
|
||||||
val consumerGroup: String = "event-processors",
|
var consumerGroup: String = "event-processors",
|
||||||
val consumerName: String = "event-consumer",
|
var consumerName: String = "event-consumer",
|
||||||
val streamPrefix: String = "event-stream:",
|
var streamPrefix: String = "event-stream:",
|
||||||
val allEventsStream: String = "all-events",
|
var allEventsStream: String = "all-events",
|
||||||
val claimIdleTimeout: Duration = Duration.ofMinutes(1),
|
var claimIdleTimeout: Duration = Duration.ofMinutes(1),
|
||||||
val pollTimeout: Duration = Duration.ofMillis(100),
|
var pollTimeout: Duration = Duration.ofMillis(100),
|
||||||
val maxBatchSize: Int = 100,
|
var maxBatchSize: Int = 100,
|
||||||
val createConsumerGroupIfNotExists: Boolean = true
|
var createConsumerGroupIfNotExists: Boolean = true
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+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 java.util.*
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.randomUUID()
|
||||||
|
val eventId = UUID.randomUUID()
|
||||||
|
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.randomUUID()
|
||||||
|
val eventId = UUID.randomUUID()
|
||||||
|
val timestamp = Clock.System.now()
|
||||||
|
val correlationId = UUID.randomUUID()
|
||||||
|
|
||||||
|
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.randomUUID()
|
||||||
|
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.randomUUID()
|
||||||
|
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.randomUUID()
|
||||||
|
val eventId = UUID.randomUUID()
|
||||||
|
val timestamp = Clock.System.now()
|
||||||
|
val correlationId = UUID.randomUUID()
|
||||||
|
val causationId = UUID.randomUUID()
|
||||||
|
|
||||||
|
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.randomUUID()
|
||||||
|
|
||||||
|
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.fromString("123e4567-e89b-12d3-a456-426614174000"), aggregateId)
|
||||||
|
assertEquals(UUID.fromString("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.randomUUID()),
|
||||||
|
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.randomUUID()),
|
||||||
|
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.randomUUID()),
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+429
@@ -0,0 +1,429 @@
|
|||||||
|
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.*
|
||||||
|
import java.util.concurrent.*
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
||||||
|
val keys = redisTemplate.keys("${properties.streamPrefix}*")
|
||||||
|
if (!keys.isNullOrEmpty()) {
|
||||||
|
redisTemplate.delete(keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `should handle multiple consumers processing events without conflicts`() {
|
||||||
|
val aggregateId = UUID.randomUUID()
|
||||||
|
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
|
||||||
|
consumer1.pollEvents()
|
||||||
|
consumer2.pollEvents()
|
||||||
|
|
||||||
|
// Wait for processing
|
||||||
|
assertTrue(latch.await(5, TimeUnit.SECONDS), "Events were not processed within timeout")
|
||||||
|
|
||||||
|
// 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.randomUUID()
|
||||||
|
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.randomUUID()
|
||||||
|
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.randomUUID()
|
||||||
|
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(5, 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.randomUUID()
|
||||||
|
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`() {
|
||||||
|
val aggregateId = UUID.randomUUID()
|
||||||
|
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)
|
||||||
|
consumer1.pollEvents()
|
||||||
|
|
||||||
|
// 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.randomUUID()),
|
||||||
|
@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.randomUUID()),
|
||||||
|
@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.randomUUID()),
|
||||||
|
@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 java.util.*
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.randomUUID()
|
||||||
|
|
||||||
|
// 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.randomUUID()
|
||||||
|
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.randomUUID()
|
||||||
|
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.randomUUID().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.randomUUID()
|
||||||
|
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.randomUUID().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.randomUUID()
|
||||||
|
|
||||||
|
// 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.randomUUID()
|
||||||
|
|
||||||
|
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.randomUUID()),
|
||||||
|
@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.randomUUID()),
|
||||||
|
@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.randomUUID()),
|
||||||
|
@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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+7
-8
@@ -5,10 +5,7 @@ import at.mocode.core.domain.event.DomainEvent
|
|||||||
import at.mocode.core.domain.model.*
|
import at.mocode.core.domain.model.*
|
||||||
import at.mocode.infrastructure.eventstore.api.EventSerializer
|
import at.mocode.infrastructure.eventstore.api.EventSerializer
|
||||||
import at.mocode.infrastructure.eventstore.api.EventStore
|
import at.mocode.infrastructure.eventstore.api.EventStore
|
||||||
import com.benasher44.uuid.Uuid
|
|
||||||
import com.benasher44.uuid.uuid4
|
import com.benasher44.uuid.uuid4
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Instant
|
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
@@ -23,6 +20,8 @@ import org.testcontainers.junit.jupiter.Testcontainers
|
|||||||
import org.testcontainers.utility.DockerImageName
|
import org.testcontainers.utility.DockerImageName
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@Testcontainers
|
@Testcontainers
|
||||||
class RedisEventStoreIntegrationTest {
|
class RedisEventStoreIntegrationTest {
|
||||||
@@ -55,12 +54,12 @@ class RedisEventStoreIntegrationTest {
|
|||||||
registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
|
registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
|
||||||
}
|
}
|
||||||
|
|
||||||
properties = RedisEventStoreProperties(
|
properties = RedisEventStoreProperties().apply {
|
||||||
streamPrefix = "test-stream:",
|
streamPrefix = "test-stream:"
|
||||||
allEventsStream = "all-events",
|
allEventsStream = "all-events"
|
||||||
consumerGroup = "test-group",
|
consumerGroup = "test-group"
|
||||||
consumerName = "test-consumer"
|
consumerName = "test-consumer"
|
||||||
)
|
}
|
||||||
|
|
||||||
eventStore = RedisEventStore(redisTemplate, serializer, properties)
|
eventStore = RedisEventStore(redisTemplate, serializer, properties)
|
||||||
eventConsumer = RedisEventConsumer(redisTemplate, serializer, properties)
|
eventConsumer = RedisEventConsumer(redisTemplate, serializer, properties)
|
||||||
|
|||||||
+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 java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.randomUUID()
|
||||||
|
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.randomUUID()
|
||||||
|
val aggregateId2 = UUID.randomUUID()
|
||||||
|
|
||||||
|
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.randomUUID()
|
||||||
|
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.randomUUID()
|
||||||
|
|
||||||
|
// 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.randomUUID()
|
||||||
|
|
||||||
|
// 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.randomUUID()
|
||||||
|
|
||||||
|
// 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.randomUUID()
|
||||||
|
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.randomUUID()
|
||||||
|
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.randomUUID()),
|
||||||
|
@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.randomUUID()),
|
||||||
|
@Transient override val version: EventVersion = EventVersion(0),
|
||||||
|
val threadId: Int,
|
||||||
|
val eventIndex: Int,
|
||||||
|
val data: String
|
||||||
|
) : BaseDomainEvent(aggregateId, EventType("OrderTestEvent"), version)
|
||||||
|
}
|
||||||
+8
-6
@@ -6,7 +6,7 @@ import at.mocode.core.domain.model.EventType
|
|||||||
import at.mocode.core.domain.model.EventVersion
|
import at.mocode.core.domain.model.EventVersion
|
||||||
import at.mocode.infrastructure.eventstore.api.ConcurrencyException
|
import at.mocode.infrastructure.eventstore.api.ConcurrencyException
|
||||||
import at.mocode.infrastructure.eventstore.api.EventSerializer
|
import at.mocode.infrastructure.eventstore.api.EventSerializer
|
||||||
import com.benasher44.uuid.uuid4
|
import java.util.UUID
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
@@ -52,7 +52,9 @@ class RedisEventStoreTest {
|
|||||||
registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
|
registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
|
||||||
}
|
}
|
||||||
|
|
||||||
properties = RedisEventStoreProperties(streamPrefix = "test-stream:")
|
properties = RedisEventStoreProperties().apply {
|
||||||
|
streamPrefix = "test-stream:"
|
||||||
|
}
|
||||||
eventStore = RedisEventStore(redisTemplate, serializer, properties)
|
eventStore = RedisEventStore(redisTemplate, serializer, properties)
|
||||||
cleanupRedis()
|
cleanupRedis()
|
||||||
}
|
}
|
||||||
@@ -69,7 +71,7 @@ class RedisEventStoreTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `append and read events should work correctly for new stream`() {
|
fun `append and read events should work correctly for new stream`() {
|
||||||
val aggregateId = uuid4()
|
val aggregateId = UUID.randomUUID()
|
||||||
val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity")
|
val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity")
|
||||||
val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity")
|
val event2 = TestUpdatedEvent(AggregateId(aggregateId), EventVersion(2L), "Updated Test Entity")
|
||||||
|
|
||||||
@@ -89,7 +91,7 @@ class RedisEventStoreTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `appending with wrong expected version should throw ConcurrencyException`() {
|
fun `appending with wrong expected version should throw ConcurrencyException`() {
|
||||||
val aggregateId = uuid4()
|
val aggregateId = UUID.randomUUID()
|
||||||
val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity")
|
val event1 = TestCreatedEvent(AggregateId(aggregateId), EventVersion(1L), "Test Entity")
|
||||||
eventStore.appendToStream(listOf(event1), aggregateId, 0) // Stream is now at version 1
|
eventStore.appendToStream(listOf(event1), aggregateId, 0) // Stream is now at version 1
|
||||||
|
|
||||||
@@ -101,14 +103,14 @@ class RedisEventStoreTest {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TestCreatedEvent(
|
data class TestCreatedEvent(
|
||||||
@Transient override val aggregateId: AggregateId = AggregateId(uuid4()),
|
@Transient override val aggregateId: AggregateId = AggregateId(UUID.randomUUID()),
|
||||||
@Transient override val version: EventVersion = EventVersion(0),
|
@Transient override val version: EventVersion = EventVersion(0),
|
||||||
val name: String
|
val name: String
|
||||||
) : BaseDomainEvent(aggregateId, EventType("TestCreated"), version)
|
) : BaseDomainEvent(aggregateId, EventType("TestCreated"), version)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TestUpdatedEvent(
|
data class TestUpdatedEvent(
|
||||||
@Transient override val aggregateId: AggregateId = AggregateId(uuid4()),
|
@Transient override val aggregateId: AggregateId = AggregateId(UUID.randomUUID()),
|
||||||
@Transient override val version: EventVersion = EventVersion(0),
|
@Transient override val version: EventVersion = EventVersion(0),
|
||||||
val name: String
|
val name: String
|
||||||
) : BaseDomainEvent(aggregateId, EventType("TestUpdated"), version)
|
) : BaseDomainEvent(aggregateId, EventType("TestUpdated"), version)
|
||||||
|
|||||||
+5
-5
@@ -49,12 +49,12 @@ class RedisIntegrationTest {
|
|||||||
registerEventType(TestCreatedEvent::class.java, "TestCreated")
|
registerEventType(TestCreatedEvent::class.java, "TestCreated")
|
||||||
registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
|
registerEventType(TestUpdatedEvent::class.java, "TestUpdated")
|
||||||
}
|
}
|
||||||
properties = RedisEventStoreProperties(
|
properties = RedisEventStoreProperties().apply {
|
||||||
streamPrefix = "test-stream:",
|
streamPrefix = "test-stream:"
|
||||||
allEventsStream = "all-events",
|
allEventsStream = "all-events"
|
||||||
consumerGroup = "test-group",
|
consumerGroup = "test-group"
|
||||||
consumerName = "test-consumer"
|
consumerName = "test-consumer"
|
||||||
)
|
}
|
||||||
eventStore = RedisEventStore(redisTemplate, serializer, properties)
|
eventStore = RedisEventStore(redisTemplate, serializer, properties)
|
||||||
eventConsumer = RedisEventConsumer(redisTemplate, serializer, properties)
|
eventConsumer = RedisEventConsumer(redisTemplate, serializer, properties)
|
||||||
cleanupRedis()
|
cleanupRedis()
|
||||||
|
|||||||
Reference in New Issue
Block a user