refactor: Migrate from monolithic to modular architecture

1. **Dokumentation der Architektur:**
    - Vervollständigen Sie die C4-Diagramme im docs-Verzeichnis
    - Dokumentieren Sie die wichtigsten Architekturentscheidungen in ADRs

2. **Redis-Integration finalisieren:**
    - Implementieren Sie die verteilte Cache-Lösung für die Offline-Fähigkeit
    - Nutzen Sie Redis Streams für das Event-Sourcing
This commit is contained in:
stefan
2025-07-23 14:29:40 +02:00
parent a256622f37
commit 9282dd0eb4
52 changed files with 5648 additions and 3 deletions
+1
View File
@@ -4,6 +4,7 @@ plugins {
}
dependencies {
api(platform(projects.platform.platformBom))
implementation(projects.infrastructure.cache.cacheApi)
implementation("org.springframework.boot:spring-boot-starter-data-redis")
@@ -0,0 +1,119 @@
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.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
/**
* Jackson-based implementation of CacheSerializer.
*/
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 {
// Create a wrapper that holds both the entry metadata and the serialized value
val wrapper = CacheEntryWrapper(
key = entry.key,
valueBytes = serialize(entry.value),
valueType = entry.value.javaClass.name,
createdAt = entry.createdAt,
expiresAt = entry.expiresAt,
lastModifiedAt = entry.lastModifiedAt,
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 = wrapper.createdAt,
expiresAt = wrapper.expiresAt,
lastModifiedAt = wrapper.lastModifiedAt,
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()
}
/**
* Wrapper class for serializing cache entries.
* This separates the metadata from the value, allowing us to deserialize
* the metadata without knowing the type of the value.
*/
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 (createdAt != other.createdAt) return false
if (expiresAt != other.expiresAt) return false
if (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
}
}
}
@@ -0,0 +1,99 @@
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.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(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()
}
}
@@ -0,0 +1,494 @@
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.time.Duration
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
/**
* Redis implementation of DistributedCache with offline capability.
*/
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 = Instant.now()
// Connection state listeners
private val connectionListeners = CopyOnWriteArrayList<ConnectionStateListener>()
init {
// Try to connect to Redis
checkConnection()
}
//
// DistributedCache implementation
//
override fun <T : Any> get(key: String, clazz: Class<T>): T? {
val prefixedKey = addPrefix(key)
// Try to get from local cache first
val localEntry = localCache[prefixedKey] as? CacheEntry<T>
if (localEntry != null) {
if (localEntry.isExpired()) {
localCache.remove(prefixedKey)
return null
}
return localEntry.value
}
// If not in local cache and we're disconnected, return null
if (!isConnected()) {
return null
}
// Try to get from Redis
try {
val bytes = redisTemplate.opsForValue().get(prefixedKey) ?: return null
val entry = serializer.deserializeEntry(bytes, clazz)
// Store in local cache
localCache[prefixedKey] = entry as CacheEntry<Any>
return entry.value
} catch (e: RedisConnectionFailureException) {
handleConnectionFailure(e)
return null
} catch (e: Exception) {
logger.error("Error getting value from Redis for key $prefixedKey", e)
return null
}
}
override fun <T : Any> set(key: String, value: T, ttl: Duration?) {
val prefixedKey = addPrefix(key)
val expiresAt = ttl?.let { Instant.now().plus(it) } ?: config.defaultTtl?.let { Instant.now().plus(it) }
val entry = CacheEntry(
key = prefixedKey,
value = value,
expiresAt = expiresAt
)
// Store in local cache
localCache[prefixedKey] = entry as CacheEntry<Any>
// If we're disconnected, mark as dirty and return
if (!isConnected()) {
markDirty(key)
return
}
// Try to store in Redis
try {
val bytes = serializer.serializeEntry(entry)
redisTemplate.opsForValue().set(prefixedKey, bytes)
if (ttl != null) {
redisTemplate.expire(prefixedKey, ttl)
} else if (config.defaultTtl != null) {
val defaultTtl: Duration? = config.defaultTtl
redisTemplate.expire(prefixedKey, defaultTtl)
}
} catch (e: RedisConnectionFailureException) {
handleConnectionFailure(e)
markDirty(key)
} catch (e: Exception) {
logger.error("Error setting value in Redis for key $prefixedKey", e)
markDirty(key)
}
}
override fun delete(key: String) {
val prefixedKey = addPrefix(key)
// Remove from 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 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 local cache first
val prefixedKeys = keys.map { addPrefix(it) }
val localEntries = prefixedKeys.mapNotNull { key ->
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 local cache
localCache[key] = entry as CacheEntry<Any>
// 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
}
override fun <T : Any> multiSet(entries: Map<String, T>, ttl: Duration?) {
// Store in local cache and prepare for Redis
val redisBatch = mutableMapOf<String, ByteArray>()
val expiresAt = ttl?.let { Instant.now().plus(it) } ?: config.defaultTtl?.let { Instant.now().plus(it) }
for ((key, value) in entries) {
val prefixedKey = addPrefix(key)
val entry = CacheEntry(
key = prefixedKey,
value = value,
expiresAt = expiresAt
)
// Store in local cache
localCache[prefixedKey] = entry as CacheEntry<Any>
// Prepare for Redis
redisBatch[prefixedKey] = serializer.serializeEntry(entry)
}
// If we're disconnected, mark all as dirty and return
if (!isConnected()) {
entries.keys.forEach { markDirty(it) }
return
}
// Try to store in Redis
try {
redisTemplate.opsForValue().multiSet(redisBatch)
if (ttl != null || config.defaultTtl != null) {
val duration = ttl ?: config.defaultTtl
if (duration != null) {
for (key in redisBatch.keys) {
redisTemplate.expire(key, duration)
}
}
}
} 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 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)
redisTemplate.opsForValue().set(prefixedKey, bytes)
val ttl = localEntry.expiresAt?.let { Duration.between(Instant.now(), it) }
if (ttl != null && !ttl.isNegative) {
redisTemplate.expire(prefixedKey, ttl)
}
// Update local entry to mark as clean
localCache[prefixedKey] = localEntry.markClean() as CacheEntry<Any>
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() as CacheEntry<Any>
}
}
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)
}
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 = Instant.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)
}
}
}
/**
* Periodically check the connection to Redis.
*/
@Scheduled(fixedDelayString = "\${redis.connection-check-interval:10000}")
fun checkConnection() {
try {
redisTemplate.hasKey("connection-test")
setConnectionState(ConnectionState.CONNECTED)
} catch (e: Exception) {
setConnectionState(ConnectionState.DISCONNECTED)
}
}
/**
* Periodically clean up expired entries from the local cache.
*/
@Scheduled(fixedDelayString = "\${redis.local-cache-cleanup-interval:60000}")
fun cleanupLocalCache() {
val now = Instant.now()
val expiredKeys = localCache.entries
.filter { it.value.expiresAt?.isBefore(now) ?: false }
.map { it.key }
expiredKeys.forEach { localCache.remove(it) }
if (expiredKeys.isNotEmpty()) {
logger.debug("Removed ${expiredKeys.size} expired entries from local cache")
}
}
/**
* Periodically synchronize dirty keys when connected.
*/
@Scheduled(fixedDelayString = "\${redis.sync-interval:300000}")
fun scheduledSync() {
if (isConnected() && dirtyKeys.isNotEmpty()) {
synchronize(null)
}
}
}
@@ -0,0 +1,198 @@
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.ConnectionState
import at.mocode.infrastructure.cache.api.DefaultCacheConfiguration
import org.junit.jupiter.api.AfterEach
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.time.Duration
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@Testcontainers
class RedisDistributedCacheTest {
companion object {
@Container
val redisContainer = GenericContainer(DockerImageName.parse("redis:7-alpine"))
.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
)
cache = RedisDistributedCache(redisTemplate, serializer, config)
// Clear the cache before each test
cache.clear()
}
@AfterEach
fun tearDown() {
cache.clear()
}
@Test
fun `test basic cache operations`() {
// Set a value
cache.set("key1", "value1")
// Get the value
val value = cache.get("key1", String::class.java)
assertEquals("value1", value)
// Check if the key exists
assertTrue(cache.exists("key1"))
// Delete the key
cache.delete("key1")
// Verify it's gone
assertFalse(cache.exists("key1"))
assertNull(cache.get("key1", String::class.java))
}
@Test
fun `test cache with TTL`() {
// Set a value with a short TTL
cache.set("key2", "value2", Duration.ofMillis(100))
// Verify it exists
assertTrue(cache.exists("key2"))
assertEquals("value2", cache.get("key2", String::class.java))
// Wait for it to expire
Thread.sleep(200)
// Verify it's gone
assertFalse(cache.exists("key2"))
assertNull(cache.get("key2", String::class.java))
}
@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 `test offline capability`() {
// Set a value
cache.set("offline1", "value1")
// Simulate going offline by stopping the Redis container
redisContainer.stop()
// Verify connection state is DISCONNECTED
assertEquals(ConnectionState.DISCONNECTED, cache.getConnectionState())
// We should still be able to get the value from local cache
assertEquals("value1", cache.get("offline1", String::class.java))
// Set a new value while offline
cache.set("offline2", "value2")
// Verify it's marked as dirty
assertTrue(cache.getDirtyKeys().contains("offline2"))
// Start Redis again
redisContainer.start()
// Manually trigger synchronization
cache.synchronize()
// Verify connection state is CONNECTED
assertEquals(ConnectionState.CONNECTED, cache.getConnectionState())
// Verify the value set while offline is now in Redis
assertEquals("value2", cache.get("offline2", String::class.java))
// Verify it's no longer marked as dirty
assertFalse(cache.getDirtyKeys().contains("offline2"))
}
@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 data class
data class Person(
val name: String,
val age: Int,
val hobbies: List<String>
)
}