chore(ci): Align GH Workflows with Docker SSoT, new paths; minimal SSoT guard; staticAnalysis (#23)
* chore(MP-21): snapshot pre-refactor state (Epic 1)
* chore(MP-22): scaffold new repo structure, relocate Docker Compose, move frontend/backend modules, update Makefile; add docs mapping and env template
* MP-22 Epic 2: Erfolgreich umgesetzt und verifiziert
* MP-23 Epic 3: Gradle/Build Governance zentralisieren
* MP-23 Epic 3: Gradle/Build Governance zentralisieren
* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt
- ENV Single Source of Truth
- docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
- config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])
- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
- Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
und Start mit -c config_file=/etc/postgresql/postgresql.conf
- Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
- Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
- Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT
- Frontend/DI/Network (MP-23 Grundlage)
- :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
- Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
- Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)
- Build/Gradle & Module-Refs
- settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
- Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
- Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht
- Dockerfiles angepasst
- backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
- backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service
- Static Analysis / Guards
- config/detekt/detekt.yml hinzugefügt
- Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet
- Doku
- docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
- docs/adr/README.md angelegt
BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)
Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`
Refs: MP-22 (Epic 2), MP-23 (Epic 3)
* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt
- ENV Single Source of Truth
- docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
- config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])
- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
- Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
und Start mit -c config_file=/etc/postgresql/postgresql.conf
- Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
- Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
- Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT
- Frontend/DI/Network (MP-23 Grundlage)
- :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
- Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
- Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)
- Build/Gradle & Module-Refs
- settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
- Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
- Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht
- Dockerfiles angepasst
- backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
- backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service
- Static Analysis / Guards
- config/detekt/detekt.yml hinzugefügt
- Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet
- Doku
- docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
- docs/adr/README.md angelegt
BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)
Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`
Refs: MP-22 (Epic 2), MP-23 (Epic 3)
* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt
- ENV Single Source of Truth
- docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
- config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])
- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
- Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
und Start mit -c config_file=/etc/postgresql/postgresql.conf
- Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
- Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
- Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT
- Frontend/DI/Network (MP-23 Grundlage)
- :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
- Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
- Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)
- Build/Gradle & Module-Refs
- settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
- Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
- Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht
- Dockerfiles angepasst
- backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
- backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service
- Static Analysis / Guards
- config/detekt/detekt.yml hinzugefügt
- Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet
- Doku
- docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
- docs/adr/README.md angelegt
BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)
Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`
Refs: MP-22 (Epic 2), MP-23 (Epic 3)
* chore(ci): Workflows an Docker-SSoT & neue Struktur angepasst, minimaler SSoT-Guard
- ssot-guard.yml: Option B (minimal) → `docker compose -f docker/docker-compose.yaml config` als Lint
- integration-tests.yml: `./gradlew staticAnalysis` vor Integrationstests
- docs-kdoc-sync.yml: Dokka-Task Fallback (dokkaGfmAll || dokkaGfm), YouTrack-Sync nur wenn Script vorhanden
- deploy-proxmox.yml: Compose-Pfade auf docker/docker-compose.yaml + `--env-file docker/.env`; Build/Test Schritte vereinheitlicht
- ci-main.yml: SSoT-Skripte per `if: hashFiles(...)` guarded, Compose-Lint Fallback; OpenAPI‑Pfad → backend/gateway; ADR‑Pfade → docs/adr/**; `staticAnalysis` in Build integriert
- youtrack-sync.yml: unverändert (funktional)
Refs: MP-22, MP-23
* chore(ci): Workflows an Docker-SSoT & neue Struktur angepasst, minimaler SSoT-Guard
- ssot-guard.yml: Option B (minimal) → `docker compose -f docker/docker-compose.yaml config` als Lint
- integration-tests.yml: `./gradlew staticAnalysis` vor Integrationstests
- docs-kdoc-sync.yml: Dokka-Task Fallback (dokkaGfmAll || dokkaGfm), YouTrack-Sync nur wenn Script vorhanden
- deploy-proxmox.yml: Compose-Pfade auf docker/docker-compose.yaml + `--env-file docker/.env`; Build/Test Schritte vereinheitlicht
- ci-main.yml: SSoT-Skripte per `if: hashFiles(...)` guarded, Compose-Lint Fallback; OpenAPI‑Pfad → backend/gateway; ADR‑Pfade → docs/adr/**; `staticAnalysis` in Build integriert
- youtrack-sync.yml: unverändert (funktional)
Refs: MP-22, MP-23
* fix(ci): create .env from example before validating compose config
* fix(ci): update ssot-guard filename (.yaml) and sync workflow state
* fixing
* fix(webpack): correct sql.js fallback configuration for webpack 5
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
// Dieses Modul definiert die provider-agnostische Caching-API.
|
||||
// Es enthält nur Interfaces (z.B. `CacheService`) und Datenmodelle,
|
||||
// aber keine konkrete Implementierung.
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinJvm)
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
}
|
||||
}
|
||||
|
||||
// Erlaubt die Verwendung der kotlin.time API im gesamten Modul
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Stellt sicher, dass alle Versionen aus der zentralen BOM kommen.
|
||||
api(platform(projects.platform.platformBom))
|
||||
// Stellt gemeinsame Abhängigkeiten wie Logging bereit und exportiert sie für Konsumenten der API.
|
||||
api(projects.platform.platformDependencies)
|
||||
// Stellt Test-Abhängigkeiten bereit.
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package at.mocode.infrastructure.cache.api
|
||||
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
interface CacheConfiguration {
|
||||
val defaultTtl: Duration?
|
||||
val localCacheMaxSize: Int?
|
||||
val offlineModeEnabled: Boolean
|
||||
val synchronizationInterval: Duration
|
||||
val offlineEntryMaxAge: Duration?
|
||||
val keyPrefix: String
|
||||
val compressionEnabled: Boolean
|
||||
val compressionThreshold: Int
|
||||
}
|
||||
|
||||
data class DefaultCacheConfiguration(
|
||||
override val defaultTtl: Duration? = 1.hours,
|
||||
override val localCacheMaxSize: Int? = 10000,
|
||||
override val offlineModeEnabled: Boolean = true,
|
||||
override val synchronizationInterval: Duration = 5.minutes,
|
||||
override val offlineEntryMaxAge: Duration? = 7.days,
|
||||
override val keyPrefix: String = "",
|
||||
override val compressionEnabled: Boolean = true,
|
||||
override val compressionThreshold: Int = 1024
|
||||
) : CacheConfiguration
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package at.mocode.infrastructure.cache.api
|
||||
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
data class CacheEntry<T : Any>(
|
||||
val key: String,
|
||||
val value: T,
|
||||
val createdAt: Instant = Clock.System.now(),
|
||||
val expiresAt: Instant? = null,
|
||||
val lastModifiedAt: Instant = Clock.System.now(),
|
||||
val isDirty: Boolean = false,
|
||||
val isLocal: Boolean = false
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return expiresAt?.let { it < Clock.System.now() } ?: false
|
||||
}
|
||||
|
||||
fun markDirty(): CacheEntry<T> {
|
||||
return copy(isDirty = true, lastModifiedAt = Clock.System.now())
|
||||
}
|
||||
|
||||
fun markClean(): CacheEntry<T> {
|
||||
return copy(isDirty = false, isLocal = false, lastModifiedAt = Clock.System.now())
|
||||
}
|
||||
|
||||
fun markLocal(): CacheEntry<T> {
|
||||
return copy(isLocal = true, lastModifiedAt = Clock.System.now())
|
||||
}
|
||||
|
||||
fun updateValue(newValue: T): CacheEntry<T> {
|
||||
return copy(value = newValue, lastModifiedAt = Clock.System.now())
|
||||
}
|
||||
|
||||
fun updateExpiration(newExpiresAt: Instant?): CacheEntry<T> {
|
||||
return copy(expiresAt = newExpiresAt, lastModifiedAt = Clock.System.now())
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package at.mocode.infrastructure.cache.api
|
||||
|
||||
/**
|
||||
* Schnittstelle zum Serialisieren und Deserialisieren von Cache-Einträgen.
|
||||
*/
|
||||
interface CacheSerializer {
|
||||
/**
|
||||
* Serialisiert einen Wert zu einem Byte-Array.
|
||||
*
|
||||
* @param value Der zu serialisierende Wert
|
||||
* @return Der serialisierte Wert als Byte-Array
|
||||
*/
|
||||
fun <T : Any> serialize(value: T): ByteArray
|
||||
|
||||
/**
|
||||
* Deserialisiert ein Byte-Array zu einem Wert.
|
||||
*
|
||||
* @param bytes Das zu deserialisierende Byte-Array
|
||||
* @param clazz Die Zielklasse des zu deserialisierenden Werts
|
||||
* @return Der deserialisierte Wert
|
||||
*/
|
||||
fun <T : Any> deserialize(bytes: ByteArray, clazz: Class<T>): T
|
||||
|
||||
/**
|
||||
* Serialisiert einen Cache-Eintrag zu einem Byte-Array.
|
||||
*
|
||||
* @param entry Der zu serialisierende Cache-Eintrag
|
||||
* @return Der serialisierte Cache-Eintrag als Byte-Array
|
||||
*/
|
||||
fun <T : Any> serializeEntry(entry: CacheEntry<T>): ByteArray
|
||||
|
||||
/**
|
||||
* Deserialisiert ein Byte-Array zu einem Cache-Eintrag.
|
||||
*
|
||||
* @param bytes Das zu deserialisierende Byte-Array
|
||||
* @param valueClass Die Klasse des Werts im Cache-Eintrag
|
||||
* @return Der deserialisierte Cache-Eintrag
|
||||
*/
|
||||
fun <T : Any> deserializeEntry(bytes: ByteArray, valueClass: Class<T>): CacheEntry<T>
|
||||
|
||||
/**
|
||||
* Komprimiert ein Byte-Array.
|
||||
*
|
||||
* @param bytes Das zu komprimierende Byte-Array
|
||||
* @return Das komprimierte Byte-Array
|
||||
*/
|
||||
fun compress(bytes: ByteArray): ByteArray
|
||||
|
||||
/**
|
||||
* Dekomprimiert ein Byte-Array.
|
||||
*
|
||||
* @param bytes Das zu dekomprimierende Byte-Array
|
||||
* @return Das dekomprimierte Byte-Array
|
||||
*/
|
||||
fun decompress(bytes: ByteArray): ByteArray
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package at.mocode.infrastructure.cache.api
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
enum class ConnectionState {
|
||||
CONNECTED, DISCONNECTED, RECONNECTING
|
||||
}
|
||||
|
||||
interface ConnectionStatusTracker {
|
||||
fun getConnectionState(): ConnectionState
|
||||
fun getLastStateChangeTime(): Instant
|
||||
fun registerConnectionListener(listener: ConnectionStateListener)
|
||||
fun unregisterConnectionListener(listener: ConnectionStateListener)
|
||||
fun isConnected(): Boolean = getConnectionState() == ConnectionState.CONNECTED
|
||||
}
|
||||
|
||||
interface ConnectionStateListener {
|
||||
fun onConnectionStateChanged(newState: ConnectionState, timestamp: Instant)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package at.mocode.infrastructure.cache.api
|
||||
|
||||
import kotlin.time.Duration
|
||||
|
||||
interface DistributedCache {
|
||||
fun <T : Any> get(key: String, clazz: Class<T>): T?
|
||||
fun <T : Any> set(key: String, value: T, ttl: Duration? = null) // Geändert
|
||||
fun delete(key: String)
|
||||
fun exists(key: String): Boolean
|
||||
fun <T : Any> multiGet(keys: Collection<String>, clazz: Class<T>): Map<String, T>
|
||||
fun <T : Any> multiSet(entries: Map<String, T>, ttl: Duration? = null) // Geändert
|
||||
fun multiDelete(keys: Collection<String>)
|
||||
fun synchronize(keys: Collection<String>? = null)
|
||||
fun markDirty(key: String)
|
||||
fun getDirtyKeys(): Collection<String>
|
||||
fun clear()
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package at.mocode.infrastructure.cache.api
|
||||
|
||||
/**
|
||||
* Kotlin-idiomatische Extension-Funktion, um einen Wert aus dem Cache zu lesen
|
||||
* – mit reified Typen.
|
||||
*
|
||||
* Beispiel: `val user = cache.get<User>("user:123")`
|
||||
*/
|
||||
inline fun <reified T : Any> DistributedCache.get(key: String): T? {
|
||||
return this.get(key, T::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kotlin-idiomatische Extension-Funktion, um mehrere Werte aus dem Cache zu lesen
|
||||
* – mit reified Typen.
|
||||
*
|
||||
* Beispiel: `val users = cache.multiGet<User>(listOf("user:123", "user:124"))`
|
||||
*/
|
||||
inline fun <reified T : Any> DistributedCache.multiGet(keys: Collection<String>): Map<String, T> {
|
||||
return this.multiGet(keys, T::class.java)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Dieses Modul stellt eine konkrete Implementierung der `cache-api`
|
||||
// unter Verwendung von Redis als Caching-Backend bereit.
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinJvm)
|
||||
alias(libs.plugins.kotlinSpring)
|
||||
// Als Bibliothek benötigt dieses Modul das Spring Boot Plugin nicht.
|
||||
alias(libs.plugins.spring.dependencyManagement)
|
||||
}
|
||||
|
||||
// Stellt sicher, dass ein normales JAR gebaut wird (Bibliotheks-Modul).
|
||||
java {
|
||||
withJavadocJar()
|
||||
withSourcesJar()
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Stellt sicher, dass alle Versionen aus der zentralen BOM kommen.
|
||||
api(platform(projects.platform.platformBom))
|
||||
// Implementiert die provider-agnostische Caching-API.
|
||||
implementation(projects.backend.infrastructure.cache.cacheApi)
|
||||
// OPTIMIERUNG: Verwendung des `redis-cache`-Bundles aus libs.versions.toml.
|
||||
// Dieses Bundle enthält Spring Data Redis, Lettuce und Jackson-Module.
|
||||
implementation(libs.bundles.redis.cache)
|
||||
// Stellt alle Test-Abhängigkeiten gebündelt bereit.
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
testImplementation(libs.bundles.testing.jvm)
|
||||
testImplementation(libs.kotlin.test)
|
||||
testImplementation(libs.kotlin.logging.jvm)
|
||||
testImplementation(libs.logback.classic)
|
||||
testImplementation(libs.logback.core)
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package at.mocode.infrastructure.cache.redis
|
||||
|
||||
import at.mocode.infrastructure.cache.api.CacheEntry
|
||||
import at.mocode.infrastructure.cache.api.CacheSerializer
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.Objects
|
||||
import java.util.zip.GZIPInputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
class JacksonCacheSerializer : CacheSerializer {
|
||||
private val objectMapper: ObjectMapper = ObjectMapper().apply {
|
||||
registerModule(KotlinModule.Builder().build())
|
||||
registerModule(JavaTimeModule())
|
||||
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
}
|
||||
|
||||
override fun <T : Any> serialize(value: T): ByteArray {
|
||||
return objectMapper.writeValueAsBytes(value)
|
||||
}
|
||||
|
||||
override fun <T : Any> deserialize(bytes: ByteArray, clazz: Class<T>): T {
|
||||
return objectMapper.readValue(bytes, clazz)
|
||||
}
|
||||
|
||||
override fun <T : Any> serializeEntry(entry: CacheEntry<T>): ByteArray {
|
||||
val wrapper = CacheEntryWrapper(
|
||||
key = entry.key,
|
||||
valueBytes = serialize(entry.value),
|
||||
valueType = entry.value.javaClass.name,
|
||||
createdAt = java.time.Instant.ofEpochMilli(entry.createdAt.toEpochMilliseconds()),
|
||||
expiresAt = entry.expiresAt?.toEpochMilliseconds()?.let { java.time.Instant.ofEpochMilli(it) },
|
||||
lastModifiedAt = java.time.Instant.ofEpochMilli(entry.lastModifiedAt.toEpochMilliseconds()),
|
||||
isDirty = entry.isDirty,
|
||||
isLocal = entry.isLocal
|
||||
)
|
||||
return objectMapper.writeValueAsBytes(wrapper)
|
||||
}
|
||||
|
||||
override fun <T : Any> deserializeEntry(bytes: ByteArray, valueClass: Class<T>): CacheEntry<T> {
|
||||
val wrapper = objectMapper.readValue<CacheEntryWrapper>(bytes)
|
||||
val value = deserialize(wrapper.valueBytes, valueClass)
|
||||
return CacheEntry(
|
||||
key = wrapper.key,
|
||||
value = value,
|
||||
createdAt = Instant.fromEpochMilliseconds(wrapper.createdAt.toEpochMilli()),
|
||||
expiresAt = wrapper.expiresAt?.toEpochMilli()?.let { Instant.fromEpochMilliseconds(it) },
|
||||
lastModifiedAt = Instant.fromEpochMilliseconds(wrapper.lastModifiedAt.toEpochMilli()),
|
||||
isDirty = wrapper.isDirty,
|
||||
isLocal = wrapper.isLocal
|
||||
)
|
||||
}
|
||||
|
||||
override fun compress(bytes: ByteArray): ByteArray {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
GZIPOutputStream(outputStream).use { it.write(bytes) }
|
||||
return outputStream.toByteArray()
|
||||
}
|
||||
|
||||
override fun decompress(bytes: ByteArray): ByteArray {
|
||||
val inputStream = GZIPInputStream(ByteArrayInputStream(bytes))
|
||||
return inputStream.readBytes()
|
||||
}
|
||||
|
||||
private data class CacheEntryWrapper(
|
||||
val key: String,
|
||||
val valueBytes: ByteArray,
|
||||
val valueType: String,
|
||||
val createdAt: java.time.Instant,
|
||||
val expiresAt: java.time.Instant?,
|
||||
val lastModifiedAt: java.time.Instant,
|
||||
val isDirty: Boolean,
|
||||
val isLocal: Boolean
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as CacheEntryWrapper
|
||||
|
||||
if (key != other.key) return false
|
||||
if (!valueBytes.contentEquals(other.valueBytes)) return false
|
||||
if (valueType != other.valueType) return false
|
||||
if (!Objects.equals(createdAt, other.createdAt)) return false
|
||||
if (!Objects.equals(expiresAt, other.expiresAt)) return false
|
||||
if (!Objects.equals(lastModifiedAt, other.lastModifiedAt)) return false
|
||||
if (isDirty != other.isDirty) return false
|
||||
if (isLocal != other.isLocal) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = key.hashCode()
|
||||
result = 31 * result + valueBytes.contentHashCode()
|
||||
result = 31 * result + valueType.hashCode()
|
||||
result = 31 * result + createdAt.hashCode()
|
||||
result = 31 * result + (expiresAt?.hashCode() ?: 0)
|
||||
result = 31 * result + lastModifiedAt.hashCode()
|
||||
result = 31 * result + isDirty.hashCode()
|
||||
result = 31 * result + isLocal.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package at.mocode.infrastructure.cache.redis
|
||||
|
||||
import at.mocode.infrastructure.cache.api.CacheConfiguration
|
||||
import at.mocode.infrastructure.cache.api.CacheSerializer
|
||||
import at.mocode.infrastructure.cache.api.DefaultCacheConfiguration
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory
|
||||
import org.springframework.data.redis.connection.RedisPassword
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
|
||||
import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer
|
||||
|
||||
/**
|
||||
* Redis connection properties.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "redis")
|
||||
data class RedisProperties(
|
||||
val host: String = "localhost",
|
||||
val port: Int = 6379,
|
||||
val password: String? = null,
|
||||
val database: Int = 0,
|
||||
val connectionTimeout: Long = 2000,
|
||||
val readTimeout: Long = 2000,
|
||||
val usePooling: Boolean = true,
|
||||
val maxPoolSize: Int = 8,
|
||||
val minPoolSize: Int = 2
|
||||
)
|
||||
|
||||
/**
|
||||
* Spring configuration for Redis.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(RedisProperties::class)
|
||||
class RedisConfiguration {
|
||||
|
||||
/**
|
||||
* Creates a Redis connection factory.
|
||||
*
|
||||
* @param properties Redis connection properties
|
||||
* @return Redis connection factory
|
||||
*/
|
||||
@Bean
|
||||
fun redisConnectionFactory(properties: RedisProperties): RedisConnectionFactory {
|
||||
val config = RedisStandaloneConfiguration().apply {
|
||||
hostName = properties.host
|
||||
port = properties.port
|
||||
properties.password?.let { password = RedisPassword.of(it) }
|
||||
database = properties.database
|
||||
}
|
||||
|
||||
return LettuceConnectionFactory(config).apply {
|
||||
// Configure connection timeouts
|
||||
afterPropertiesSet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Redis template for byte arrays.
|
||||
*
|
||||
* @param connectionFactory Redis connection factory
|
||||
* @return Redis template
|
||||
*/
|
||||
@Bean
|
||||
fun redisTemplate(
|
||||
@Qualifier("redisConnectionFactory") connectionFactory: RedisConnectionFactory
|
||||
): RedisTemplate<String, ByteArray> {
|
||||
return RedisTemplate<String, ByteArray>().apply {
|
||||
setConnectionFactory(connectionFactory)
|
||||
keySerializer = StringRedisSerializer()
|
||||
// Use default serializer for values (byte arrays)
|
||||
afterPropertiesSet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cache serializer.
|
||||
*
|
||||
* @return Cache serializer
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
fun cacheSerializer(): CacheSerializer {
|
||||
return JacksonCacheSerializer()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a default cache configuration if none is provided.
|
||||
*
|
||||
* @return Cache configuration
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
fun cacheConfiguration(): CacheConfiguration {
|
||||
return DefaultCacheConfiguration()
|
||||
}
|
||||
}
|
||||
+629
@@ -0,0 +1,629 @@
|
||||
package at.mocode.infrastructure.cache.redis
|
||||
|
||||
import at.mocode.infrastructure.cache.api.CacheConfiguration
|
||||
import at.mocode.infrastructure.cache.api.CacheEntry
|
||||
import at.mocode.infrastructure.cache.api.CacheSerializer
|
||||
import at.mocode.infrastructure.cache.api.ConnectionState
|
||||
import at.mocode.infrastructure.cache.api.ConnectionStateListener
|
||||
import at.mocode.infrastructure.cache.api.ConnectionStatusTracker
|
||||
import at.mocode.infrastructure.cache.api.DistributedCache
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.data.redis.RedisConnectionFailureException
|
||||
import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.toJavaDuration
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
class RedisDistributedCache(
|
||||
private val redisTemplate: RedisTemplate<String, ByteArray>,
|
||||
private val serializer: CacheSerializer,
|
||||
private val config: CacheConfiguration
|
||||
) : DistributedCache, ConnectionStatusTracker {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RedisDistributedCache::class.java)
|
||||
|
||||
// Local cache for offline capability
|
||||
private val localCache = ConcurrentHashMap<String, CacheEntry<Any>>()
|
||||
|
||||
// Set of keys that have been modified locally and need to be synchronized
|
||||
private val dirtyKeys = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
// Connection state
|
||||
private var connectionState = ConnectionState.DISCONNECTED
|
||||
|
||||
private var lastStateChangeTime = Clock.System.now()
|
||||
|
||||
// Connection state listeners
|
||||
private val connectionListeners = CopyOnWriteArrayList<ConnectionStateListener>()
|
||||
|
||||
// Performance metrics tracking
|
||||
private var totalOperations = 0L
|
||||
private var successfulOperations = 0L
|
||||
private var lastMetricsLogTime = Clock.System.now()
|
||||
|
||||
init {
|
||||
// Try to connect to Redis
|
||||
checkConnection()
|
||||
}
|
||||
|
||||
override fun <T : Any> get(key: String, clazz: Class<T>): T? {
|
||||
val prefixedKey = addPrefix(key)
|
||||
|
||||
// Try to get from the local cache first
|
||||
val localEntry = localCache[prefixedKey] as? CacheEntry<*>
|
||||
if (localEntry != null) {
|
||||
if (localEntry.isExpired()) {
|
||||
localCache.remove(prefixedKey)
|
||||
return null
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return localEntry.value as T?
|
||||
}
|
||||
|
||||
// If not in the local cache, and we're disconnected, return null
|
||||
if (!isConnected()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Try to get from Redis
|
||||
try {
|
||||
val bytes = redisTemplate.opsForValue().get(prefixedKey) ?: run {
|
||||
trackOperation(true) // successful operation, just no data
|
||||
return null
|
||||
}
|
||||
val entry = serializer.deserializeEntry(bytes, clazz)
|
||||
|
||||
// Store in a local cache
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
localCache[prefixedKey] = entry as CacheEntry<Any>
|
||||
enforceLocalCacheSize()
|
||||
|
||||
trackOperation(true)
|
||||
return entry.value
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
trackOperation(false)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error getting value from Redis for key $prefixedKey", e)
|
||||
trackOperation(false)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun <T : Any> set(key: String, value: T, ttl: Duration?) {
|
||||
val prefixedKey = addPrefix(key)
|
||||
// KORREKTUR: Logik verwendet jetzt kotlin.time
|
||||
val expiresAt = ttl?.let { Clock.System.now() + it } ?: config.defaultTtl?.let { Clock.System.now() + it }
|
||||
|
||||
val entry = CacheEntry(
|
||||
key = prefixedKey,
|
||||
value = value,
|
||||
expiresAt = expiresAt
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
localCache[prefixedKey] = entry as CacheEntry<Any>
|
||||
enforceLocalCacheSize()
|
||||
|
||||
if (!isConnected()) {
|
||||
markDirty(key)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val bytes = serializer.serializeEntry(entry)
|
||||
val effectiveTtl = ttl ?: config.defaultTtl
|
||||
if (effectiveTtl != null) {
|
||||
// KORREKTUR: Konvertierung zu java.time.Duration für RedisTemplate
|
||||
redisTemplate.opsForValue().set(prefixedKey, bytes, effectiveTtl.toJavaDuration())
|
||||
} else {
|
||||
redisTemplate.opsForValue().set(prefixedKey, bytes)
|
||||
}
|
||||
trackOperation(true)
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
markDirty(key)
|
||||
trackOperation(false)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error setting value in Redis for key $prefixedKey", e)
|
||||
markDirty(key)
|
||||
trackOperation(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(key: String) {
|
||||
val prefixedKey = addPrefix(key)
|
||||
|
||||
// Remove from the local cache
|
||||
localCache.remove(prefixedKey)
|
||||
|
||||
// If we're disconnected, mark as dirty and return
|
||||
if (!isConnected()) {
|
||||
markDirty(key)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to delete from Redis
|
||||
try {
|
||||
redisTemplate.delete(prefixedKey)
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
markDirty(key)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error deleting value from Redis for key $prefixedKey", e)
|
||||
markDirty(key)
|
||||
}
|
||||
}
|
||||
|
||||
override fun exists(key: String): Boolean {
|
||||
val prefixedKey = addPrefix(key)
|
||||
|
||||
// Check the local cache first
|
||||
if (localCache.containsKey(prefixedKey)) {
|
||||
val entry = localCache[prefixedKey]
|
||||
if (entry != null && !entry.isExpired()) {
|
||||
return true
|
||||
}
|
||||
// Remove expired entry
|
||||
localCache.remove(prefixedKey)
|
||||
}
|
||||
|
||||
// If we're disconnected, return false
|
||||
if (!isConnected()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check Redis
|
||||
try {
|
||||
return redisTemplate.hasKey(prefixedKey) ?: false
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
return false
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error checking if key exists in Redis for key $prefixedKey", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override fun <T : Any> multiGet(keys: Collection<String>, clazz: Class<T>): Map<String, T> {
|
||||
val result = mutableMapOf<String, T>()
|
||||
|
||||
// Get from the local cache first
|
||||
val prefixedKeys = keys.map { addPrefix(it) }
|
||||
val localEntries = prefixedKeys.mapNotNull { key ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val entry = localCache[key] as? CacheEntry<T>
|
||||
if (entry != null && !entry.isExpired()) {
|
||||
key to entry.value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
result.putAll(localEntries.mapKeys { removePrefix(it.key) })
|
||||
|
||||
// If we're disconnected, return local entries
|
||||
if (!isConnected()) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Get missing keys from Redis
|
||||
val missingKeys = prefixedKeys.filter { !localEntries.containsKey(it) }
|
||||
if (missingKeys.isEmpty()) {
|
||||
return result
|
||||
}
|
||||
|
||||
try {
|
||||
val redisEntries = redisTemplate.opsForValue().multiGet(missingKeys)
|
||||
if (redisEntries != null) {
|
||||
for (i in missingKeys.indices) {
|
||||
val key = missingKeys[i]
|
||||
val bytes = redisEntries[i]
|
||||
if (bytes != null) {
|
||||
try {
|
||||
val entry = serializer.deserializeEntry(bytes, clazz)
|
||||
|
||||
// Store in a local cache
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
localCache[key] = entry as CacheEntry<Any>
|
||||
enforceLocalCacheSize()
|
||||
|
||||
// Add to result
|
||||
result[removePrefix(key)] = entry.value
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error deserializing entry for key $key", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error getting multiple values from Redis", e)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ... (multiSet ebenfalls anpassen)
|
||||
override fun <T : Any> multiSet(entries: Map<String, T>, ttl: Duration?) {
|
||||
val redisBatch = mutableMapOf<String, ByteArray>()
|
||||
val expiresAt = ttl?.let { Clock.System.now() + it } ?: config.defaultTtl?.let { Clock.System.now() + it }
|
||||
|
||||
for ((key, value) in entries) {
|
||||
val prefixedKey = addPrefix(key)
|
||||
val entry = CacheEntry(
|
||||
key = prefixedKey,
|
||||
value = value,
|
||||
expiresAt = expiresAt
|
||||
)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
localCache[prefixedKey] = entry as CacheEntry<Any>
|
||||
enforceLocalCacheSize()
|
||||
redisBatch[prefixedKey] = serializer.serializeEntry(entry)
|
||||
}
|
||||
|
||||
if (!isConnected()) {
|
||||
entries.keys.forEach { markDirty(it) }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
redisTemplate.opsForValue().multiSet(redisBatch)
|
||||
val effectiveTtl = ttl ?: config.defaultTtl
|
||||
if (effectiveTtl != null) {
|
||||
redisTemplate.executePipelined { connection ->
|
||||
redisBatch.keys.forEach { key ->
|
||||
connection.keyCommands().pExpire(key.toByteArray(), effectiveTtl.inWholeMilliseconds)
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
entries.keys.forEach { markDirty(it) }
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error setting multiple values in Redis", e)
|
||||
entries.keys.forEach { markDirty(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun multiDelete(keys: Collection<String>) {
|
||||
val prefixedKeys = keys.map { addPrefix(it) }
|
||||
|
||||
// Remove from the local cache
|
||||
prefixedKeys.forEach { localCache.remove(it) }
|
||||
|
||||
// If we're disconnected, mark all as dirty and return
|
||||
if (!isConnected()) {
|
||||
keys.forEach { markDirty(it) }
|
||||
return
|
||||
}
|
||||
|
||||
// Try to delete from Redis
|
||||
try {
|
||||
redisTemplate.delete(prefixedKeys)
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
keys.forEach { markDirty(it) }
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error deleting multiple values from Redis", e)
|
||||
keys.forEach { markDirty(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun synchronize(keys: Collection<String>?) {
|
||||
if (!isConnected()) {
|
||||
logger.debug("Cannot synchronize while disconnected")
|
||||
return
|
||||
}
|
||||
|
||||
val keysToSync = keys ?: getDirtyKeys()
|
||||
if (keysToSync.isEmpty()) {
|
||||
logger.debug("No keys to synchronize")
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("Synchronizing ${keysToSync.size} keys")
|
||||
|
||||
for (key in keysToSync) {
|
||||
val prefixedKey = addPrefix(key)
|
||||
val localEntry = localCache[prefixedKey]
|
||||
|
||||
if (localEntry == null) {
|
||||
// Entry was deleted locally, delete from Redis
|
||||
try {
|
||||
redisTemplate.delete(prefixedKey)
|
||||
dirtyKeys.remove(key)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error deleting key $prefixedKey during synchronization", e)
|
||||
}
|
||||
} else {
|
||||
// Entry exists locally, update in Redis
|
||||
try {
|
||||
val bytes = serializer.serializeEntry(localEntry)
|
||||
|
||||
// Die 'set'-Methode erwartet kein TTL-Argument hier
|
||||
redisTemplate.opsForValue().set(prefixedKey, bytes)
|
||||
|
||||
// So wird die Dauer zwischen zwei Instants berechnet
|
||||
val ttl = localEntry.expiresAt?.let { it - Clock.System.now() }
|
||||
|
||||
// 'isNegative' wird zu '< Duration.ZERO'
|
||||
if (ttl != null && ttl > Duration.ZERO) {
|
||||
// KORREKTUR: 'expire' braucht eine java.time.Duration
|
||||
redisTemplate.expire(prefixedKey, ttl.toJavaDuration())
|
||||
}
|
||||
|
||||
// Update local entry to mark as clean
|
||||
localCache[prefixedKey] = localEntry.markClean()
|
||||
dirtyKeys.remove(key)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error updating key $prefixedKey during synchronization", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun markDirty(key: String) {
|
||||
dirtyKeys.add(key)
|
||||
|
||||
val prefixedKey = addPrefix(key)
|
||||
val entry = localCache[prefixedKey]
|
||||
if (entry != null) {
|
||||
localCache[prefixedKey] = entry.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDirtyKeys(): Collection<String> {
|
||||
return dirtyKeys.toList()
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
// Clear local cache
|
||||
localCache.clear()
|
||||
dirtyKeys.clear()
|
||||
|
||||
// If we're disconnected, return
|
||||
if (!isConnected()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to clear Redis
|
||||
try {
|
||||
val keys = redisTemplate.keys("${config.keyPrefix}*")
|
||||
if (keys != null && keys.isNotEmpty()) {
|
||||
redisTemplate.delete(keys)
|
||||
}
|
||||
} catch (e: RedisConnectionFailureException) {
|
||||
handleConnectionFailure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error clearing Redis cache", e)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// ConnectionStatusTracker implementation
|
||||
//
|
||||
|
||||
override fun getConnectionState(): ConnectionState {
|
||||
return connectionState
|
||||
}
|
||||
|
||||
override fun getLastStateChangeTime(): Instant {
|
||||
return lastStateChangeTime
|
||||
}
|
||||
|
||||
override fun registerConnectionListener(listener: ConnectionStateListener) {
|
||||
connectionListeners.add(listener)
|
||||
}
|
||||
|
||||
override fun unregisterConnectionListener(listener: ConnectionStateListener) {
|
||||
connectionListeners.remove(listener)
|
||||
}
|
||||
|
||||
//
|
||||
// Helper methods
|
||||
//
|
||||
|
||||
private fun addPrefix(key: String): String {
|
||||
return if (config.keyPrefix.isEmpty()) key else "${config.keyPrefix}:$key"
|
||||
}
|
||||
|
||||
private fun removePrefix(key: String): String {
|
||||
return if (config.keyPrefix.isEmpty()) key else key.substring(config.keyPrefix.length + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzwingt die maximale Größe des lokalen Caches, indem die am längsten nicht
|
||||
* mehr modifizierten Einträge entfernt werden.
|
||||
*/
|
||||
private fun enforceLocalCacheSize() {
|
||||
val max = config.localCacheMaxSize ?: return
|
||||
val overflow = localCache.size - max
|
||||
if (overflow <= 0) return
|
||||
val toEvict = localCache.entries
|
||||
.sortedBy { it.value.lastModifiedAt }
|
||||
.take(overflow)
|
||||
.map { it.key }
|
||||
toEvict.forEach { localCache.remove(it) }
|
||||
logger.debug("Evicted ${toEvict.size} entries to enforce local cache size limit $max")
|
||||
}
|
||||
|
||||
private fun handleConnectionFailure(e: Exception) {
|
||||
logger.warn("Redis connection failure: ${e.message}")
|
||||
setConnectionState(ConnectionState.DISCONNECTED)
|
||||
}
|
||||
|
||||
private fun setConnectionState(newState: ConnectionState) {
|
||||
if (connectionState != newState) {
|
||||
val oldState = connectionState
|
||||
connectionState = newState
|
||||
lastStateChangeTime = Clock.System.now()
|
||||
|
||||
logger.info("Cache connection state changed from $oldState to $newState")
|
||||
|
||||
// Notify listeners
|
||||
val timestamp = lastStateChangeTime
|
||||
connectionListeners.forEach { listener ->
|
||||
try {
|
||||
listener.onConnectionStateChanged(newState, timestamp)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error notifying connection listener", e)
|
||||
}
|
||||
}
|
||||
|
||||
// If reconnected, synchronize dirty keys
|
||||
if (oldState != ConnectionState.CONNECTED && newState == ConnectionState.CONNECTED) {
|
||||
synchronize(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft periodisch die Verbindung zu Redis.
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "\${redis.connection-check-interval:10000}")
|
||||
fun checkConnection() {
|
||||
try {
|
||||
redisTemplate.hasKey("connection-test")
|
||||
setConnectionState(ConnectionState.CONNECTED)
|
||||
} catch (_: Exception) {
|
||||
setConnectionState(ConnectionState.DISCONNECTED)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereinigt periodisch abgelaufene Einträge aus dem lokalen Cache.
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "\${redis.local-cache-cleanup-interval:60000}")
|
||||
fun cleanupLocalCache() {
|
||||
val now = Clock.System.now()
|
||||
val expiredKeys = localCache.entries
|
||||
.filter { it.value.expiresAt?.let { exp -> exp < now } ?: false }
|
||||
.map { it.key }
|
||||
|
||||
expiredKeys.forEach { localCache.remove(it) }
|
||||
|
||||
if (expiredKeys.isNotEmpty()) {
|
||||
logger.debug("Removed ${expiredKeys.size} expired entries from local cache")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronisiert periodisch schmutzige Schlüssel, sobald verbunden.
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "\${redis.sync-interval:300000}")
|
||||
fun scheduledSync() {
|
||||
if (isConnected() && dirtyKeys.isNotEmpty()) {
|
||||
synchronize(null)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Performance monitoring and optimization methods
|
||||
//
|
||||
|
||||
/**
|
||||
* Zeichnet eine Cache-Operation für Metriken auf.
|
||||
*/
|
||||
private fun trackOperation(success: Boolean) {
|
||||
synchronized(this) {
|
||||
totalOperations++
|
||||
if (success) successfulOperations++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert aktuelle Performance-Metriken.
|
||||
*/
|
||||
fun getPerformanceMetrics(): Map<String, Any> {
|
||||
val now = Clock.System.now()
|
||||
val successRate = if (totalOperations > 0) {
|
||||
(successfulOperations.toDouble() / totalOperations.toDouble()) * 100.0
|
||||
} else 0.0
|
||||
|
||||
return mapOf(
|
||||
"totalOperations" to totalOperations,
|
||||
"successfulOperations" to successfulOperations,
|
||||
"successRate" to String.format("%.1f%%", successRate),
|
||||
"dirtyKeysCount" to dirtyKeys.size,
|
||||
"localCacheSize" to localCache.size,
|
||||
"connectionState" to connectionState.name,
|
||||
"lastStateChangeTime" to lastStateChangeTime,
|
||||
"uptimeSinceLastMetrics" to (now - lastMetricsLogTime)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loggt Performance-Metriken (periodisch aufgerufen).
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "\${redis.metrics-log-interval:300000}")
|
||||
fun logPerformanceMetrics() {
|
||||
val metrics = getPerformanceMetrics()
|
||||
logger.info("Cache performance metrics: $metrics")
|
||||
lastMetricsLogTime = Clock.System.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-Warming-Helfer – lädt angegebene Schlüssel vor.
|
||||
*/
|
||||
fun warmCache(keys: Collection<String>, dataLoader: (String) -> Any?) {
|
||||
logger.info("Starting cache warming for ${keys.size} keys")
|
||||
var warmedCount = 0
|
||||
val startTime = Clock.System.now()
|
||||
|
||||
keys.forEach { key ->
|
||||
if (!exists(key)) {
|
||||
val data = dataLoader(key)
|
||||
if (data != null) {
|
||||
set(key, data, config.defaultTtl)
|
||||
warmedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val duration = Clock.System.now() - startTime
|
||||
logger.info("Cache warming completed: $warmedCount/${keys.size} keys loaded in $duration")
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-Cache-Warming mit Batch-Operationen.
|
||||
*/
|
||||
fun warmCacheBulk(keyDataMap: Map<String, Any>, ttl: Duration? = null) {
|
||||
logger.info("Starting bulk cache warming for ${keyDataMap.size} entries")
|
||||
val startTime = Clock.System.now()
|
||||
|
||||
multiSet(keyDataMap, ttl ?: config.defaultTtl)
|
||||
|
||||
val duration = Clock.System.now() - startTime
|
||||
logger.info("Bulk cache warming completed: ${keyDataMap.size} entries loaded in $duration")
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den Cache-Gesundheitsstatus.
|
||||
*/
|
||||
fun getHealthStatus(): Map<String, Any> {
|
||||
val metrics = getPerformanceMetrics()
|
||||
val successRate = metrics["successRate"] as String
|
||||
val successRateValue = successRate.replace("%", "").toDoubleOrNull() ?: 0.0
|
||||
|
||||
return mapOf(
|
||||
"healthy" to (connectionState == ConnectionState.CONNECTED && successRateValue >= 90.0),
|
||||
"connectionState" to connectionState.name,
|
||||
"successRate" to successRate,
|
||||
"localCacheUtilization" to if (config.localCacheMaxSize != null) {
|
||||
"${localCache.size}/${config.localCacheMaxSize}"
|
||||
} else "${localCache.size}/unlimited",
|
||||
"dirtyKeysCount" to dirtyKeys.size,
|
||||
"lastHealthCheck" to Clock.System.now()
|
||||
)
|
||||
}
|
||||
}
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
package at.mocode.infrastructure.cache.redis
|
||||
|
||||
import at.mocode.infrastructure.cache.api.CacheSerializer
|
||||
import at.mocode.infrastructure.cache.api.DefaultCacheConfiguration
|
||||
import at.mocode.infrastructure.cache.api.get
|
||||
import at.mocode.infrastructure.cache.api.multiGet
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
|
||||
import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer
|
||||
import org.testcontainers.containers.GenericContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import org.testcontainers.utility.DockerImageName
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
/**
|
||||
* Configuration Tests for RedisDistributedCache
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Testcontainers
|
||||
class RedisDistributedCacheConfigurationTest {
|
||||
|
||||
companion object {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Container
|
||||
val redisContainer = GenericContainer<Nothing>(
|
||||
DockerImageName.parse("redis:7-alpine")
|
||||
.asCompatibleSubstituteFor("redis")
|
||||
).apply {
|
||||
withExposedPorts(6379)
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var redisTemplate: RedisTemplate<String, ByteArray>
|
||||
private lateinit var serializer: CacheSerializer
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
val redisPort = redisContainer.getMappedPort(6379)
|
||||
val redisHost = redisContainer.host
|
||||
|
||||
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
|
||||
val connectionFactory = LettuceConnectionFactory(redisConfig)
|
||||
connectionFactory.afterPropertiesSet()
|
||||
|
||||
redisTemplate = RedisTemplate<String, ByteArray>().apply {
|
||||
setConnectionFactory(connectionFactory)
|
||||
keySerializer = StringRedisSerializer()
|
||||
afterPropertiesSet()
|
||||
}
|
||||
|
||||
serializer = JacksonCacheSerializer()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test different cache configurations`() {
|
||||
logger.info { "Testing different cache configurations" }
|
||||
|
||||
// Configuration 1: High performance, short TTL
|
||||
val performanceConfig = DefaultCacheConfiguration(
|
||||
keyPrefix = "perf",
|
||||
defaultTtl = 5.minutes,
|
||||
localCacheMaxSize = 50000,
|
||||
offlineModeEnabled = true,
|
||||
synchronizationInterval = 30.seconds,
|
||||
offlineEntryMaxAge = 1.hours,
|
||||
compressionEnabled = false,
|
||||
compressionThreshold = Int.MAX_VALUE
|
||||
)
|
||||
|
||||
val performanceCache = RedisDistributedCache(redisTemplate, serializer, performanceConfig)
|
||||
performanceCache.clear()
|
||||
|
||||
// Test performance config
|
||||
performanceCache.set("perf-test", "performance-value")
|
||||
assertEquals("performance-value", performanceCache.get<String>("perf-test"))
|
||||
assertTrue(performanceCache.exists("perf-test"))
|
||||
|
||||
logger.info { "Performance configuration works correctly" }
|
||||
|
||||
// Configuration 2: Storage optimized, long TTL, compression enabled
|
||||
val storageConfig = DefaultCacheConfiguration(
|
||||
keyPrefix = "storage",
|
||||
defaultTtl = 7.days,
|
||||
localCacheMaxSize = 1000,
|
||||
offlineModeEnabled = true,
|
||||
synchronizationInterval = 5.minutes,
|
||||
offlineEntryMaxAge = 24.hours,
|
||||
compressionEnabled = true,
|
||||
compressionThreshold = 100
|
||||
)
|
||||
|
||||
val storageCache = RedisDistributedCache(redisTemplate, serializer, storageConfig)
|
||||
storageCache.clear()
|
||||
|
||||
// Test storage config with large data (should be compressed)
|
||||
val largeData = "Large data content: " + "X".repeat(1000)
|
||||
storageCache.set("storage-test", largeData)
|
||||
assertEquals(largeData, storageCache.get<String>("storage-test"))
|
||||
|
||||
logger.info { "Storage optimized configuration works correctly" }
|
||||
|
||||
// Configuration 3: Minimal configuration
|
||||
val minimalConfig = DefaultCacheConfiguration(
|
||||
keyPrefix = "minimal",
|
||||
defaultTtl = null, // No TTL
|
||||
localCacheMaxSize = null, // No limit
|
||||
offlineModeEnabled = false,
|
||||
synchronizationInterval = 1.minutes,
|
||||
offlineEntryMaxAge = null,
|
||||
compressionEnabled = false,
|
||||
compressionThreshold = Int.MAX_VALUE
|
||||
)
|
||||
|
||||
val minimalCache = RedisDistributedCache(redisTemplate, serializer, minimalConfig)
|
||||
minimalCache.clear()
|
||||
|
||||
// Test minimal config
|
||||
minimalCache.set("minimal-test", "minimal-value")
|
||||
assertEquals("minimal-value", minimalCache.get<String>("minimal-test"))
|
||||
|
||||
logger.info { "Minimal configuration works correctly" }
|
||||
|
||||
// Clean up
|
||||
performanceCache.clear()
|
||||
storageCache.clear()
|
||||
minimalCache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test compression threshold behavior`() {
|
||||
logger.info { "Testing compression threshold behavior" }
|
||||
|
||||
// Configuration with low compression threshold
|
||||
val compressionConfig = DefaultCacheConfiguration(
|
||||
keyPrefix = "compression-test",
|
||||
defaultTtl = 30.minutes,
|
||||
compressionEnabled = true,
|
||||
compressionThreshold = 50 // Very low threshold
|
||||
)
|
||||
|
||||
val compressionCache = RedisDistributedCache(redisTemplate, serializer, compressionConfig)
|
||||
compressionCache.clear()
|
||||
|
||||
// Test small data (below threshold) - should not be compressed
|
||||
val smallData = "Small"
|
||||
compressionCache.set("small-data", smallData)
|
||||
assertEquals(smallData, compressionCache.get<String>("small-data"))
|
||||
|
||||
// Test large data (above threshold) - should be compressed
|
||||
val largeData = "A".repeat(200) // Well above threshold
|
||||
compressionCache.set("large-data", largeData)
|
||||
val retrievedLarge = compressionCache.get<String>("large-data")
|
||||
assertEquals(largeData, retrievedLarge)
|
||||
assertEquals(200, retrievedLarge?.length)
|
||||
|
||||
logger.info { "Small data length: ${smallData.length}" }
|
||||
logger.info { "Large data length: ${largeData.length}" }
|
||||
logger.info { "Compression threshold: ${compressionConfig.compressionThreshold}" }
|
||||
|
||||
// Test medium data (right at threshold)
|
||||
val mediumData = "B".repeat(50) // Exactly at threshold
|
||||
compressionCache.set("medium-data", mediumData)
|
||||
assertEquals(mediumData, compressionCache.get<String>("medium-data"))
|
||||
|
||||
logger.info { "Compression threshold behavior validated" }
|
||||
|
||||
compressionCache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test key prefix functionality`() {
|
||||
logger.info { "Testing key prefix functionality" }
|
||||
|
||||
// Create caches with different prefixes
|
||||
val config1 = DefaultCacheConfiguration(keyPrefix = "app1", defaultTtl = 30.minutes)
|
||||
val config2 = DefaultCacheConfiguration(keyPrefix = "app2", defaultTtl = 30.minutes)
|
||||
val config3 = DefaultCacheConfiguration(keyPrefix = "", defaultTtl = 30.minutes) // No prefix
|
||||
|
||||
val cache1 = RedisDistributedCache(redisTemplate, serializer, config1)
|
||||
val cache2 = RedisDistributedCache(redisTemplate, serializer, config2)
|
||||
val cache3 = RedisDistributedCache(redisTemplate, serializer, config3)
|
||||
|
||||
// Clear all caches
|
||||
cache1.clear()
|
||||
cache2.clear()
|
||||
cache3.clear()
|
||||
|
||||
// Store same key in all caches with different values
|
||||
val testKey = "shared-key"
|
||||
cache1.set(testKey, "value-from-app1")
|
||||
cache2.set(testKey, "value-from-app2")
|
||||
cache3.set(testKey, "value-from-no-prefix")
|
||||
|
||||
// Verify each cache returns its own value (thanks to prefixes)
|
||||
assertEquals("value-from-app1", cache1.get<String>(testKey))
|
||||
assertEquals("value-from-app2", cache2.get<String>(testKey))
|
||||
assertEquals("value-from-no-prefix", cache3.get<String>(testKey))
|
||||
|
||||
// Verify isolation - keys don't exist in other caches
|
||||
assertTrue(cache1.exists(testKey))
|
||||
assertTrue(cache2.exists(testKey))
|
||||
assertTrue(cache3.exists(testKey))
|
||||
|
||||
logger.info { "Key prefix isolation works correctly" }
|
||||
|
||||
// Test batch operations with prefixes
|
||||
val batchData = mapOf(
|
||||
"batch1" to "batch-value-1",
|
||||
"batch2" to "batch-value-2"
|
||||
)
|
||||
|
||||
cache1.multiSet(batchData)
|
||||
cache2.multiSet(batchData.mapValues { "${it.value}-app2" })
|
||||
|
||||
val retrieved1 = cache1.multiGet<String>(batchData.keys)
|
||||
val retrieved2 = cache2.multiGet<String>(batchData.keys)
|
||||
|
||||
assertEquals("batch-value-1", retrieved1["batch1"])
|
||||
assertEquals("batch-value-1-app2", retrieved2["batch1"])
|
||||
|
||||
logger.info { "Batch operations with prefixes work correctly" }
|
||||
|
||||
// Clean up
|
||||
cache1.clear()
|
||||
cache2.clear()
|
||||
cache3.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test TTL configuration variations`() {
|
||||
logger.info { "Testing TTL configuration variations" }
|
||||
|
||||
// Configuration with no default TTL
|
||||
val noTtlConfig = DefaultCacheConfiguration(
|
||||
keyPrefix = "no-ttl-test",
|
||||
defaultTtl = null
|
||||
)
|
||||
|
||||
val noTtlCache = RedisDistributedCache(redisTemplate, serializer, noTtlConfig)
|
||||
noTtlCache.clear()
|
||||
|
||||
// Store without TTL - should persist indefinitely
|
||||
noTtlCache.set("persistent-key", "persistent-value")
|
||||
assertEquals("persistent-value", noTtlCache.get<String>("persistent-key"))
|
||||
|
||||
// Store with explicit TTL - should override default (which is null)
|
||||
noTtlCache.set("explicit-ttl-key", "explicit-ttl-value", 100.milliseconds)
|
||||
assertEquals("explicit-ttl-value", noTtlCache.get<String>("explicit-ttl-key"))
|
||||
|
||||
Thread.sleep(200)
|
||||
assertFalse(noTtlCache.exists("explicit-ttl-key"))
|
||||
|
||||
// Configuration with short default TTL
|
||||
val shortTtlConfig = DefaultCacheConfiguration(
|
||||
keyPrefix = "short-ttl-test",
|
||||
defaultTtl = 100.milliseconds
|
||||
)
|
||||
|
||||
val shortTtlCache = RedisDistributedCache(redisTemplate, serializer, shortTtlConfig)
|
||||
shortTtlCache.clear()
|
||||
|
||||
// Store with default TTL
|
||||
shortTtlCache.set("default-ttl-key", "default-ttl-value")
|
||||
assertEquals("default-ttl-value", shortTtlCache.get<String>("default-ttl-key"))
|
||||
|
||||
Thread.sleep(200)
|
||||
assertFalse(shortTtlCache.exists("default-ttl-key"))
|
||||
|
||||
// Store with explicit longer TTL - should override default
|
||||
shortTtlCache.set("override-ttl-key", "override-ttl-value", 30.minutes)
|
||||
assertEquals("override-ttl-value", shortTtlCache.get<String>("override-ttl-key"))
|
||||
// Should still exist after short default TTL
|
||||
assertTrue(shortTtlCache.exists("override-ttl-key"))
|
||||
|
||||
logger.info { "TTL configurations work correctly" }
|
||||
|
||||
noTtlCache.clear()
|
||||
shortTtlCache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test offline mode configuration`() {
|
||||
logger.info { "Testing offline mode configuration" }
|
||||
|
||||
// Configuration with offline mode disabled
|
||||
val noOfflineConfig = DefaultCacheConfiguration(
|
||||
keyPrefix = "no-offline-test",
|
||||
defaultTtl = 30.minutes,
|
||||
offlineModeEnabled = false
|
||||
)
|
||||
|
||||
val noOfflineCache = RedisDistributedCache(redisTemplate, serializer, noOfflineConfig)
|
||||
noOfflineCache.clear()
|
||||
|
||||
// Normal operations should work
|
||||
noOfflineCache.set("online-key", "online-value")
|
||||
assertEquals("online-value", noOfflineCache.get<String>("online-key"))
|
||||
|
||||
// Configuration with offline mode enabled and specific settings
|
||||
val offlineConfig = DefaultCacheConfiguration(
|
||||
keyPrefix = "offline-test",
|
||||
defaultTtl = 30.minutes,
|
||||
offlineModeEnabled = true,
|
||||
localCacheMaxSize = 1000,
|
||||
synchronizationInterval = 10.seconds,
|
||||
offlineEntryMaxAge = 2.hours
|
||||
)
|
||||
|
||||
val offlineCache = RedisDistributedCache(redisTemplate, serializer, offlineConfig)
|
||||
offlineCache.clear()
|
||||
|
||||
// Test offline capabilities
|
||||
offlineCache.set("offline-key", "offline-value")
|
||||
assertEquals("offline-value", offlineCache.get<String>("offline-key"))
|
||||
|
||||
logger.info { "Offline mode configuration works correctly" }
|
||||
|
||||
noOfflineCache.clear()
|
||||
offlineCache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test local cache size limits`() {
|
||||
logger.info { "Testing local cache size limits" }
|
||||
|
||||
// Configuration with very small local cache
|
||||
val smallCacheConfig = DefaultCacheConfiguration(
|
||||
keyPrefix = "small-cache-test",
|
||||
defaultTtl = 30.minutes,
|
||||
localCacheMaxSize = 3, // Very small
|
||||
offlineModeEnabled = true
|
||||
)
|
||||
|
||||
val smallCache = RedisDistributedCache(redisTemplate, serializer, smallCacheConfig)
|
||||
smallCache.clear()
|
||||
|
||||
// Fill local cache beyond its limit
|
||||
repeat(10) { i ->
|
||||
smallCache.set("key-$i", "value-$i")
|
||||
}
|
||||
|
||||
// All values should still be retrievable (from Redis if not in local cache)
|
||||
repeat(10) { i ->
|
||||
assertEquals("value-$i", smallCache.get<String>("key-$i"))
|
||||
}
|
||||
|
||||
// Configuration with unlimited local cache
|
||||
val unlimitedCacheConfig = DefaultCacheConfiguration(
|
||||
keyPrefix = "unlimited-cache-test",
|
||||
defaultTtl = 30.minutes,
|
||||
localCacheMaxSize = null, // No limit
|
||||
offlineModeEnabled = true
|
||||
)
|
||||
|
||||
val unlimitedCache = RedisDistributedCache(redisTemplate, serializer, unlimitedCacheConfig)
|
||||
unlimitedCache.clear()
|
||||
|
||||
// Fill with many entries
|
||||
repeat(1000) { i ->
|
||||
unlimitedCache.set("unlimited-key-$i", "unlimited-value-$i")
|
||||
}
|
||||
|
||||
// All should be retrievable
|
||||
repeat(1000) { i ->
|
||||
assertEquals("unlimited-value-$i", unlimitedCache.get<String>("unlimited-key-$i"))
|
||||
}
|
||||
|
||||
logger.info { "Local cache size limits work correctly" }
|
||||
|
||||
smallCache.clear()
|
||||
unlimitedCache.clear()
|
||||
}
|
||||
}
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
package at.mocode.infrastructure.cache.redis
|
||||
|
||||
import at.mocode.infrastructure.cache.api.*
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
|
||||
import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer
|
||||
import org.testcontainers.containers.GenericContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import org.testcontainers.utility.DockerImageName
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* Edge Cases and Error Handling Tests for RedisDistributedCache
|
||||
*/
|
||||
@Testcontainers
|
||||
class RedisDistributedCacheEdgeCasesTest {
|
||||
|
||||
companion object {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Container
|
||||
val redisContainer = GenericContainer<Nothing>(
|
||||
DockerImageName.parse("redis:7-alpine")
|
||||
.asCompatibleSubstituteFor("redis")
|
||||
).apply {
|
||||
withExposedPorts(6379)
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var redisTemplate: RedisTemplate<String, ByteArray>
|
||||
private lateinit var serializer: CacheSerializer
|
||||
private lateinit var config: CacheConfiguration
|
||||
private lateinit var cache: RedisDistributedCache
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
val redisPort = redisContainer.getMappedPort(6379)
|
||||
val redisHost = redisContainer.host
|
||||
|
||||
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
|
||||
val connectionFactory = LettuceConnectionFactory(redisConfig)
|
||||
connectionFactory.afterPropertiesSet()
|
||||
|
||||
redisTemplate = RedisTemplate<String, ByteArray>().apply {
|
||||
setConnectionFactory(connectionFactory)
|
||||
keySerializer = StringRedisSerializer()
|
||||
afterPropertiesSet()
|
||||
}
|
||||
|
||||
serializer = JacksonCacheSerializer()
|
||||
config = DefaultCacheConfiguration(
|
||||
keyPrefix = "edge-test",
|
||||
defaultTtl = 30.minutes,
|
||||
compressionEnabled = true,
|
||||
compressionThreshold = 1024
|
||||
)
|
||||
|
||||
cache = RedisDistributedCache(redisTemplate, serializer, config)
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test serialization with problematic objects`() {
|
||||
logger.info { "Testing serialization with problematic objects" }
|
||||
|
||||
// Test 1: Object with circular references (causes StackOverflowError)
|
||||
val circularObject = CircularReferenceClass()
|
||||
circularObject.self = circularObject
|
||||
|
||||
// This should handle the serialization gracefully (either succeed or fail gracefully)
|
||||
try {
|
||||
cache.set("circular-reference", circularObject as Any)
|
||||
logger.info { "Circular reference object was handled (possibly with Jackson's circular reference handling)" }
|
||||
} catch (t: Throwable) {
|
||||
logger.info { "Circular reference object caused expected serialization issue: ${t::class.simpleName}" }
|
||||
assertTrue(
|
||||
t is com.fasterxml.jackson.databind.JsonMappingException ||
|
||||
t is StackOverflowError ||
|
||||
t is RuntimeException,
|
||||
"Expected serialization-related exception"
|
||||
)
|
||||
}
|
||||
|
||||
// Test 2: Very deep nesting that might cause issues
|
||||
val deepObject = createDeeplyNestedObject(50)
|
||||
try {
|
||||
cache.set("deep-nested", deepObject as Any)
|
||||
cache.get("deep-nested", DeeplyNestedObject::class.java)
|
||||
logger.info { "Deep nested object serialized successfully" }
|
||||
} catch (t: Throwable) {
|
||||
logger.info { "Deep nested object caused expected issues: ${t::class.simpleName}" }
|
||||
}
|
||||
|
||||
// Verify that the cache remains stable after problematic serialization attempts
|
||||
cache.set("normal-object", "test-value")
|
||||
assertEquals("test-value", cache.get<String>("normal-object"))
|
||||
|
||||
logger.info { "Serialization edge cases handled correctly" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test cache with extremely large values`() {
|
||||
logger.info { "Testing extremely large values" }
|
||||
|
||||
// Create a very large string (10MB)
|
||||
val largeValue = "X".repeat(10 * 1024 * 1024)
|
||||
val key = "large-value"
|
||||
|
||||
// This should trigger compression
|
||||
cache.set(key, largeValue)
|
||||
|
||||
// Verify we can retrieve it
|
||||
val retrieved = cache.get<String>(key)
|
||||
assertNotNull(retrieved)
|
||||
assertEquals(largeValue.length, retrieved.length)
|
||||
assertEquals(largeValue.substring(0, 1000), retrieved.substring(0, 1000))
|
||||
|
||||
logger.info { "Large value (${largeValue.length} chars) stored and retrieved successfully" }
|
||||
|
||||
// Test with multiple large values
|
||||
val largeValues = (1..5).associateWith { "Y".repeat(2 * 1024 * 1024) }
|
||||
cache.multiSet(largeValues.mapKeys { "large-multi-${it.key}" })
|
||||
|
||||
val retrievedLarge = cache.multiGet<String>(largeValues.keys.map { "large-multi-$it" })
|
||||
assertEquals(5, retrievedLarge.size)
|
||||
|
||||
logger.info { "Multiple large values stored and retrieved successfully" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test cache with null and empty values`() {
|
||||
logger.info { "Testing null and empty values" }
|
||||
|
||||
// Test empty string
|
||||
cache.set("empty-string", "")
|
||||
assertEquals("", cache.get<String>("empty-string"))
|
||||
|
||||
// Test string with only whitespace
|
||||
cache.set("whitespace", " \n\t ")
|
||||
assertEquals(" \n\t ", cache.get<String>("whitespace"))
|
||||
|
||||
// Test empty collections
|
||||
val emptyList = emptyList<String>()
|
||||
cache.set("empty-list", emptyList)
|
||||
assertEquals(emptyList, cache.get<List<String>>("empty-list"))
|
||||
|
||||
val emptyMap = emptyMap<String, String>()
|
||||
cache.set("empty-map", emptyMap)
|
||||
assertEquals(emptyMap, cache.get<Map<String, String>>("empty-map"))
|
||||
|
||||
// Test object with null fields
|
||||
val objectWithNulls = PersonWithNullable(name = "John", age = null, email = null)
|
||||
cache.set("null-fields", objectWithNulls)
|
||||
val retrieved = cache.get<PersonWithNullable>("null-fields")
|
||||
assertNotNull(retrieved)
|
||||
assertEquals("John", retrieved.name)
|
||||
assertNull(retrieved.age)
|
||||
assertNull(retrieved.email)
|
||||
|
||||
logger.info { "Null and empty values handled correctly" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test special characters and unicode in keys and values`() {
|
||||
logger.info { "Testing special characters and unicode" }
|
||||
|
||||
// Test keys with special characters (encoded)
|
||||
val specialKeys = listOf(
|
||||
"key:with:colons",
|
||||
"key with spaces",
|
||||
"key-with-dashes",
|
||||
"key_with_underscores",
|
||||
"key.with.dots"
|
||||
)
|
||||
|
||||
specialKeys.forEachIndexed { index, key ->
|
||||
cache.set(key, "value-$index")
|
||||
}
|
||||
|
||||
specialKeys.forEachIndexed { index, key ->
|
||||
assertEquals("value-$index", cache.get<String>(key))
|
||||
}
|
||||
|
||||
// Test values with unicode characters
|
||||
val unicodeValues = mapOf(
|
||||
"emoji" to "🚀 Hello World! 🌟",
|
||||
"german" to "Äöüß und Umlaute",
|
||||
"chinese" to "你好世界",
|
||||
"arabic" to "مرحبا بالعالم",
|
||||
"russian" to "Привет мир",
|
||||
"mixed" to "Mixed: 123 ABC äöü 🎉 العالم"
|
||||
)
|
||||
|
||||
cache.multiSet(unicodeValues)
|
||||
val retrievedUnicode = cache.multiGet<String>(unicodeValues.keys)
|
||||
|
||||
unicodeValues.forEach { (key, expectedValue) ->
|
||||
assertEquals(expectedValue, retrievedUnicode[key])
|
||||
}
|
||||
|
||||
logger.info { "Special characters and unicode handled correctly" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test cache with complex nested objects`() {
|
||||
logger.info { "Testing complex nested objects" }
|
||||
|
||||
// Create a complex nested structure
|
||||
val complexObject = ComplexNestedObject(
|
||||
id = 1,
|
||||
name = "Complex Object",
|
||||
metadata = mapOf(
|
||||
"tags" to listOf("tag1", "tag2", "tag3"),
|
||||
"properties" to mapOf(
|
||||
"nested" to mapOf(
|
||||
"deep" to "value",
|
||||
"numbers" to listOf(1, 2, 3, 4, 5)
|
||||
)
|
||||
)
|
||||
),
|
||||
children = listOf(
|
||||
SimpleChild(1, "Child 1"),
|
||||
SimpleChild(2, "Child 2")
|
||||
)
|
||||
)
|
||||
|
||||
// Store and retrieve
|
||||
cache.set("complex-object", complexObject)
|
||||
val retrieved = cache.get<ComplexNestedObject>("complex-object")
|
||||
|
||||
assertNotNull(retrieved)
|
||||
assertEquals(complexObject.id, retrieved.id)
|
||||
assertEquals(complexObject.name, retrieved.name)
|
||||
assertEquals(complexObject.children.size, retrieved.children.size)
|
||||
assertEquals(complexObject.children[0].name, retrieved.children[0].name)
|
||||
|
||||
// Check nested metadata
|
||||
val retrievedTags = retrieved.metadata["tags"] as List<*>
|
||||
assertEquals(3, retrievedTags.size)
|
||||
assertTrue(retrievedTags.contains("tag1"))
|
||||
|
||||
logger.info { "Complex nested object serialized and deserialized correctly" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test cache behavior with malformed data`() {
|
||||
logger.info { "Testing cache behavior with malformed data" }
|
||||
|
||||
// Test retrieving non-existent keys
|
||||
assertNull(cache.get<String>("non-existent-key"))
|
||||
|
||||
// Test batch operations with mixed existing/non-existing keys
|
||||
cache.set("existing-1", "value-1")
|
||||
cache.set("existing-2", "value-2")
|
||||
|
||||
val mixedKeys = listOf("existing-1", "non-existing", "existing-2", "also-non-existing")
|
||||
val result = cache.multiGet<String>(mixedKeys)
|
||||
|
||||
assertEquals(2, result.size)
|
||||
assertEquals("value-1", result["existing-1"])
|
||||
assertEquals("value-2", result["existing-2"])
|
||||
assertNull(result["non-existing"])
|
||||
assertNull(result["also-non-existing"])
|
||||
|
||||
logger.info { "Malformed data scenarios handled correctly" }
|
||||
}
|
||||
|
||||
// Helper method to create deeply nested objects
|
||||
private fun createDeeplyNestedObject(depth: Int): DeeplyNestedObject {
|
||||
return if (depth <= 0) {
|
||||
DeeplyNestedObject("leaf", null)
|
||||
} else {
|
||||
DeeplyNestedObject("node-$depth", createDeeplyNestedObject(depth - 1))
|
||||
}
|
||||
}
|
||||
|
||||
// Test data classes
|
||||
private class NonSerializableClass {
|
||||
// This class intentionally has no default constructor or proper serialization
|
||||
private val threadLocal = ThreadLocal<String>()
|
||||
|
||||
fun someMethod() = "not serializable"
|
||||
}
|
||||
|
||||
private class CircularReferenceClass {
|
||||
var name: String = "circular"
|
||||
var self: CircularReferenceClass? = null
|
||||
}
|
||||
|
||||
data class DeeplyNestedObject(
|
||||
val name: String,
|
||||
val child: DeeplyNestedObject?
|
||||
)
|
||||
|
||||
data class PersonWithNullable(
|
||||
val name: String,
|
||||
val age: Int?,
|
||||
val email: String?
|
||||
)
|
||||
|
||||
data class ComplexNestedObject(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val metadata: Map<String, Any>,
|
||||
val children: List<SimpleChild>
|
||||
)
|
||||
|
||||
data class SimpleChild(
|
||||
val id: Int,
|
||||
val name: String
|
||||
)
|
||||
}
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
package at.mocode.infrastructure.cache.redis
|
||||
|
||||
import at.mocode.infrastructure.cache.api.*
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
|
||||
import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer
|
||||
import org.testcontainers.containers.GenericContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import org.testcontainers.utility.DockerImageName
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTime
|
||||
|
||||
/**
|
||||
* Monitoring and Integration Tests for RedisDistributedCache
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Testcontainers
|
||||
class RedisDistributedCacheIntegrationTest {
|
||||
|
||||
companion object {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Container
|
||||
val redisContainer = GenericContainer<Nothing>(
|
||||
DockerImageName.parse("redis:7-alpine")
|
||||
.asCompatibleSubstituteFor("redis")
|
||||
).apply {
|
||||
withExposedPorts(6379)
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var redisTemplate: RedisTemplate<String, ByteArray>
|
||||
private lateinit var serializer: CacheSerializer
|
||||
private lateinit var config: CacheConfiguration
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
val redisPort = redisContainer.getMappedPort(6379)
|
||||
val redisHost = redisContainer.host
|
||||
|
||||
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
|
||||
val connectionFactory = LettuceConnectionFactory(redisConfig)
|
||||
connectionFactory.afterPropertiesSet()
|
||||
|
||||
redisTemplate = RedisTemplate<String, ByteArray>().apply {
|
||||
setConnectionFactory(connectionFactory)
|
||||
keySerializer = StringRedisSerializer()
|
||||
afterPropertiesSet()
|
||||
}
|
||||
|
||||
serializer = JacksonCacheSerializer()
|
||||
config = DefaultCacheConfiguration(
|
||||
keyPrefix = "integration-test",
|
||||
defaultTtl = 30.minutes
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test connection state listener functionality`() = runBlocking {
|
||||
logger.info { "Testing connection state listener functionality" }
|
||||
|
||||
val cache = RedisDistributedCache(redisTemplate, serializer, config)
|
||||
cache.clear()
|
||||
|
||||
val stateChanges = mutableListOf<Pair<ConnectionState, kotlin.time.Instant>>()
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
val listener = object : ConnectionStateListener {
|
||||
override fun onConnectionStateChanged(newState: ConnectionState, timestamp: kotlin.time.Instant) {
|
||||
logger.info { "Connection state changed to: $newState at $timestamp" }
|
||||
stateChanges.add(newState to timestamp)
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
// Register listener
|
||||
cache.registerConnectionListener(listener)
|
||||
|
||||
// Initial state should be connected
|
||||
assertEquals(ConnectionState.CONNECTED, cache.getConnectionState())
|
||||
logger.info { "Initial connection state: ${cache.getConnectionState()}" }
|
||||
|
||||
// Test listener registration/unregistration
|
||||
val multipleListeners = mutableListOf<ConnectionStateListener>()
|
||||
val callCounts = AtomicInteger(0)
|
||||
|
||||
repeat(3) { i ->
|
||||
val testListener = object : ConnectionStateListener {
|
||||
override fun onConnectionStateChanged(newState: ConnectionState, timestamp: kotlin.time.Instant) {
|
||||
callCounts.incrementAndGet()
|
||||
logger.info { "Listener $i received state change: $newState" }
|
||||
}
|
||||
}
|
||||
multipleListeners.add(testListener)
|
||||
cache.registerConnectionListener(testListener)
|
||||
}
|
||||
|
||||
// Simulate state change (this might not trigger in our test environment,
|
||||
// but we're testing the listener mechanism)
|
||||
cache.checkConnection()
|
||||
|
||||
// Unregister listeners
|
||||
multipleListeners.forEach { cache.unregisterConnectionListener(it) }
|
||||
cache.unregisterConnectionListener(listener)
|
||||
|
||||
logger.info { "Connection state listener functionality tested" }
|
||||
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test different Redis configurations`() {
|
||||
logger.info { "Testing different Redis configurations" }
|
||||
|
||||
// Test with current configuration
|
||||
val standardCache = RedisDistributedCache(redisTemplate, serializer, config)
|
||||
standardCache.clear()
|
||||
|
||||
// Basic functionality test
|
||||
standardCache.set("config-test-1", "standard-value")
|
||||
assertEquals("standard-value", standardCache.get<String>("config-test-1"))
|
||||
|
||||
// Test with different Redis configuration (same container, different settings)
|
||||
val alternativeConfig = DefaultCacheConfiguration(
|
||||
keyPrefix = "alt-config",
|
||||
defaultTtl = 1.hours,
|
||||
compressionEnabled = true,
|
||||
compressionThreshold = 500
|
||||
)
|
||||
|
||||
val alternativeCache = RedisDistributedCache(redisTemplate, serializer, alternativeConfig)
|
||||
alternativeCache.clear()
|
||||
|
||||
// Test isolation between configurations
|
||||
alternativeCache.set("config-test-1", "alternative-value")
|
||||
|
||||
// Both caches should maintain their own data
|
||||
assertEquals("standard-value", standardCache.get<String>("config-test-1"))
|
||||
assertEquals("alternative-value", alternativeCache.get<String>("config-test-1"))
|
||||
|
||||
// Test connection state tracking
|
||||
assertEquals(ConnectionState.CONNECTED, standardCache.getConnectionState())
|
||||
assertEquals(ConnectionState.CONNECTED, alternativeCache.getConnectionState())
|
||||
|
||||
logger.info { "Different Redis configurations work correctly" }
|
||||
|
||||
standardCache.clear()
|
||||
alternativeCache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test cache warming scenarios`() {
|
||||
logger.info { "Testing cache warming scenarios" }
|
||||
|
||||
val cache = RedisDistributedCache(redisTemplate, serializer, config)
|
||||
cache.clear()
|
||||
|
||||
// Scenario 1: Bulk warming with predefined data
|
||||
val warmupData = (1..1000).associate { "warmup-key-$it" to "warmup-value-$it" }
|
||||
|
||||
logger.info { "Starting cache warming with ${warmupData.size} entries" }
|
||||
val warmupTime = measureTime {
|
||||
cache.multiSet(warmupData)
|
||||
}
|
||||
logger.info { "Cache warmup completed in $warmupTime" }
|
||||
|
||||
// Verify all data is accessible
|
||||
val verificationTime = measureTime {
|
||||
val retrieved = cache.multiGet<String>(warmupData.keys)
|
||||
assertEquals(warmupData.size, retrieved.size)
|
||||
|
||||
// Spot check some values
|
||||
assertEquals("warmup-value-1", retrieved["warmup-key-1"])
|
||||
assertEquals("warmup-value-500", retrieved["warmup-key-500"])
|
||||
assertEquals("warmup-value-1000", retrieved["warmup-key-1000"])
|
||||
}
|
||||
logger.info { "Cache verification completed in $verificationTime" }
|
||||
|
||||
// Scenario 2: Gradual warming simulation
|
||||
logger.info { "Testing gradual cache warming" }
|
||||
val gradualWarmupCache = RedisDistributedCache(redisTemplate, serializer,
|
||||
DefaultCacheConfiguration(keyPrefix = "gradual-warmup", defaultTtl = 1.hours))
|
||||
gradualWarmupCache.clear()
|
||||
|
||||
// Simulate application startup with gradual data loading
|
||||
val batchSize = 100
|
||||
val totalBatches = 10
|
||||
|
||||
repeat(totalBatches) { batchIndex ->
|
||||
val batchData = (1..batchSize).associate {
|
||||
"gradual-${batchIndex * batchSize + it}" to "gradual-value-${batchIndex * batchSize + it}"
|
||||
}
|
||||
gradualWarmupCache.multiSet(batchData)
|
||||
|
||||
// Simulate some delay between batches (like database queries)
|
||||
Thread.sleep(10)
|
||||
}
|
||||
|
||||
// Verify gradual warmup worked
|
||||
val totalEntries = batchSize * totalBatches
|
||||
val allKeys = (1..totalEntries).map { "gradual-$it" }
|
||||
val retrievedGradual = gradualWarmupCache.multiGet<String>(allKeys)
|
||||
|
||||
assertEquals(totalEntries, retrievedGradual.size)
|
||||
logger.info { "Gradual warmup successful: ${retrievedGradual.size} entries" }
|
||||
|
||||
// Scenario 3: Selective warming based on usage patterns
|
||||
logger.info { "Testing selective cache warming" }
|
||||
val selectiveCache = RedisDistributedCache(redisTemplate, serializer,
|
||||
DefaultCacheConfiguration(keyPrefix = "selective-warmup", defaultTtl = 2.hours))
|
||||
selectiveCache.clear()
|
||||
|
||||
// Simulate frequently accessed data
|
||||
val frequentData = listOf("user:123", "config:global", "menu:main")
|
||||
val infrequentData = (1..100).map { "rare:data:$it" }
|
||||
|
||||
// Warm up frequent data first (priority warming)
|
||||
frequentData.forEach { key ->
|
||||
selectiveCache.set(key, "frequent-$key")
|
||||
}
|
||||
|
||||
// Warm up infrequent data in background
|
||||
infrequentData.forEach { key ->
|
||||
selectiveCache.set(key, "infrequent-$key")
|
||||
}
|
||||
|
||||
// Verify selective warming
|
||||
frequentData.forEach { key ->
|
||||
assertEquals("frequent-$key", selectiveCache.get<String>(key))
|
||||
}
|
||||
|
||||
logger.info { "Selective cache warming completed successfully" }
|
||||
|
||||
cache.clear()
|
||||
gradualWarmupCache.clear()
|
||||
selectiveCache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test metrics and monitoring integration`() = runBlocking {
|
||||
logger.info { "Testing metrics and monitoring integration" }
|
||||
|
||||
val monitoringCache = RedisDistributedCache(redisTemplate, serializer, config)
|
||||
monitoringCache.clear()
|
||||
|
||||
// Test connection state tracking over time
|
||||
val connectionStateHistory = mutableListOf<ConnectionState>()
|
||||
var lastStateChangeTime = monitoringCache.getLastStateChangeTime()
|
||||
|
||||
logger.info { "Initial connection state: ${monitoringCache.getConnectionState()}" }
|
||||
logger.info { "Last state change time: $lastStateChangeTime" }
|
||||
|
||||
connectionStateHistory.add(monitoringCache.getConnectionState())
|
||||
|
||||
// Perform various operations and monitor state
|
||||
repeat(100) { i ->
|
||||
monitoringCache.set("monitoring-key-$i", "monitoring-value-$i")
|
||||
|
||||
if (i % 20 == 0) {
|
||||
val currentState = monitoringCache.getConnectionState()
|
||||
val currentTime = monitoringCache.getLastStateChangeTime()
|
||||
|
||||
if (currentTime != lastStateChangeTime) {
|
||||
logger.info { "State change detected at operation $i" }
|
||||
connectionStateHistory.add(currentState)
|
||||
lastStateChangeTime = currentTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test dirty keys tracking for monitoring
|
||||
logger.info { "Testing dirty keys monitoring" }
|
||||
val initialDirtyKeys = monitoringCache.getDirtyKeys()
|
||||
logger.info { "Initial dirty keys count: ${initialDirtyKeys.size}" }
|
||||
|
||||
// Add some data and verify dirty keys tracking
|
||||
monitoringCache.set("dirty-test-1", "dirty-value-1")
|
||||
monitoringCache.set("dirty-test-2", "dirty-value-2")
|
||||
|
||||
// In normal connected state, dirty keys should be minimal
|
||||
val finalDirtyKeys = monitoringCache.getDirtyKeys()
|
||||
logger.info { "Final dirty keys count: ${finalDirtyKeys.size}" }
|
||||
|
||||
// Test batch operations monitoring
|
||||
val batchData = (1..50).associate { "batch-monitoring-$it" to "batch-value-$it" }
|
||||
|
||||
val batchTime = measureTime {
|
||||
monitoringCache.multiSet(batchData)
|
||||
}
|
||||
logger.info { "Batch operation took: $batchTime" }
|
||||
|
||||
val retrievalTime = measureTime {
|
||||
val retrieved = monitoringCache.multiGet<String>(batchData.keys)
|
||||
assertEquals(50, retrieved.size)
|
||||
}
|
||||
logger.info { "Batch retrieval took: $retrievalTime" }
|
||||
|
||||
logger.info { "Monitoring integration test completed" }
|
||||
|
||||
monitoringCache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test cross-instance synchronization`() = runBlocking {
|
||||
logger.info { "Testing cross-instance synchronization" }
|
||||
|
||||
// Create two cache instances (simulating different application instances)
|
||||
val instance1 = RedisDistributedCache(redisTemplate, serializer,
|
||||
DefaultCacheConfiguration(keyPrefix = "sync-test", defaultTtl = 1.hours))
|
||||
val instance2 = RedisDistributedCache(redisTemplate, serializer,
|
||||
DefaultCacheConfiguration(keyPrefix = "sync-test", defaultTtl = 1.hours))
|
||||
|
||||
instance1.clear()
|
||||
instance2.clear()
|
||||
|
||||
// Instance 1 writes data
|
||||
instance1.set("sync-key-1", "from-instance-1")
|
||||
instance1.set("sync-key-2", "from-instance-1-v2")
|
||||
|
||||
// Small delay to ensure propagation
|
||||
delay(100.milliseconds)
|
||||
|
||||
// Instance 2 should be able to read the data
|
||||
assertEquals("from-instance-1", instance2.get<String>("sync-key-1"))
|
||||
assertEquals("from-instance-1-v2", instance2.get<String>("sync-key-2"))
|
||||
|
||||
// Instance 2 modifies and adds data
|
||||
instance2.set("sync-key-2", "modified-by-instance-2")
|
||||
instance2.set("sync-key-3", "from-instance-2")
|
||||
|
||||
// Small delay to ensure propagation
|
||||
delay(100.milliseconds)
|
||||
|
||||
// Instance 1 should see the changes
|
||||
// Note: Due to local caching, we need to clear local cache or use a fresh get
|
||||
// The current implementation may cache locally, so we test what we can reliably verify
|
||||
val retrievedByInstance1 = instance1.get<String>("sync-key-3") // New key should work
|
||||
assertEquals("from-instance-2", retrievedByInstance1)
|
||||
|
||||
// Test batch operations across instances
|
||||
val batchData1 = mapOf(
|
||||
"batch-sync-1" to "batch-from-instance-1",
|
||||
"batch-sync-2" to "batch-from-instance-1-v2"
|
||||
)
|
||||
|
||||
instance1.multiSet(batchData1)
|
||||
|
||||
val retrievedByInstance2 = instance2.multiGet<String>(batchData1.keys)
|
||||
assertEquals(2, retrievedByInstance2.size)
|
||||
assertEquals("batch-from-instance-1", retrievedByInstance2["batch-sync-1"])
|
||||
|
||||
logger.info { "Cross-instance synchronization works correctly" }
|
||||
|
||||
instance1.clear()
|
||||
instance2.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test production-like scenarios`() = runBlocking {
|
||||
logger.info { "Testing production-like scenarios" }
|
||||
|
||||
val prodCache = RedisDistributedCache(redisTemplate, serializer,
|
||||
DefaultCacheConfiguration(
|
||||
keyPrefix = "prod-test",
|
||||
defaultTtl = 30.minutes,
|
||||
localCacheMaxSize = 10000,
|
||||
compressionEnabled = true,
|
||||
compressionThreshold = 1024
|
||||
))
|
||||
prodCache.clear()
|
||||
|
||||
// Scenario 1: User session caching
|
||||
logger.info { "Testing user session caching" }
|
||||
val userSessions = (1..1000).associate {
|
||||
"user:session:$it" to UserSession(
|
||||
userId = "user$it",
|
||||
sessionId = "session$it",
|
||||
lastActivity = System.currentTimeMillis(),
|
||||
permissions = listOf("read", "write")
|
||||
)
|
||||
}
|
||||
|
||||
val sessionTime = measureTime {
|
||||
prodCache.multiSet(userSessions.mapValues { it.value })
|
||||
}
|
||||
logger.info { "Stored ${userSessions.size} user sessions in $sessionTime" }
|
||||
|
||||
// Verify session retrieval
|
||||
val retrievedSession = prodCache.get<UserSession>("user:session:500")
|
||||
assertNotNull(retrievedSession)
|
||||
assertEquals("user500", retrievedSession.userId)
|
||||
|
||||
// Scenario 2: Configuration caching
|
||||
logger.info { "Testing configuration caching" }
|
||||
val configData = mapOf(
|
||||
"config:database:connection" to DatabaseConfig(
|
||||
host = "localhost",
|
||||
port = 5432,
|
||||
database = "production",
|
||||
maxConnections = 50
|
||||
),
|
||||
"config:feature:flags" to mapOf(
|
||||
"new_ui" to true,
|
||||
"experimental_feature" to false,
|
||||
"maintenance_mode" to false
|
||||
)
|
||||
)
|
||||
|
||||
configData.forEach { (key, value) ->
|
||||
prodCache.set(key, value, 1.hours) // Config cached for 1 hour
|
||||
}
|
||||
|
||||
val dbConfig = prodCache.get<DatabaseConfig>("config:database:connection")
|
||||
assertNotNull(dbConfig)
|
||||
assertEquals("localhost", dbConfig.host)
|
||||
|
||||
// Scenario 3: API response caching
|
||||
logger.info { "Testing API response caching" }
|
||||
val apiResponses = (1..100).associate {
|
||||
"api:response:endpoint$it" to ApiResponse(
|
||||
status = 200,
|
||||
data = "Response data for endpoint $it",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
cacheHeaders = mapOf("Cache-Control" to "public, max-age=3600")
|
||||
)
|
||||
}
|
||||
|
||||
val apiTime = measureTime {
|
||||
apiResponses.forEach { (key, value) ->
|
||||
prodCache.set(key, value, 5.minutes) // API responses cached for 5 minutes
|
||||
}
|
||||
}
|
||||
logger.info { "Cached ${apiResponses.size} API responses in $apiTime" }
|
||||
|
||||
// Verify API response retrieval
|
||||
val apiResponse = prodCache.get<ApiResponse>("api:response:endpoint50")
|
||||
assertNotNull(apiResponse)
|
||||
assertEquals(200, apiResponse.status)
|
||||
|
||||
logger.info { "Production-like scenarios completed successfully" }
|
||||
|
||||
prodCache.clear()
|
||||
}
|
||||
|
||||
// Test data classes for production scenarios
|
||||
data class UserSession(
|
||||
val userId: String,
|
||||
val sessionId: String,
|
||||
val lastActivity: Long,
|
||||
val permissions: List<String>
|
||||
)
|
||||
|
||||
data class DatabaseConfig(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val database: String,
|
||||
val maxConnections: Int
|
||||
)
|
||||
|
||||
data class ApiResponse(
|
||||
val status: Int,
|
||||
val data: String,
|
||||
val timestamp: Long,
|
||||
val cacheHeaders: Map<String, String>
|
||||
)
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package at.mocode.infrastructure.cache.redis
|
||||
|
||||
import at.mocode.infrastructure.cache.api.*
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
|
||||
import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer
|
||||
import org.testcontainers.containers.GenericContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import org.testcontainers.utility.DockerImageName
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.measureTime
|
||||
|
||||
/**
|
||||
* Performance and Load Tests for RedisDistributedCache
|
||||
*/
|
||||
@Testcontainers
|
||||
class RedisDistributedCachePerformanceTest {
|
||||
|
||||
companion object {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Container
|
||||
val redisContainer = GenericContainer<Nothing>(
|
||||
DockerImageName.parse("redis:7-alpine")
|
||||
.asCompatibleSubstituteFor("redis")
|
||||
).apply {
|
||||
withExposedPorts(6379)
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var redisTemplate: RedisTemplate<String, ByteArray>
|
||||
private lateinit var serializer: CacheSerializer
|
||||
private lateinit var config: CacheConfiguration
|
||||
private lateinit var cache: RedisDistributedCache
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
val redisPort = redisContainer.getMappedPort(6379)
|
||||
val redisHost = redisContainer.host
|
||||
|
||||
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
|
||||
val connectionFactory = LettuceConnectionFactory(redisConfig)
|
||||
connectionFactory.afterPropertiesSet()
|
||||
|
||||
redisTemplate = RedisTemplate<String, ByteArray>().apply {
|
||||
setConnectionFactory(connectionFactory)
|
||||
keySerializer = StringRedisSerializer()
|
||||
afterPropertiesSet()
|
||||
}
|
||||
|
||||
serializer = JacksonCacheSerializer()
|
||||
config = DefaultCacheConfiguration(
|
||||
keyPrefix = "perf-test",
|
||||
defaultTtl = 30.minutes
|
||||
)
|
||||
|
||||
cache = RedisDistributedCache(redisTemplate, serializer, config)
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test cache performance with high concurrent access`() = runTest {
|
||||
logger.info { "Starting concurrent access test" }
|
||||
val numberOfCoroutines = 100
|
||||
val operationsPerCoroutine = 50
|
||||
val successCounter = AtomicInteger(0)
|
||||
val errorCounter = AtomicInteger(0)
|
||||
|
||||
val time = measureTime {
|
||||
val jobs = (1..numberOfCoroutines).map { coroutineId ->
|
||||
launch {
|
||||
repeat(operationsPerCoroutine) { operationId ->
|
||||
try {
|
||||
val key = "concurrent-$coroutineId-$operationId"
|
||||
val value = "value-$coroutineId-$operationId"
|
||||
|
||||
// Set operation
|
||||
cache.set(key, value)
|
||||
|
||||
// Get operation
|
||||
val retrieved = cache.get<String>(key)
|
||||
if (retrieved == value) {
|
||||
successCounter.incrementAndGet()
|
||||
} else {
|
||||
errorCounter.incrementAndGet()
|
||||
logger.warn { "Mismatch: expected $value, got $retrieved" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
errorCounter.incrementAndGet()
|
||||
logger.warn { "Error in operation: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
jobs.joinAll()
|
||||
}
|
||||
|
||||
val totalOperations = numberOfCoroutines * operationsPerCoroutine
|
||||
val successRate = successCounter.get().toDouble() / totalOperations
|
||||
val operationsPerSecond = if (time.inWholeSeconds > 0) totalOperations / time.inWholeSeconds else totalOperations * 1000 / maxOf(1, time.inWholeMilliseconds)
|
||||
|
||||
logger.info { "Performance test completed" }
|
||||
logger.info { "Total operations: $totalOperations" }
|
||||
logger.info { "Successful operations: ${successCounter.get()}" }
|
||||
logger.info { "Failed operations: ${errorCounter.get()}" }
|
||||
logger.info { "Success rate: ${successRate * 100}%" }
|
||||
logger.info { "Total time: $time" }
|
||||
logger.info { "Operations per second: $operationsPerSecond" }
|
||||
|
||||
assertTrue(successRate > 0.95, "Success rate should be > 95%, but was ${successRate * 100}%")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test cache behavior under memory pressure`() {
|
||||
logger.info { "Starting memory pressure test" }
|
||||
|
||||
// Create cache with limited local cache size
|
||||
val limitedConfig = DefaultCacheConfiguration(
|
||||
keyPrefix = "memory-test",
|
||||
localCacheMaxSize = 100, // Very small local cache
|
||||
defaultTtl = 30.minutes
|
||||
)
|
||||
val limitedCache = RedisDistributedCache(redisTemplate, serializer, limitedConfig)
|
||||
|
||||
// Fill cache with more entries than local cache can hold
|
||||
val numberOfEntries = 500
|
||||
val largeValue = "A".repeat(1000) // 1KB per entry
|
||||
|
||||
val time = measureTime {
|
||||
repeat(numberOfEntries) { i ->
|
||||
val key = "memory-pressure-$i"
|
||||
limitedCache.set(key, largeValue)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info { "Inserted $numberOfEntries entries in $time" }
|
||||
|
||||
// Verify that entries are still retrievable (should come from Redis)
|
||||
var retrievedCount = 0
|
||||
repeat(numberOfEntries) { i ->
|
||||
val key = "memory-pressure-$i"
|
||||
val retrieved = limitedCache.get<String>(key)
|
||||
if (retrieved == largeValue) {
|
||||
retrievedCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.info { "Successfully retrieved $retrievedCount out of $numberOfEntries entries" }
|
||||
assertTrue(retrievedCount > numberOfEntries * 0.9,
|
||||
"Should retrieve > 90% of entries, but retrieved only ${retrievedCount * 100.0 / numberOfEntries}%")
|
||||
|
||||
limitedCache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test bulk operations performance`() {
|
||||
logger.info { "Starting bulk operations performance test" }
|
||||
|
||||
val batchSize = 1000
|
||||
val entries = (1..batchSize).associate {
|
||||
"bulk-$it" to "bulk-value-$it"
|
||||
}
|
||||
|
||||
// Test multiSet performance
|
||||
val setTime = measureTime {
|
||||
cache.multiSet(entries)
|
||||
}
|
||||
|
||||
// Test multiGet performance
|
||||
val getTime = measureTime {
|
||||
val retrieved = cache.multiGet<String>(entries.keys)
|
||||
assertEquals(batchSize, retrieved.size)
|
||||
}
|
||||
|
||||
val setRatePerSec = if (setTime.inWholeSeconds > 0) batchSize / setTime.inWholeSeconds else batchSize * 1000 / maxOf(1, setTime.inWholeMilliseconds)
|
||||
val getRatePerSec = if (getTime.inWholeSeconds > 0) batchSize / getTime.inWholeSeconds else batchSize * 1000 / maxOf(1, getTime.inWholeMilliseconds)
|
||||
|
||||
logger.info { "Bulk operations performance completed" }
|
||||
logger.info { "MultiSet ${batchSize} entries: $setTime" }
|
||||
logger.info { "MultiGet ${batchSize} entries: $getTime" }
|
||||
logger.info { "Set rate: $setRatePerSec entries/sec" }
|
||||
logger.info { "Get rate: $getRatePerSec entries/sec" }
|
||||
|
||||
assertTrue(setTime.inWholeSeconds < 10, "MultiSet should complete within 10 seconds")
|
||||
assertTrue(getTime.inWholeSeconds < 10, "MultiGet should complete within 10 seconds")
|
||||
}
|
||||
}
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
package at.mocode.infrastructure.cache.redis
|
||||
|
||||
import at.mocode.infrastructure.cache.api.*
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.data.redis.RedisConnectionFailureException
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
|
||||
import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.data.redis.core.ValueOperations
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer
|
||||
import org.testcontainers.containers.GenericContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import org.testcontainers.utility.DockerImageName
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.ExperimentalTime
|
||||
import java.time.Duration as JavaDuration
|
||||
|
||||
/**
|
||||
* Timeout and Resilience Tests for RedisDistributedCache
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Testcontainers
|
||||
class RedisDistributedCacheResilienceTest {
|
||||
|
||||
companion object {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Container
|
||||
val redisContainer = GenericContainer<Nothing>(
|
||||
DockerImageName.parse("redis:7-alpine")
|
||||
.asCompatibleSubstituteFor("redis")
|
||||
).apply {
|
||||
withExposedPorts(6379)
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var redisTemplate: RedisTemplate<String, ByteArray>
|
||||
private lateinit var serializer: CacheSerializer
|
||||
private lateinit var config: CacheConfiguration
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
val redisPort = redisContainer.getMappedPort(6379)
|
||||
val redisHost = redisContainer.host
|
||||
|
||||
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
|
||||
val connectionFactory = LettuceConnectionFactory(redisConfig)
|
||||
connectionFactory.afterPropertiesSet()
|
||||
|
||||
redisTemplate = RedisTemplate<String, ByteArray>().apply {
|
||||
setConnectionFactory(connectionFactory)
|
||||
keySerializer = StringRedisSerializer()
|
||||
afterPropertiesSet()
|
||||
}
|
||||
|
||||
serializer = JacksonCacheSerializer()
|
||||
config = DefaultCacheConfiguration(
|
||||
keyPrefix = "resilience-test",
|
||||
defaultTtl = 30.minutes,
|
||||
offlineModeEnabled = true
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test connection timeout scenarios`() = runBlocking {
|
||||
logger.info { "Testing connection timeout scenarios" }
|
||||
|
||||
val mockTemplate = mockk<RedisTemplate<String, ByteArray>>()
|
||||
val mockValueOps = mockk<ValueOperations<String, ByteArray>>()
|
||||
|
||||
every { mockTemplate.opsForValue() } returns mockValueOps
|
||||
|
||||
// Simulate slow Redis responses
|
||||
every { mockValueOps.get(any()) } answers {
|
||||
Thread.sleep(5000) // 5-second delay
|
||||
"slow-response".toByteArray()
|
||||
}
|
||||
|
||||
every { mockValueOps.set(any<String>(), any<ByteArray>(), any<JavaDuration>()) } answers {
|
||||
Thread.sleep(3000) // 3-second delay
|
||||
}
|
||||
|
||||
val slowCache = RedisDistributedCache(mockTemplate, serializer, config)
|
||||
|
||||
// Test get operation with timeout
|
||||
val startTime = System.currentTimeMillis()
|
||||
val result = slowCache.get<String>("slow-key")
|
||||
val endTime = System.currentTimeMillis()
|
||||
|
||||
logger.info { "Get operation took ${endTime - startTime}ms" }
|
||||
// The operation should either succeed or fail gracefully
|
||||
|
||||
// Test set operation with timeout
|
||||
val setStartTime = System.currentTimeMillis()
|
||||
slowCache.set("slow-set-key", "value")
|
||||
val setEndTime = System.currentTimeMillis()
|
||||
|
||||
logger.info { "Set operation took ${setEndTime - setStartTime}ms" }
|
||||
|
||||
// Verify that operations don't hang indefinitely
|
||||
assertTrue((endTime - startTime) < 10000, "Get operation should not take more than 10 seconds")
|
||||
assertTrue((setEndTime - setStartTime) < 10000, "Set operation should not take more than 10 seconds")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test partial Redis failures`() {
|
||||
logger.info { "Testing partial Redis failures" }
|
||||
|
||||
val mockTemplate = mockk<RedisTemplate<String, ByteArray>>()
|
||||
val mockValueOps = mockk<ValueOperations<String, ByteArray>>()
|
||||
|
||||
every { mockTemplate.opsForValue() } returns mockValueOps
|
||||
every { mockTemplate.hasKey(any()) } returns true
|
||||
|
||||
val failureCounter = AtomicInteger(0)
|
||||
|
||||
// Simulate intermittent connection failures (fail every 3rd operation)
|
||||
every { mockValueOps.get(any()) } answers {
|
||||
if (failureCounter.incrementAndGet() % 3 == 0) {
|
||||
throw RedisConnectionFailureException("Intermittent failure")
|
||||
}
|
||||
serializer.serializeEntry(CacheEntry("test", "value"))
|
||||
}
|
||||
|
||||
every { mockValueOps.set(any<String>(), any<ByteArray>(), any<JavaDuration>()) } answers {
|
||||
if (failureCounter.incrementAndGet() % 3 == 0) {
|
||||
throw RedisConnectionFailureException("Intermittent failure")
|
||||
}
|
||||
}
|
||||
|
||||
val unreliableCache = RedisDistributedCache(mockTemplate, serializer, config)
|
||||
|
||||
// Test multiple operations with intermittent failures
|
||||
var successCount = 0
|
||||
var failureCount = 0
|
||||
|
||||
repeat(20) { i ->
|
||||
try {
|
||||
unreliableCache.set("intermittent-$i", "value-$i")
|
||||
val retrieved = unreliableCache.get<String>("intermittent-$i")
|
||||
if (retrieved != null) {
|
||||
successCount++
|
||||
} else {
|
||||
failureCount++
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failureCount++
|
||||
logger.info { "Operation failed as expected: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
logger.info { "Partial failure test results:" }
|
||||
logger.info { "Successful operations: $successCount" }
|
||||
logger.info { "Failed operations: $failureCount" }
|
||||
logger.info { "Total operations: 20" }
|
||||
|
||||
// Due to offline mode, operations might succeed locally even when Redis fails,
|
||||
// So we verify the cache is resilient and continues working
|
||||
assertTrue(successCount >= 0, "Should handle operations gracefully")
|
||||
assertEquals(20, successCount + failureCount, "Should process all operations")
|
||||
|
||||
// Verify that the cache state is properly managed despite intermittent failures
|
||||
assertEquals(ConnectionState.DISCONNECTED, unreliableCache.getConnectionState())
|
||||
|
||||
// Verify that dirty keys are tracked for failed operations
|
||||
val dirtyKeys = unreliableCache.getDirtyKeys()
|
||||
assertTrue(dirtyKeys.isNotEmpty(), "Should have dirty keys from failed operations")
|
||||
logger.info { "Dirty keys count: ${dirtyKeys.size}" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test network partitioning simulation`() {
|
||||
logger.info { "Testing network partitioning simulation" }
|
||||
|
||||
val cache = RedisDistributedCache(redisTemplate, serializer, config)
|
||||
cache.clear()
|
||||
|
||||
// Phase 1: Normal operations (network is fine)
|
||||
logger.info { "Phase 1: Normal operations" }
|
||||
cache.set("partition-test-1", "value-1")
|
||||
cache.set("partition-test-2", "value-2")
|
||||
|
||||
assertEquals("value-1", cache.get<String>("partition-test-1"))
|
||||
assertEquals("value-2", cache.get<String>("partition-test-2"))
|
||||
assertEquals(ConnectionState.CONNECTED, cache.getConnectionState())
|
||||
|
||||
// Phase 2: Simulate network partition by creating a new cache with a broken connection
|
||||
logger.info { "Phase 2: Simulating network partition" }
|
||||
val mockTemplate = mockk<RedisTemplate<String, ByteArray>>()
|
||||
val mockValueOps = mockk<ValueOperations<String, ByteArray>>()
|
||||
|
||||
every { mockTemplate.opsForValue() } returns mockValueOps
|
||||
every { mockValueOps.get(any()) } throws RedisConnectionFailureException("Network partition")
|
||||
every {
|
||||
mockValueOps.set(
|
||||
any<String>(),
|
||||
any<ByteArray>(),
|
||||
any<JavaDuration>()
|
||||
)
|
||||
} throws RedisConnectionFailureException("Network partition")
|
||||
every { mockTemplate.delete(any<String>()) } throws RedisConnectionFailureException("Network partition")
|
||||
every { mockTemplate.hasKey(any()) } throws RedisConnectionFailureException("Network partition")
|
||||
|
||||
val partitionedCache = RedisDistributedCache(mockTemplate, serializer, config)
|
||||
|
||||
// Operations during partition should work locally
|
||||
partitionedCache.set("partition-offline-1", "offline-value-1")
|
||||
partitionedCache.set("partition-offline-2", "offline-value-2")
|
||||
|
||||
// Should be able to retrieve from a local cache
|
||||
assertEquals("offline-value-1", partitionedCache.get<String>("partition-offline-1"))
|
||||
assertEquals("offline-value-2", partitionedCache.get<String>("partition-offline-2"))
|
||||
assertEquals(ConnectionState.DISCONNECTED, partitionedCache.getConnectionState())
|
||||
|
||||
// Should track dirty keys
|
||||
val dirtyKeys = partitionedCache.getDirtyKeys()
|
||||
assertTrue(dirtyKeys.contains("partition-offline-1"))
|
||||
assertTrue(dirtyKeys.contains("partition-offline-2"))
|
||||
|
||||
logger.info { "Network partition handled correctly - operations work offline" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test reconnection and synchronization after network issues`() {
|
||||
logger.info { "Testing reconnection and synchronization" }
|
||||
|
||||
val mockTemplate = mockk<RedisTemplate<String, ByteArray>>()
|
||||
val mockValueOps = mockk<ValueOperations<String, ByteArray>>()
|
||||
|
||||
every { mockTemplate.opsForValue() } returns mockValueOps
|
||||
|
||||
val reconnectingCache = RedisDistributedCache(mockTemplate, serializer, config)
|
||||
|
||||
// Phase 1: Simulate disconnection
|
||||
every { mockValueOps.get(any()) } throws RedisConnectionFailureException("Disconnected")
|
||||
every {
|
||||
mockValueOps.set(
|
||||
any<String>(),
|
||||
any<ByteArray>(),
|
||||
any<JavaDuration>()
|
||||
)
|
||||
} throws RedisConnectionFailureException("Disconnected")
|
||||
every { mockTemplate.hasKey(any()) } throws RedisConnectionFailureException("Disconnected")
|
||||
|
||||
reconnectingCache.set("reconnect-test-1", "value-1")
|
||||
reconnectingCache.set("reconnect-test-2", "value-2")
|
||||
|
||||
assertEquals(ConnectionState.DISCONNECTED, reconnectingCache.getConnectionState())
|
||||
assertTrue(reconnectingCache.getDirtyKeys().size >= 2)
|
||||
|
||||
// Phase 2: Simulate reconnection
|
||||
every { mockValueOps.set(any<String>(), any<ByteArray>(), any<JavaDuration>()) } returns Unit
|
||||
every { mockTemplate.hasKey(any()) } returns true
|
||||
every { mockTemplate.delete(any<String>()) } returns true
|
||||
|
||||
// Trigger connection check (this would normally be done by a scheduled task)
|
||||
reconnectingCache.checkConnection()
|
||||
|
||||
// After a successful connection check, dirty keys should be synchronized
|
||||
// Note: In a real scenario, this would be handled by the synchronization mechanism
|
||||
|
||||
logger.info { "Reconnection simulation completed" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test connection state listener notifications`() = runBlocking {
|
||||
logger.info { "Testing connection state listener notifications" }
|
||||
|
||||
val stateChanges = mutableListOf<ConnectionState>()
|
||||
|
||||
val listener = object : ConnectionStateListener {
|
||||
override fun onConnectionStateChanged(newState: ConnectionState, timestamp: kotlin.time.Instant) {
|
||||
logger.info { "Connection state changed to: $newState at $timestamp" }
|
||||
stateChanges.add(newState)
|
||||
}
|
||||
}
|
||||
|
||||
val cache = RedisDistributedCache(redisTemplate, serializer, config)
|
||||
cache.registerConnectionListener(listener)
|
||||
|
||||
// Initially should be connected
|
||||
assertEquals(ConnectionState.CONNECTED, cache.getConnectionState())
|
||||
logger.info { "Initial connection state: ${cache.getConnectionState()}" }
|
||||
|
||||
// Test listener registration/unregistration mechanism
|
||||
val testListener = object : ConnectionStateListener {
|
||||
override fun onConnectionStateChanged(newState: ConnectionState, timestamp: kotlin.time.Instant) {
|
||||
logger.info { "Test listener received state change: $newState" }
|
||||
}
|
||||
}
|
||||
|
||||
// Register and unregister listeners (testing the mechanism itself)
|
||||
cache.registerConnectionListener(testListener)
|
||||
cache.unregisterConnectionListener(testListener)
|
||||
cache.unregisterConnectionListener(listener)
|
||||
|
||||
logger.info { "Connection state listener registration/unregistration mechanism tested" }
|
||||
|
||||
// Test that connection state is properly tracked
|
||||
assertTrue(cache.isConnected(), "Cache should be connected to Redis")
|
||||
|
||||
logger.info { "Connection state listener functionality verified" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test cache operations during Redis restart simulation`() = runBlocking {
|
||||
logger.info { "Testing cache operations during Redis restart simulation" }
|
||||
|
||||
val cache = RedisDistributedCache(redisTemplate, serializer, config)
|
||||
cache.clear()
|
||||
|
||||
// Store some initial data
|
||||
cache.set("restart-test-1", "initial-value-1")
|
||||
cache.set("restart-test-2", "initial-value-2")
|
||||
|
||||
assertEquals("initial-value-1", cache.get<String>("restart-test-1"))
|
||||
|
||||
// Simulate Redis restart by creating a new cache instance
|
||||
// (In a real scenario, this would be the same instance, but Redis would be restarted)
|
||||
|
||||
// During "restart" (brief unavailability), operations should work locally
|
||||
val duringRestartCache = RedisDistributedCache(redisTemplate, serializer, config)
|
||||
|
||||
// These should work even if Redis is temporarily unavailable
|
||||
duringRestartCache.set("during-restart-1", "temp-value-1")
|
||||
assertEquals("temp-value-1", duringRestartCache.get<String>("during-restart-1"))
|
||||
|
||||
// After "restart", data should be synchronized
|
||||
delay(1.seconds) // Brief delay to simulate restart completion
|
||||
|
||||
val afterRestartCache = RedisDistributedCache(redisTemplate, serializer, config)
|
||||
|
||||
// Should be able to access both old and new data
|
||||
// Note: In a real Redis restart, persisted data would still be there
|
||||
afterRestartCache.set("after-restart-1", "post-restart-value-1")
|
||||
assertEquals("post-restart-value-1", afterRestartCache.get<String>("after-restart-1"))
|
||||
|
||||
logger.info { "Redis restart simulation completed successfully" }
|
||||
}
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
package at.mocode.infrastructure.cache.redis
|
||||
|
||||
import at.mocode.infrastructure.cache.api.*
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.data.redis.RedisConnectionFailureException
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
|
||||
import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.data.redis.core.ValueOperations
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer
|
||||
import org.testcontainers.containers.GenericContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import org.testcontainers.utility.DockerImageName
|
||||
import kotlin.test.*
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import java.time.Duration as JavaDuration // Alias für Eindeutigkeit
|
||||
|
||||
@Testcontainers
|
||||
class RedisDistributedCacheTest {
|
||||
|
||||
companion object {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Container
|
||||
val redisContainer = GenericContainer<Nothing>(
|
||||
DockerImageName.parse("redis:7-alpine")
|
||||
.asCompatibleSubstituteFor("redis")
|
||||
).apply {
|
||||
withExposedPorts(6379)
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var redisTemplate: RedisTemplate<String, ByteArray>
|
||||
private lateinit var serializer: CacheSerializer
|
||||
private lateinit var config: CacheConfiguration
|
||||
private lateinit var cache: RedisDistributedCache
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
val redisPort = redisContainer.getMappedPort(6379)
|
||||
val redisHost = redisContainer.host
|
||||
|
||||
val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort)
|
||||
val connectionFactory = LettuceConnectionFactory(redisConfig)
|
||||
connectionFactory.afterPropertiesSet()
|
||||
|
||||
redisTemplate = RedisTemplate<String, ByteArray>().apply {
|
||||
setConnectionFactory(connectionFactory)
|
||||
keySerializer = StringRedisSerializer()
|
||||
afterPropertiesSet()
|
||||
}
|
||||
|
||||
serializer = JacksonCacheSerializer()
|
||||
config = DefaultCacheConfiguration(
|
||||
keyPrefix = "test",
|
||||
offlineModeEnabled = true,
|
||||
defaultTtl = 30.minutes
|
||||
)
|
||||
|
||||
cache = RedisDistributedCache(redisTemplate, serializer, config)
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get should return value with new reified extension function`() {
|
||||
cache.set("key1", "value1")
|
||||
val value = cache.get<String>("key1")
|
||||
assertEquals("value1", value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test basic cache operations`() {
|
||||
cache.set("key1", "value1")
|
||||
val value = cache.get("key1", String::class.java)
|
||||
assertEquals("value1", value)
|
||||
assertTrue(cache.exists("key1"))
|
||||
cache.delete("key1")
|
||||
assertFalse(cache.exists("key1"))
|
||||
assertNull(cache.get("key1", String::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test cache with TTL`() {
|
||||
cache.set("key2", "value2", 100.milliseconds)
|
||||
assertTrue(cache.exists("key2"))
|
||||
Thread.sleep(200)
|
||||
assertFalse(cache.exists("key2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test batch operations`() {
|
||||
// Set multiple values
|
||||
val entries = mapOf(
|
||||
"batch1" to "value1",
|
||||
"batch2" to "value2",
|
||||
"batch3" to "value3"
|
||||
)
|
||||
cache.multiSet(entries)
|
||||
|
||||
// Get multiple values
|
||||
val values = cache.multiGet(listOf("batch1", "batch2", "batch3"), String::class.java)
|
||||
assertEquals(3, values.size)
|
||||
assertEquals("value1", values["batch1"])
|
||||
assertEquals("value2", values["batch2"])
|
||||
assertEquals("value3", values["batch3"])
|
||||
|
||||
// Delete multiple values
|
||||
cache.multiDelete(listOf("batch1", "batch3"))
|
||||
|
||||
// Verify they're gone
|
||||
val remainingValues = cache.multiGet(listOf("batch1", "batch2", "batch3"), String::class.java)
|
||||
assertEquals(1, remainingValues.size)
|
||||
assertNull(remainingValues["batch1"])
|
||||
assertEquals("value2", remainingValues["batch2"])
|
||||
assertNull(remainingValues["batch3"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle offline mode and synchronize correctly`() {
|
||||
// Arrange
|
||||
val mockTemplate = mockk<RedisTemplate<String, ByteArray>>(relaxed = true)
|
||||
val mockValueOps = mockk<ValueOperations<String, ByteArray>>(relaxed = true)
|
||||
every { mockTemplate.opsForValue() } returns mockValueOps
|
||||
|
||||
val offlineCache = RedisDistributedCache(mockTemplate, serializer, config)
|
||||
|
||||
// 1. Online-Phase
|
||||
every { mockValueOps.set(any<String>(), any<ByteArray>(), any<JavaDuration>()) } returns Unit
|
||||
offlineCache.set("key1", "online-value")
|
||||
verify(exactly = 1) { mockValueOps.set(eq("test:key1"), any<ByteArray>(), any<JavaDuration>()) }
|
||||
|
||||
// 2. Offline-Phase simulieren
|
||||
every {
|
||||
mockValueOps.set(
|
||||
any<String>(),
|
||||
any<ByteArray>(),
|
||||
any<JavaDuration>()
|
||||
)
|
||||
} throws RedisConnectionFailureException("Redis is down")
|
||||
every { mockTemplate.delete(any<String>()) } throws RedisConnectionFailureException("Redis is down")
|
||||
|
||||
offlineCache.set("key2", "offline-value")
|
||||
offlineCache.delete("key1")
|
||||
|
||||
assertEquals("offline-value", offlineCache.get<String>("key2"))
|
||||
assertTrue(offlineCache.getDirtyKeys().contains("key2"))
|
||||
assertTrue(offlineCache.getDirtyKeys().contains("key1"))
|
||||
|
||||
// 3. Wiederverbindungs-Phase
|
||||
every { mockValueOps.set(any<String>(), any<ByteArray>(), any<JavaDuration>()) } returns Unit
|
||||
every { mockTemplate.delete(any<String>()) } returns true
|
||||
every { mockTemplate.hasKey("connection-test") } returns true
|
||||
|
||||
offlineCache.checkConnection()
|
||||
|
||||
verify(exactly = 1) { mockValueOps.set(eq("test:key1"), any<ByteArray>(), any<JavaDuration>()) }
|
||||
verify(exactly = 1) { mockTemplate.delete(eq("test:key1")) }
|
||||
assertTrue(offlineCache.getDirtyKeys().isEmpty(), "Dirty keys should be empty after sync")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test multiSet with TTL`() {
|
||||
val entries = mapOf("batchTtl1" to "value1", "batchTtl2" to "value2")
|
||||
cache.multiSet(entries, 100.milliseconds)
|
||||
|
||||
assertTrue(cache.exists("batchTtl1"))
|
||||
Thread.sleep(200)
|
||||
assertFalse(cache.exists("batchTtl1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test complex objects`() {
|
||||
// Create a complex object
|
||||
val person = Person("John Doe", 30, listOf("Reading", "Hiking"))
|
||||
|
||||
// Store it in the cache
|
||||
cache.set("person1", person)
|
||||
|
||||
// Retrieve it
|
||||
val retrievedPerson = cache.get("person1", Person::class.java)
|
||||
|
||||
// Verify it's the same
|
||||
assertNotNull(retrievedPerson)
|
||||
assertEquals("John Doe", retrievedPerson.name)
|
||||
assertEquals(30, retrievedPerson.age)
|
||||
assertEquals(2, retrievedPerson.hobbies.size)
|
||||
assertTrue(retrievedPerson.hobbies.contains("Reading"))
|
||||
assertTrue(retrievedPerson.hobbies.contains("Hiking"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test clear method`() {
|
||||
// Set multiple values
|
||||
cache.set("clear1", "value1")
|
||||
cache.set("clear2", "value2")
|
||||
|
||||
// Verify they exist
|
||||
assertTrue(cache.exists("clear1"))
|
||||
assertTrue(cache.exists("clear2"))
|
||||
|
||||
// Clear the cache
|
||||
cache.clear()
|
||||
|
||||
// Verify they're gone
|
||||
assertFalse(cache.exists("clear1"))
|
||||
assertFalse(cache.exists("clear2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test markDirty method`() {
|
||||
// Set a value
|
||||
cache.set("dirty1", "value1")
|
||||
|
||||
// Mark it as dirty
|
||||
cache.markDirty("dirty1")
|
||||
|
||||
// Verify it's in the dirty keys
|
||||
assertTrue(cache.getDirtyKeys().contains("dirty1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test handling Redis connection failures`() {
|
||||
// Create a mock RedisTemplate and ValueOperations
|
||||
val mockTemplate = mockk<RedisTemplate<String, ByteArray>>()
|
||||
val mockValueOps = mockk<ValueOperations<String, ByteArray>>()
|
||||
|
||||
// Configure the mock to throw connection failure
|
||||
every { mockTemplate.opsForValue() } returns mockValueOps
|
||||
every { mockValueOps.get(any()) } throws RedisConnectionFailureException("Test connection failure")
|
||||
every { mockTemplate.hasKey(any()) } throws RedisConnectionFailureException("Test connection failure")
|
||||
|
||||
// Create a cache with the mock
|
||||
val mockCache = RedisDistributedCache(mockTemplate, serializer, config)
|
||||
|
||||
// Try to get a value
|
||||
val value = mockCache.get("failure1", String::class.java)
|
||||
|
||||
// Verify it returns null
|
||||
assertNull(value)
|
||||
|
||||
// Verify the connection state is DISCONNECTED
|
||||
assertEquals(ConnectionState.DISCONNECTED, mockCache.getConnectionState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test default TTL`() {
|
||||
// Set a value without specifying TTL
|
||||
cache.set("defaultTtl", "value")
|
||||
|
||||
// Verify it exists
|
||||
assertTrue(cache.exists("defaultTtl"))
|
||||
|
||||
// The default TTL is 30 minutes, so it should still exist
|
||||
assertEquals("value", cache.get("defaultTtl", String::class.java))
|
||||
}
|
||||
|
||||
// Test data class
|
||||
data class Person(
|
||||
val name: String,
|
||||
val age: Int,
|
||||
val hobbies: List<String>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<!-- Console Appender für Test-Ausgaben -->
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- Cache-spezifische Logger -->
|
||||
<logger name="at.mocode.infrastructure.cache" level="DEBUG" />
|
||||
|
||||
<!-- Performance Test Logger -->
|
||||
<logger name="RedisDistributedCachePerformanceTest" level="INFO" />
|
||||
|
||||
<!-- Edge Cases Test Logger -->
|
||||
<logger name="RedisDistributedCacheEdgeCasesTest" level="INFO" />
|
||||
|
||||
<!-- Resilience Test Logger -->
|
||||
<logger name="RedisDistributedCacheResilienceTest" level="INFO" />
|
||||
|
||||
<!-- Configuration Test Logger -->
|
||||
<logger name="RedisDistributedCacheConfigurationTest" level="INFO" />
|
||||
|
||||
<!-- Integration Test Logger -->
|
||||
<logger name="RedisDistributedCacheIntegrationTest" level="INFO" />
|
||||
|
||||
<!-- Testcontainers Logger (reduziert Verbosity) -->
|
||||
<logger name="org.testcontainers" level="WARN" />
|
||||
<logger name="com.github.dockerjava" level="WARN" />
|
||||
|
||||
<!-- Redis/Lettuce Logger (reduziert Verbosity) -->
|
||||
<logger name="io.lettuce" level="WARN" />
|
||||
<logger name="org.springframework.data.redis" level="WARN" />
|
||||
|
||||
<!-- Root Logger -->
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT" />
|
||||
</root>
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user