From 62455182dd446931fedb6a6b4d3fb4d4bab8e142 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Tue, 20 Jan 2026 20:27:04 +0100 Subject: [PATCH 01/34] feat: encapsulate cache replacement strategies into enum - add new redis cache replacement strategy --- .../sdq/lissa/ratlr/cache/CacheManager.java | 7 +- .../ratlr/cache/CacheReplacementStrategy.java | 73 +++++++++++++++++++ .../sdq/lissa/ratlr/cache/RedisCache.java | 38 +++++----- 3 files changed, 97 insertions(+), 21 deletions(-) create mode 100644 src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index b061d8ad..4816d2b9 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -23,9 +23,10 @@ public final class CacheManager { /** * The default strategy for handling cache conflicts between local and Redis caches. - * When true, Redis values take precedence over local cache values in case of conflicts. + * Redis values take precedence over local cache values in case of conflicts. */ - private static final boolean DEFAULT_REPLACE_LOCAL_CACHE_ON_CONFLICT = true; + private static final CacheReplacementStrategy DEFAULT_CONFLICT_RESOLUTION = + CacheReplacementStrategy.REPLACE_LOCAL_VALUE; private static @Nullable CacheManager defaultInstanceManager; private final Path directoryOfCaches; @@ -109,7 +110,7 @@ private Cache getCache(String name, CacheParameter pa } LocalCache localCache = new LocalCache<>(directoryOfCaches + "/" + name + ".json", parameters); - RedisCache cache = new RedisCache<>(parameters, localCache, DEFAULT_REPLACE_LOCAL_CACHE_ON_CONFLICT); + RedisCache cache = new RedisCache<>(parameters, localCache, DEFAULT_CONFLICT_RESOLUTION); caches.put(name, cache); return cache; } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java new file mode 100644 index 00000000..1e553ec2 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java @@ -0,0 +1,73 @@ +/* Licensed under MIT 2025-2026. */ +package edu.kit.kastel.sdq.lissa.ratlr.cache; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import redis.clients.jedis.UnifiedJedis; + +/** + * Defines strategies for handling cache value replacement when conflicts occur between Redis and local cache. + * A conflict occurs when the same key exists in both caches but with different values. + */ +public enum CacheReplacementStrategy { + /** + * Does not replace conflicting values - leaves both cache values as they are. + * The Redis value will be returned when reading. + */ + NONE { + @Override + public String resolve( + String key, String redisValue, String localValue, LocalCache localCache, @Nullable UnifiedJedis jedis) { + logger.info("Cache inconsistency detected for key {}, keeping both values (returning Redis value)", key); + return redisValue; + } + }, + + /** + * Replaces the local cache value with the Redis value when a conflict is detected. + * This strategy gives precedence to the Redis cache as the source of truth. + */ + REPLACE_LOCAL_VALUE { + @Override + public String resolve( + String key, String redisValue, String localValue, LocalCache localCache, @Nullable UnifiedJedis jedis) { + logger.info("Cache inconsistency detected for key {}, using Redis value and replacing local one", key); + localCache.put(key, redisValue); + return redisValue; + } + }, + + /** + * Replaces the Redis cache value with the local cache value when a conflict is detected. + * This strategy gives precedence to the local cache as the source of truth. + */ + REPLACE_REDIS_VALUE { + @Override + public String resolve( + String key, String redisValue, String localValue, LocalCache localCache, @Nullable UnifiedJedis jedis) { + logger.info("Cache inconsistency detected for key {}, using local value and replacing Redis one", key); + if (jedis != null) { + jedis.hset(key, "data", localValue); + } + return localValue; + } + }; + + private static final Logger logger = LoggerFactory.getLogger(CacheReplacementStrategy.class); + + /** + * Resolves a conflict between Redis and local cache values by applying the appropriate replacement strategy. + * + * @param key The cache key where the conflict occurred + * @param redisValue The value from Redis + * @param localValue The value from the local cache + * @param localCache The local cache instance + * @param jedis The Redis client instance (may be null) + * + * @return The resolved cache value to be used + */ + public abstract String resolve( + String key, String redisValue, String localValue, LocalCache localCache, @Nullable UnifiedJedis jedis); +} diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java index be550e88..4fd8285c 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java @@ -44,18 +44,21 @@ class RedisCache implements Cache { private @Nullable UnifiedJedis jedis; /** - * Flag indicating whether to replace local cache entries on conflict with Redis values. + * Strategy for resolving conflicts between Redis and local cache values. */ - private final boolean replaceLocalCacheOnConflict; + private final CacheReplacementStrategy conflictResolution; /** * Creates a new Redis cache instance with an optional local cache backup. * * @param localCache The local cache to use as backup, or null if no backup is needed + * @param conflictResolution Strategy for resolving conflicts between Redis and local cache values * @throws IllegalArgumentException If neither Redis nor local cache can be initialized */ RedisCache( - CacheParameter cacheParameter, @Nullable LocalCache localCache, boolean replaceLocalCacheOnConflict) { + CacheParameter cacheParameter, + @Nullable LocalCache localCache, + CacheReplacementStrategy conflictResolution) { this.cacheParameter = Objects.requireNonNull(cacheParameter); this.localCache = localCache == null || !localCache.isReady() ? null : localCache; if (this.localCache != null && !this.getCacheParameter().equals(this.localCache.getCacheParameter())) { @@ -67,7 +70,7 @@ class RedisCache implements Cache { if (jedis == null && this.localCache == null) { throw new IllegalArgumentException("Could not create cache"); } - this.replaceLocalCacheOnConflict = replaceLocalCacheOnConflict; + this.conflictResolution = conflictResolution; } @Override @@ -112,9 +115,8 @@ private void createRedisConnection() { * falls back to the local cache. * If the value is found in the local cache and Redis is available, it will be synchronized to Redis. * If the value is found in Redis and the local cache is available, it will be synchronized to the local cache. - * In case of a mismatch between Redis and local cache values, a warning is logged and the replacement strategy - * is applied: if {@link #replaceLocalCacheOnConflict} is true, the Redis value takes precedence and replaces - * the local cache value; otherwise, the Redis cache value is returned without modification. + * In case of a mismatch between Redis and local cache values, a warning is logged and the configured + * {@link #conflictResolution} strategy is applied. * * @param The type to deserialize the value to * @param key The cache key to look up @@ -137,13 +139,13 @@ public synchronized T get(String key, Class clazz) { if (localData != null && jsonData == null && jedis != null) { jedis.hset(cacheKey.toJsonKey(), "data", localData); } - // Value is in both caches, but they differ - if (replaceLocalCacheOnConflict && jsonData != null && localData != null && !jsonData.equals(localData)) { - logger.info("Cache inconsistency detected for key {}, using Redis value and replacing local one", key); - localCache.put(key, jsonData); + String valueToReturn; + if (jsonData != null && localData != null && !jsonData.equals(localData)) { + // Value is in both caches, but they differ - apply conflict resolution strategy + valueToReturn = conflictResolution.resolve(key, jsonData, localData, localCache, jedis); + } else { + valueToReturn = jsonData != null ? jsonData : localData; } - - String valueToReturn = jsonData != null ? jsonData : localData; return convert(valueToReturn, clazz); } @@ -163,13 +165,13 @@ public synchronized T get(String key, Class clazz) { if (localData != null && jsonData == null && jedis != null) { jedis.hset(cacheKey.toJsonKey(), "data", localData); } + String valueToReturn; // Value is in both caches, but they differ - if (replaceLocalCacheOnConflict && jsonData != null && localData != null && !jsonData.equals(localData)) { - logger.info("Cache inconsistency detected for key {}, using Redis value and replacing local one", cacheKey); - localCache.putViaInternalKey(cacheKey, jsonData); + if (jsonData != null && localData != null && !jsonData.equals(localData)) { + valueToReturn = conflictResolution.resolve(cacheKey.toJsonKey(), jsonData, localData, localCache, jedis); + } else { + valueToReturn = jsonData != null ? jsonData : localData; } - - String valueToReturn = jsonData != null ? jsonData : localData; return convert(valueToReturn, clazz); } From 95ecd2da4b542146d5bbb70dc15a18b3b49fb41d Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:13:33 +0200 Subject: [PATCH 02/34] feat: add cache initialization logic to EvaluationConfiguration and update CacheManager to support initialization from ModuleConfiguration --- .../kastel/sdq/lissa/ratlr/Evaluation.java | 2 +- .../sdq/lissa/ratlr/cache/CacheManager.java | 32 ++++++++- .../EvaluationConfiguration.java | 23 +++++-- .../sdq/lissa/ratlr/ArchitectureTest.java | 16 +++++ .../sdq/lissa/ratlr/cache/CacheTest.java | 69 +++++++++++++++++++ 5 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/Evaluation.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/Evaluation.java index 6b2d2ab4..bfd3e900 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/Evaluation.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/Evaluation.java @@ -198,7 +198,7 @@ public Evaluation(EvaluationConfiguration config) throws IOException { * @throws IOException If there are issues reading the configuration */ private void setup() throws IOException { - CacheManager.setCacheDir(configuration.cacheDir()); + configuration.initializeCache(); ContextStore contextStore = new ContextStore(); diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index 4816d2b9..5dcb7ea5 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -9,6 +9,8 @@ import org.jspecify.annotations.Nullable; +import edu.kit.kastel.sdq.lissa.ratlr.configuration.ModuleConfiguration; + /** * Manages caching operations in the LiSSA framework. * This class provides a centralized way to create and access caches for different purposes, @@ -28,6 +30,11 @@ public final class CacheManager { private static final CacheReplacementStrategy DEFAULT_CONFLICT_RESOLUTION = CacheReplacementStrategy.REPLACE_LOCAL_VALUE; + /** + * The configuration key for specifying the cache directory in a ModuleConfiguration. + */ + public static final String CACHE_DIR_CONFIGURATION_KEY = "cache_dir"; + private static @Nullable CacheManager defaultInstanceManager; private final Path directoryOfCaches; private final Map> caches = new HashMap<>(); @@ -39,8 +46,18 @@ public final class CacheManager { * @param directory The path to the cache directory, or null to use the default directory * @throws IOException If the cache directory cannot be created */ + @Deprecated(forRemoval = false) public static synchronized void setCacheDir(@Nullable String directory) throws IOException { - defaultInstanceManager = new CacheManager(Path.of(directory == null ? DEFAULT_CACHE_DIRECTORY : directory)); + if (directory == null) { + directory = DEFAULT_CACHE_DIRECTORY; + } + ModuleConfiguration config = new ModuleConfiguration("cache", Map.of(CACHE_DIR_CONFIGURATION_KEY, directory)); + setCacheDir(config); + } + + public static synchronized void setCacheDir(ModuleConfiguration configuration) throws IOException { + String cacheDir = configuration.argumentAsString(CACHE_DIR_CONFIGURATION_KEY, DEFAULT_CACHE_DIRECTORY); + defaultInstanceManager = new CacheManager(Path.of(cacheDir)); } /** @@ -71,6 +88,19 @@ public static CacheManager getDefaultInstance() { return defaultInstanceManager; } + /** + * Resets the default cache manager instance. + * This method is intended for testing purposes only to allow clean state between tests. + * After calling this method, {@link #setCacheDir(String)} or {@link #setCacheDir(ModuleConfiguration)} + * must be called again before using the default instance. + */ + static synchronized void resetDefaultInstance() { + if (defaultInstanceManager != null) { + defaultInstanceManager.flush(); + } + defaultInstanceManager = null; + } + /** * Gets a cache instance for the specified name. * This method is designed for internal use by model implementations. diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/EvaluationConfiguration.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/EvaluationConfiguration.java index f156e0b9..64df740d 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/EvaluationConfiguration.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/EvaluationConfiguration.java @@ -1,6 +1,7 @@ /* Licensed under MIT 2025-2026. */ package edu.kit.kastel.sdq.lissa.ratlr.configuration; +import java.io.IOException; import java.io.UncheckedIOException; import java.util.List; @@ -12,6 +13,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import edu.kit.kastel.sdq.lissa.ratlr.cache.CacheManager; import edu.kit.kastel.sdq.lissa.ratlr.classifier.Classifier; import edu.kit.kastel.sdq.lissa.ratlr.context.ContextStore; @@ -26,13 +28,14 @@ * The configuration is used to instantiate pipeline components, each of which can access shared context * via a {@link edu.kit.kastel.sdq.lissa.ratlr.context.ContextStore} passed to their factory methods. *

+ * @param cacheDir Directory for caching intermediate results. Either this or {@link #cache} must be set, but not both. + * @param cache Configuration for the caching of LLM calls. Either this or {@link #cacheDir} must be set, but not both. */ @RecordBuilder() public record EvaluationConfiguration( - /** - * Directory for caching intermediate results. - */ - @JsonProperty("cache_dir") String cacheDir, + @JsonProperty("cache_dir") @Nullable String cacheDir, + + @JsonProperty("cache") @Nullable ModuleConfiguration cache, /** * EvaluationConfiguration for gold standard evaluation. @@ -180,4 +183,16 @@ public Classifier createClassifier(ContextStore contextStore) { ? Classifier.createClassifier(classifier, contextStore) : Classifier.createMultiStageClassifier(classifiers, contextStore); } + + public void initializeCache() throws IOException { + if ((cacheDir == null) == (cache == null)) { + throw new IllegalStateException("Either 'cache_dir' or 'cache' must be set, but not both."); + } + + if (cacheDir != null) { + CacheManager.setCacheDir(cacheDir); + } else { + CacheManager.setCacheDir(cache); + } + } } diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java index d6595047..63bd463b 100644 --- a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java +++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java @@ -27,6 +27,7 @@ import edu.kit.kastel.sdq.lissa.cli.command.OptimizeCommand; import edu.kit.kastel.sdq.lissa.ratlr.cache.CacheKey; +import edu.kit.kastel.sdq.lissa.ratlr.cache.CacheManager; import edu.kit.kastel.sdq.lissa.ratlr.cache.CacheParameter; import edu.kit.kastel.sdq.lissa.ratlr.cache.classifier.ClassifierCacheParameter; import edu.kit.kastel.sdq.lissa.ratlr.cache.embedding.EmbeddingCacheParameter; @@ -317,4 +318,19 @@ public void check(JavaClass javaClass, ConditionEvents events) { } } }); + + /** + * Rule that enforces that CacheManager.resetDefaultInstance() is only called from the CacheTest class. + *

+ * The resetDefaultInstance() method should only be used to reset the singleton state between tests. + * It must never be called from production code or other test classes. + */ + @ArchTest + static final ArchRule cacheManagerResetOnlyInTests = noClasses() + .that() + .haveNameNotMatching(".*Test*") + .should() + .callMethod(CacheManager.class, "resetDefaultInstance") + .because( + "CacheManager.resetDefaultInstance() is only intended for testing purposes in CacheTest and must not be used elsewhere"); } diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java new file mode 100644 index 00000000..e020915d --- /dev/null +++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java @@ -0,0 +1,69 @@ +/* Licensed under MIT 2025-2026. */ +package edu.kit.kastel.sdq.lissa.ratlr.cache; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import edu.kit.kastel.sdq.lissa.ratlr.configuration.ModuleConfiguration; + +class CacheTest { + @TempDir + private Path tempCacheDir; + + @BeforeEach + void setup() throws IOException { + // Reset the default cache manager singleton for each test + CacheManager.setCacheDir(tempCacheDir.toString()); + } + + @AfterEach + void teardown() { + // Clean up the cache manager after each test + CacheManager.resetDefaultInstance(); + } + + @Test + void testCacheDirectoryCreatedFromModuleConfiguration() throws IOException { + CacheManager.resetDefaultInstance(); + Path customCacheDir = tempCacheDir.resolve("custom_cache"); + assertFalse(Files.exists(customCacheDir), "Custom cache directory should not exist before initialization"); + assertThrows( + IllegalStateException.class, + CacheManager::getDefaultInstance, + "Default CacheManager instance should be null before initialization"); + + ModuleConfiguration config = new ModuleConfiguration("cache", Map.of("cache_dir", customCacheDir.toString())); + CacheManager.setCacheDir(config); + + assertTrue(Files.exists(customCacheDir), "Custom cache directory should be created after initialization"); + assertDoesNotThrow( + CacheManager::getDefaultInstance, + "Default CacheManager instance should be initialized after setting cache dir"); + } + + @Test + void testCacheDirectoryCreatedFromCacheDir() throws IOException { + CacheManager.resetDefaultInstance(); + Path customCacheDir = tempCacheDir.resolve("custom_cache"); + assertFalse(Files.exists(customCacheDir), "Custom cache directory should not exist before initialization"); + assertThrows( + IllegalStateException.class, + CacheManager::getDefaultInstance, + "Default CacheManager instance should be null before initialization"); + CacheManager.setCacheDir(customCacheDir.toString()); + + assertTrue(Files.exists(customCacheDir), "Custom cache directory should be created after initialization"); + assertDoesNotThrow( + CacheManager::getDefaultInstance, + "Default CacheManager instance should be initialized after setting cache dir"); + } +} From c34b4e5f52463a7c0689f15845579bf63103fc66 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Sat, 18 Apr 2026 15:45:31 +0200 Subject: [PATCH 03/34] feat: make LocalCache implement Cache and revamp CacheReplacementStrategy to be more generic --- .../kastel/sdq/lissa/ratlr/cache/Cache.java | 30 +++++++ .../sdq/lissa/ratlr/cache/CacheManager.java | 3 +- .../ratlr/cache/CacheReplacementStrategy.java | 79 +++++++++++-------- .../sdq/lissa/ratlr/cache/LocalCache.java | 76 ++++++++++++------ .../sdq/lissa/ratlr/cache/RedisCache.java | 42 ++-------- 5 files changed, 134 insertions(+), 96 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java index c72e5d2c..86b1c71c 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java @@ -3,6 +3,9 @@ import org.jspecify.annotations.Nullable; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + /** * Interface for cache implementations in the LiSSA framework. * This interface defines the contract for caching mechanisms that store and retrieve @@ -85,4 +88,31 @@ public interface Cache { * @return The cache parameters */ CacheParameter getCacheParameter(); + + /** + * Converts a JSON string to an object of the specified type. + * If the target type is String, the JSON string is returned as is. + * + * @param The type to convert to + * @param jsonData The JSON string to convert + * @param clazz The class of the target type + * @param mapper The ObjectMapper instance to use for deserialization + * @return The converted object, or null if jsonData is null + * @throws IllegalArgumentException If the JSON cannot be deserialized to the target type + */ + @SuppressWarnings("unchecked") + static @Nullable T convert(@Nullable String jsonData, Class clazz, ObjectMapper mapper) { + if (jsonData == null) { + return null; + } + if (clazz == String.class) { + return (T) jsonData; + } + + try { + return mapper.readValue(jsonData, clazz); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Could not deserialize object", e); + } + } } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index 5dcb7ea5..0527c881 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -27,8 +27,7 @@ public final class CacheManager { * The default strategy for handling cache conflicts between local and Redis caches. * Redis values take precedence over local cache values in case of conflicts. */ - private static final CacheReplacementStrategy DEFAULT_CONFLICT_RESOLUTION = - CacheReplacementStrategy.REPLACE_LOCAL_VALUE; + private static final CacheReplacementStrategy DEFAULT_CONFLICT_RESOLUTION = CacheReplacementStrategy.ERROR; /** * The configuration key for specifying the cache directory in a ModuleConfiguration. diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java index 1e553ec2..a81ee828 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java @@ -1,12 +1,9 @@ /* Licensed under MIT 2025-2026. */ package edu.kit.kastel.sdq.lissa.ratlr.cache; -import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import redis.clients.jedis.UnifiedJedis; - /** * Defines strategies for handling cache value replacement when conflicts occur between Redis and local cache. * A conflict occurs when the same key exists in both caches but with different values. @@ -18,40 +15,51 @@ public enum CacheReplacementStrategy { */ NONE { @Override - public String resolve( - String key, String redisValue, String localValue, LocalCache localCache, @Nullable UnifiedJedis jedis) { - logger.info("Cache inconsistency detected for key {}, keeping both values (returning Redis value)", key); - return redisValue; + public String resolve( + String key, String firstValue, Cache firstCache, String secondValue, Cache secondCache) { + logger.info("Cache inconsistency detected for key {}, keeping both values (returning first value)", key); + return firstValue; } }, - /** - * Replaces the local cache value with the Redis value when a conflict is detected. - * This strategy gives precedence to the Redis cache as the source of truth. - */ - REPLACE_LOCAL_VALUE { + ERROR { @Override - public String resolve( - String key, String redisValue, String localValue, LocalCache localCache, @Nullable UnifiedJedis jedis) { - logger.info("Cache inconsistency detected for key {}, using Redis value and replacing local one", key); - localCache.put(key, redisValue); - return redisValue; + public String resolve( + String key, String firstValue, Cache firstCache, String secondValue, Cache secondCache) { + logger.error( + "Cache inconsistency detected for key {}, values: {} (first cache), {} (second cache)", + key, + firstValue, + secondValue); + throw new IllegalStateException("Cache inconsistency detected for key " + key); } }, - /** - * Replaces the Redis cache value with the local cache value when a conflict is detected. - * This strategy gives precedence to the local cache as the source of truth. - */ - REPLACE_REDIS_VALUE { + OVERWRITE_FIRST { + @Override + public String resolve( + String key, String firstValue, Cache firstCache, String secondValue, Cache secondCache) { + logger.warn( + "Cache inconsistency detected for key {}, overwriting first cache value with second cache value: {} -> {}", + key, + firstValue, + secondValue); + firstCache.put(key, secondValue); + return secondValue; + } + }, + + OVERWRITE_SECOND { @Override - public String resolve( - String key, String redisValue, String localValue, LocalCache localCache, @Nullable UnifiedJedis jedis) { - logger.info("Cache inconsistency detected for key {}, using local value and replacing Redis one", key); - if (jedis != null) { - jedis.hset(key, "data", localValue); - } - return localValue; + public String resolve( + String key, String firstValue, Cache firstCache, String secondValue, Cache secondCache) { + logger.warn( + "Cache inconsistency detected for key {}, overwriting second cache value with first cache value: {} -> {}", + key, + secondValue, + firstValue); + secondCache.put(key, firstValue); + return firstValue; } }; @@ -60,14 +68,15 @@ public String resolve( /** * Resolves a conflict between Redis and local cache values by applying the appropriate replacement strategy. * + * @param The type of cache key used in both caches * @param key The cache key where the conflict occurred - * @param redisValue The value from Redis - * @param localValue The value from the local cache - * @param localCache The local cache instance - * @param jedis The Redis client instance (may be null) + * @param firstValue The value of the first cache + * @param firstCache The first cache where the value was found + * @param secondValue The value of the second cache + * @param secondCache The second cache where the value was found * * @return The resolved cache value to be used */ - public abstract String resolve( - String key, String redisValue, String localValue, LocalCache localCache, @Nullable UnifiedJedis jedis); + public abstract String resolve( + String key, String firstValue, Cache firstCache, String secondValue, Cache secondCache); } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java index 5c8b3091..ee1ac69e 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java @@ -10,6 +10,7 @@ import org.jspecify.annotations.Nullable; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -18,8 +19,10 @@ * This class provides a thread-safe implementation of a cache that persists its contents * to a JSON file. It includes automatic flushing of changes when a certain threshold * of modifications is reached. + * + * @param The type of cache key used in this cache */ -class LocalCache { +class LocalCache implements Cache { private final ObjectMapper mapper; /** @@ -47,6 +50,7 @@ class LocalCache { * or a new file will be created. * * @param cacheFile The path to the cache file + * @param cacheParameter The cache parameter configuration */ LocalCache(String cacheFile, CacheParameter cacheParameter) { this.cacheParameter = cacheParameter; @@ -114,15 +118,11 @@ public synchronized void write() { } } - /** - * Retrieves a value from the cache. - * - * @param key The cache key to look up - * @return The cached value, or null if not found - */ - public synchronized @Nullable String get(String key) { + @Override + public synchronized @Nullable T get(String key, Class clazz) { K cacheKey = cacheParameter.createCacheKey(key); - return cache.get(cacheKey.localKey()); + String jsonData = cache.get(cacheKey.localKey()); + return Cache.convert(jsonData, clazz, mapper); } /** @@ -132,19 +132,14 @@ public synchronized void write() { * @return The cached value, or null if not found * @deprecated This method exposes internal cache key handling and should not be used in general code. */ + @Override @Deprecated(forRemoval = false) - public synchronized @Nullable String getViaInternalKey(K key) { - return cache.get(key.localKey()); + public synchronized @Nullable T getViaInternalKey(K key, Class clazz) { + String jsonData = cache.get(key.localKey()); + return Cache.convert(jsonData, clazz, mapper); } - /** - * Stores a value in the cache. - * If the value is different from the existing value (if any), the dirty counter is incremented. - * If the dirty counter exceeds the maximum threshold, the cache is automatically flushed to disk. - * - * @param key The cache key to store the value under - * @param value The value to store - */ + @Override public synchronized void put(String key, String value) { K cacheKey = cacheParameter.createCacheKey(key); String old = cache.put(cacheKey.localKey(), value); @@ -166,29 +161,60 @@ public synchronized void put(String key, String value) { * @param value The value to store * @deprecated This method exposes internal cache key handling and should not be used in general code. */ + @Override @Deprecated(forRemoval = false) - public synchronized void putViaInternalKey(K cacheKey, String value) { - String old = cache.put(cacheKey.localKey(), value); - if (old == null || !old.equals(value)) { - dirty++; + public synchronized void putViaInternalKey(K cacheKey, T value) { + try { + String jsonValue = mapper.writeValueAsString(Objects.requireNonNull(value)); + String old = cache.put(cacheKey.localKey(), jsonValue); + if (old == null || !old.equals(jsonValue)) { + dirty++; + } + + if (dirty > MAX_DIRTY) { + write(); + } + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Could not serialize object", e); } + } - if (dirty > MAX_DIRTY) { - write(); + @Override + public synchronized void put(String key, T value) { + try { + String jsonValue = mapper.writeValueAsString(Objects.requireNonNull(value)); + K cacheKey = cacheParameter.createCacheKey(key); + String old = cache.put(cacheKey.localKey(), jsonValue); + if (old == null || !old.equals(jsonValue)) { + dirty++; + } + + if (dirty > MAX_DIRTY) { + write(); + } + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Could not serialize object", e); } } + @Override + public void flush() { + write(); + } + /** * Returns true if and only if this map contains a mapping for a key * * @param key The cache key to look up * @return true if this map contains a mapping for the specified key */ + @Override public boolean containsKey(String key) { K cacheKey = cacheParameter.createCacheKey(key); return cache.containsKey(cacheKey.localKey()); } + @Override public CacheParameter getCacheParameter() { return this.cacheParameter; } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java index 4fd8285c..3bd6fbb0 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java @@ -128,9 +128,9 @@ public synchronized T get(String key, Class clazz) { K cacheKey = cacheParameter.createCacheKey(key); String jsonData = jedis == null ? null : jedis.hget(cacheKey.toJsonKey(), "data"); if (localCache == null) { - return convert(jsonData, clazz); + return Cache.convert(jsonData, clazz, mapper); } - String localData = localCache.get(key); + String localData = localCache.get(key, String.class); // Value is in redis cache but not in local cache if (localData == null && jsonData != null) { localCache.put(key, jsonData); @@ -142,11 +142,11 @@ public synchronized T get(String key, Class clazz) { String valueToReturn; if (jsonData != null && localData != null && !jsonData.equals(localData)) { // Value is in both caches, but they differ - apply conflict resolution strategy - valueToReturn = conflictResolution.resolve(key, jsonData, localData, localCache, jedis); + valueToReturn = conflictResolution.resolve(key, jsonData, localCache, localData, this); } else { valueToReturn = jsonData != null ? jsonData : localData; } - return convert(valueToReturn, clazz); + return Cache.convert(valueToReturn, clazz, mapper); } @Override @@ -154,9 +154,9 @@ public synchronized T get(String key, Class clazz) { public synchronized @Nullable T getViaInternalKey(K cacheKey, Class clazz) { String jsonData = jedis == null ? null : jedis.hget(cacheKey.toJsonKey(), "data"); if (localCache == null) { - return convert(jsonData, clazz); + return Cache.convert(jsonData, clazz, mapper); } - String localData = localCache.getViaInternalKey(cacheKey); + String localData = localCache.getViaInternalKey(cacheKey, String.class); // Value is in redis cache but not in local cache if (localData == null && jsonData != null) { localCache.putViaInternalKey(cacheKey, jsonData); @@ -168,37 +168,11 @@ public synchronized T get(String key, Class clazz) { String valueToReturn; // Value is in both caches, but they differ if (jsonData != null && localData != null && !jsonData.equals(localData)) { - valueToReturn = conflictResolution.resolve(cacheKey.toJsonKey(), jsonData, localData, localCache, jedis); + valueToReturn = conflictResolution.resolve(cacheKey.toJsonKey(), localData, localCache, jsonData, this); } else { valueToReturn = jsonData != null ? jsonData : localData; } - return convert(valueToReturn, clazz); - } - - /** - * Converts a JSON string to an object of the specified type. - * If the target type is String, the JSON string is returned as is. - * - * @param The type to convert to - * @param jsonData The JSON string to convert - * @param clazz The class of the target type - * @return The converted object, or null if jsonData is null - * @throws IllegalArgumentException If the JSON cannot be deserialized to the target type - */ - @SuppressWarnings("unchecked") - private @Nullable T convert(@Nullable String jsonData, Class clazz) { - if (jsonData == null) { - return null; - } - if (clazz == String.class) { - return (T) jsonData; - } - - try { - return mapper.readValue(jsonData, clazz); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("Could not deserialize object", e); - } + return Cache.convert(valueToReturn, clazz, mapper); } /** From cc8c041ed1c4badc625e39a74b0b7e0037052021 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:11:01 +0200 Subject: [PATCH 04/34] feat: retrieve cache replacement strategy from ModuleConfiguration --- .../sdq/lissa/ratlr/cache/CacheManager.java | 14 +++++++---- .../configuration/ModuleConfiguration.java | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index 0527c881..202da574 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -36,6 +36,7 @@ public final class CacheManager { private static @Nullable CacheManager defaultInstanceManager; private final Path directoryOfCaches; + private final CacheReplacementStrategy conflictResolution; private final Map> caches = new HashMap<>(); /** @@ -50,29 +51,34 @@ public static synchronized void setCacheDir(@Nullable String directory) throws I if (directory == null) { directory = DEFAULT_CACHE_DIRECTORY; } + ModuleConfiguration config = new ModuleConfiguration("cache", Map.of(CACHE_DIR_CONFIGURATION_KEY, directory)); setCacheDir(config); } public static synchronized void setCacheDir(ModuleConfiguration configuration) throws IOException { String cacheDir = configuration.argumentAsString(CACHE_DIR_CONFIGURATION_KEY, DEFAULT_CACHE_DIRECTORY); - defaultInstanceManager = new CacheManager(Path.of(cacheDir)); + CacheReplacementStrategy conflictResolution = configuration.argumentAsEnum( + "conflict_resolution", DEFAULT_CONFLICT_RESOLUTION, CacheReplacementStrategy.class); + defaultInstanceManager = new CacheManager(Path.of(cacheDir), conflictResolution); } /** - * Creates a new cache manager instance using the specified cache directory. + * Creates a new cache manager instance using the specified cache directory and conflict resolution strategy. * The directory will be created if it doesn't exist. * * @param cacheDir The path to the cache directory + * @param conflictResolution The strategy for resolving conflicts between caches * @throws IOException If the cache directory cannot be created * @throws IllegalArgumentException If the path exists but is not a directory */ - public CacheManager(Path cacheDir) throws IOException { + public CacheManager(Path cacheDir, CacheReplacementStrategy conflictResolution) throws IOException { if (!Files.exists(cacheDir)) Files.createDirectories(cacheDir); if (!Files.isDirectory(cacheDir)) { throw new IllegalArgumentException("path is not a directory: " + cacheDir); } this.directoryOfCaches = cacheDir; + this.conflictResolution = conflictResolution; } /** @@ -139,7 +145,7 @@ private Cache getCache(String name, CacheParameter pa } LocalCache localCache = new LocalCache<>(directoryOfCaches + "/" + name + ".json", parameters); - RedisCache cache = new RedisCache<>(parameters, localCache, DEFAULT_CONFLICT_RESOLUTION); + RedisCache cache = new RedisCache<>(parameters, localCache, conflictResolution); caches.put(name, cache); return cache; } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/ModuleConfiguration.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/ModuleConfiguration.java index b4841f57..7a082a92 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/ModuleConfiguration.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/ModuleConfiguration.java @@ -237,6 +237,30 @@ public > String argumentAsStringByEnumIndex( return value; } + /** + * Retrieves an argument as an enum value by name, using a default value if not found. + * The argument value must match one of the enum constant names (case-insensitive). + * + * @param The enum type + * @param key The key of the argument to retrieve + * @param enumClass The class of the enum type + * @param defaultValue The default enum value to use if the argument is not found + * @return The enum value corresponding to the argument, or the default value + * @throws IllegalStateException If the configuration has been finalized + * @throws IllegalArgumentException If the argument value doesn't match any enum constant + */ + public > E argumentAsEnum(String key, E defaultValue, Class enumClass) { + String value = argumentAsString(key, defaultValue.name()); + try { + return Enum.valueOf(enumClass, value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Argument with key " + key + " has value '" + value + "' which is not a valid " + + enumClass.getSimpleName() + " enum constant", + e); + } + } + /** * Creates a new ModuleConfiguration with the specified argument modified. * This method creates a copy of the current configuration with one argument changed, From 5186194763812184a296ef18849235ceedbbb5f9 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:55:28 +0200 Subject: [PATCH 05/34] feat: implement HierarchicalCache for multi-layer caching and update RedisCache to simplify as a regular cache --- .../sdq/lissa/ratlr/cache/CacheManager.java | 20 ++- .../ratlr/cache/CacheReplacementStrategy.java | 75 ++++++-- .../lissa/ratlr/cache/HierarchicalCache.java | 109 ++++++++++++ .../sdq/lissa/ratlr/cache/RedisCache.java | 160 ++++-------------- 4 files changed, 218 insertions(+), 146 deletions(-) create mode 100644 src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index 202da574..de0490cb 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -9,8 +9,12 @@ import org.jspecify.annotations.Nullable; +import com.fasterxml.jackson.databind.ObjectMapper; + import edu.kit.kastel.sdq.lissa.ratlr.configuration.ModuleConfiguration; +import redis.clients.jedis.exceptions.JedisConnectionException; + /** * Manages caching operations in the LiSSA framework. * This class provides a centralized way to create and access caches for different purposes, @@ -37,7 +41,7 @@ public final class CacheManager { private static @Nullable CacheManager defaultInstanceManager; private final Path directoryOfCaches; private final CacheReplacementStrategy conflictResolution; - private final Map> caches = new HashMap<>(); + private final Map> caches = new HashMap<>(); /** * Sets the cache directory for the default cache manager instance. @@ -144,10 +148,18 @@ private Cache getCache(String name, CacheParameter pa return cached; } + ObjectMapper mapper = new ObjectMapper(); LocalCache localCache = new LocalCache<>(directoryOfCaches + "/" + name + ".json", parameters); - RedisCache cache = new RedisCache<>(parameters, localCache, conflictResolution); - caches.put(name, cache); - return cache; + try { + RedisCache redisCache = new RedisCache<>(parameters, mapper); + HierarchicalCache cache = + new HierarchicalCache<>(parameters, redisCache, localCache, conflictResolution); + caches.put(name, cache); + return cache; + } catch (JedisConnectionException e) { + caches.put(name, localCache); + return localCache; + } } /** diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java index a81ee828..4f96c384 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java @@ -1,6 +1,7 @@ /* Licensed under MIT 2025-2026. */ package edu.kit.kastel.sdq.lissa.ratlr.cache; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,19 +14,23 @@ public enum CacheReplacementStrategy { * Does not replace conflicting values - leaves both cache values as they are. * The Redis value will be returned when reading. */ - NONE { - @Override - public String resolve( - String key, String firstValue, Cache firstCache, String secondValue, Cache secondCache) { - logger.info("Cache inconsistency detected for key {}, keeping both values (returning first value)", key); - return firstValue; - } - }, + NONE, ERROR { + /** + * Throws an exception when a conflict is detected between the two caches. + */ @Override - public String resolve( - String key, String firstValue, Cache firstCache, String secondValue, Cache secondCache) { + public @Nullable T resolve( + String key, + @Nullable T firstValue, + Cache firstCache, + @Nullable T secondValue, + Cache secondCache) { + super.resolve(key, firstValue, firstCache, secondValue, secondCache); + if (firstValue == secondValue) { + return firstValue; + } logger.error( "Cache inconsistency detected for key {}, values: {} (first cache), {} (second cache)", key, @@ -36,9 +41,20 @@ public String resolve( }, OVERWRITE_FIRST { + /** + * Overwrites the first cache value with the second cache value in case of a conflict, and returns the second cache value. + */ @Override - public String resolve( - String key, String firstValue, Cache firstCache, String secondValue, Cache secondCache) { + public @Nullable T resolve( + String key, + @Nullable T firstValue, + Cache firstCache, + @Nullable T secondValue, + Cache secondCache) { + super.resolve(key, firstValue, firstCache, secondValue, secondCache); + if (firstValue == secondValue) { + return firstValue; + } logger.warn( "Cache inconsistency detected for key {}, overwriting first cache value with second cache value: {} -> {}", key, @@ -50,9 +66,20 @@ public String resolve( }, OVERWRITE_SECOND { + /** + * Overwrites the second cache value with the first cache value in case of a conflict, and returns the first cache value. + */ @Override - public String resolve( - String key, String firstValue, Cache firstCache, String secondValue, Cache secondCache) { + public @Nullable T resolve( + String key, + @Nullable T firstValue, + Cache firstCache, + @Nullable T secondValue, + Cache secondCache) { + super.resolve(key, firstValue, firstCache, secondValue, secondCache); + if (firstValue == secondValue) { + return firstValue; + } logger.warn( "Cache inconsistency detected for key {}, overwriting second cache value with first cache value: {} -> {}", key, @@ -66,17 +93,29 @@ public String resolve( private static final Logger logger = LoggerFactory.getLogger(CacheReplacementStrategy.class); /** - * Resolves a conflict between Redis and local cache values by applying the appropriate replacement strategy. + * Resolves a conflict between two caches by applying the appropriate replacement strategy. + * If a value is null in one cache but not the other, it will be copied to the cache where it is missing. + *

+ * The default implementation does not perform any replacement and simply returns the first value. * * @param The type of cache key used in both caches + * @param The type of the cache values * @param key The cache key where the conflict occurred * @param firstValue The value of the first cache * @param firstCache The first cache where the value was found * @param secondValue The value of the second cache * @param secondCache The second cache where the value was found * - * @return The resolved cache value to be used + * @return The resolved cache value to be used (may be null) */ - public abstract String resolve( - String key, String firstValue, Cache firstCache, String secondValue, Cache secondCache); + public @Nullable T resolve( + String key, @Nullable T firstValue, Cache firstCache, @Nullable T secondValue, Cache secondCache) { + if (firstValue == null && secondValue != null) { + firstCache.put(key, secondValue); + } + if (firstValue != null && secondValue == null) { + secondCache.put(key, firstValue); + } + return firstValue; + } } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java new file mode 100644 index 00000000..da8b40c8 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java @@ -0,0 +1,109 @@ +/* Licensed under MIT 2025-2026. */ +package edu.kit.kastel.sdq.lissa.ratlr.cache; + +import org.jspecify.annotations.Nullable; + +/** + * Implements a hierarchical cache that composes multiple cache implementations. + * This class manages synchronization and conflict resolution between multiple cache layers + * (e.g., Redis and local file cache), providing a unified view across the cache hierarchy. + *

+ * The cache hierarchy operates as follows: + * 1. Attempts to retrieve/store values in the primary cache + * 2. Falls back to secondary cache if primary is unavailable + * 3. Automatically synchronizes values between layers when needed + * 4. Applies conflict resolution strategy when values differ between layers + * + * @param The type of cache key used in this cache + */ +class HierarchicalCache implements Cache { + + private final CacheParameter cacheParameter; + + /** + * Primary cache in the hierarchy (typically Redis). + */ + private final Cache primaryCache; + + /** + * Secondary cache in the hierarchy (typically local file cache). + */ + private final Cache secondaryCache; + + /** + * Strategy for resolving conflicts between cache layers. + */ + private final CacheReplacementStrategy conflictResolution; + + /** + * Creates a new hierarchical cache instance. + * + * @param cacheParameter The cache parameter configuration + * @param primaryCache The primary cache (e.g., Redis), or null if not available + * @param secondaryCache The secondary cache (e.g., local file), or null if not available + * @param conflictResolution Strategy for resolving conflicts between cache layers + */ + HierarchicalCache( + CacheParameter cacheParameter, + Cache primaryCache, + Cache secondaryCache, + CacheReplacementStrategy conflictResolution) { + this.cacheParameter = cacheParameter; + this.primaryCache = primaryCache; + this.secondaryCache = secondaryCache; + this.conflictResolution = conflictResolution; + } + + @Override + public synchronized @Nullable T get(String key, Class clazz) { + T primaryValue = primaryCache.get(key, clazz); + T secondaryValue = secondaryCache.get(key, clazz); + return conflictResolution.resolve(key, primaryValue, primaryCache, secondaryValue, secondaryCache); + } + + @Override + @SuppressWarnings("deprecation") + public synchronized @Nullable T getViaInternalKey(K key, Class clazz) { + T primaryValue = primaryCache.getViaInternalKey(key, clazz); + T secondaryValue = secondaryCache.getViaInternalKey(key, clazz); + return conflictResolution.resolve(key.toJsonKey(), primaryValue, primaryCache, secondaryValue, secondaryCache); + } + + @Override + public synchronized void put(String key, String value) { + primaryCache.put(key, value); + secondaryCache.put(key, value); + } + + @Override + @SuppressWarnings("deprecation") + public synchronized void putViaInternalKey(K key, T value) { + primaryCache.putViaInternalKey(key, value); + secondaryCache.putViaInternalKey(key, value); + } + + @Override + public synchronized void put(String key, T value) { + primaryCache.put(key, value); + secondaryCache.put(key, value); + } + + @Override + public void flush() { + primaryCache.flush(); + secondaryCache.flush(); + } + + @Override + public boolean containsKey(String key) { + if (primaryCache.containsKey(key)) { + return true; + } + return secondaryCache.containsKey(key); + } + + @Override + public CacheParameter getCacheParameter() { + return cacheParameter; + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java index 3bd6fbb0..8c2f353d 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java @@ -16,168 +16,90 @@ import redis.clients.jedis.UnifiedJedis; /** - * Implements a Redis-based cache with local file backup. - * This class provides a caching mechanism that primarily uses Redis for storage, - * with a local file cache as a fallback. It supports storing and retrieving both - * string values and serialized objects. + * Implements a Redis-based cache for storing and retrieving values. For multi-layer caching with + * synchronization and conflict resolution, use {@link HierarchicalCache}. *

- * The cache can operate in three modes: - * 1. Redis-only: When Redis is available and local cache is not configured - * 2. Local-only: When Redis is unavailable and local cache is configured - * 3. Hybrid: When both Redis and local cache are available (default) + * The cache will fail to initialize if Redis is unavailable. + * + * @param The type of cache key used in this cache */ class RedisCache implements Cache { private static final Logger logger = LoggerFactory.getLogger(RedisCache.class); private final CacheParameter cacheParameter; - - /** - * Local file-based cache used as a backup. - */ - private final @Nullable LocalCache localCache; - private final ObjectMapper mapper; /** * Redis client instance. */ - private @Nullable UnifiedJedis jedis; - - /** - * Strategy for resolving conflicts between Redis and local cache values. - */ - private final CacheReplacementStrategy conflictResolution; + private UnifiedJedis jedis; /** - * Creates a new Redis cache instance with an optional local cache backup. + * Creates a new Redis cache instance. + * This constructor will throw an exception if Redis is unavailable. * - * @param localCache The local cache to use as backup, or null if no backup is needed - * @param conflictResolution Strategy for resolving conflicts between Redis and local cache values - * @throws IllegalArgumentException If neither Redis nor local cache can be initialized + * @param cacheParameter The cache parameter configuration + * @param mapper The ObjectMapper for JSON operations + * @throws IllegalArgumentException If Redis connection cannot be established */ - RedisCache( - CacheParameter cacheParameter, - @Nullable LocalCache localCache, - CacheReplacementStrategy conflictResolution) { + RedisCache(CacheParameter cacheParameter, ObjectMapper mapper) { this.cacheParameter = Objects.requireNonNull(cacheParameter); - this.localCache = localCache == null || !localCache.isReady() ? null : localCache; - if (this.localCache != null && !this.getCacheParameter().equals(this.localCache.getCacheParameter())) { - throw new IllegalArgumentException("Cache parameter of local cache does not match the one of Redis cache"); - } - - mapper = new ObjectMapper(); + this.mapper = Objects.requireNonNull(mapper); createRedisConnection(); - if (jedis == null && this.localCache == null) { - throw new IllegalArgumentException("Could not create cache"); + if (jedis == null) { + throw new IllegalArgumentException("Could not connect to Redis"); } - this.conflictResolution = conflictResolution; } @Override public void flush() { - if (localCache != null) { - localCache.write(); - } + // Redis doesn't require manual flushing } @Override public boolean containsKey(String key) { K cacheKey = cacheParameter.createCacheKey(key); - if (jedis != null && jedis.exists(cacheKey.toJsonKey())) { - return true; - } - return localCache != null && localCache.containsKey(key); + return jedis.exists(cacheKey.toJsonKey()); } /** * Establishes a connection to the Redis server. * The Redis URL can be configured through the REDIS_URL environment variable. - * If the connection fails, the cache will fall back to using only the local cache. */ private void createRedisConnection() { - try { - String redisUrl = "redis://localhost:6379"; - if (Environment.getenv("REDIS_URL") != null) { - redisUrl = Environment.getenv("REDIS_URL"); - } - jedis = new UnifiedJedis(redisUrl); - // Check if connection is working - jedis.ping(); - } catch (Exception e) { - logger.warn("Could not connect to Redis, using file cache instead"); - jedis = null; + String redisUrl = "redis://localhost:6379"; + if (Environment.getenv("REDIS_URL") != null) { + redisUrl = Environment.getenv("REDIS_URL"); } + jedis = new UnifiedJedis(redisUrl); + // Check if connection is working + jedis.ping(); } /** * Retrieves a value from the cache and deserializes it to the specified type. - * The method first attempts to retrieve the value from Redis, and if not found, - * falls back to the local cache. - * If the value is found in the local cache and Redis is available, it will be synchronized to Redis. - * If the value is found in Redis and the local cache is available, it will be synchronized to the local cache. - * In case of a mismatch between Redis and local cache values, a warning is logged and the configured - * {@link #conflictResolution} strategy is applied. * * @param The type to deserialize the value to * @param key The cache key to look up * @param clazz The class of the type to deserialize to - * @return The deserialized value, or null if not found + * @return The deserialized value, or null if not found or Redis is unavailable */ @Override - public synchronized T get(String key, Class clazz) { + public synchronized @Nullable T get(String key, Class clazz) { K cacheKey = cacheParameter.createCacheKey(key); - String jsonData = jedis == null ? null : jedis.hget(cacheKey.toJsonKey(), "data"); - if (localCache == null) { - return Cache.convert(jsonData, clazz, mapper); - } - String localData = localCache.get(key, String.class); - // Value is in redis cache but not in local cache - if (localData == null && jsonData != null) { - localCache.put(key, jsonData); - } - // Value is in local cache but not in redis cache - if (localData != null && jsonData == null && jedis != null) { - jedis.hset(cacheKey.toJsonKey(), "data", localData); - } - String valueToReturn; - if (jsonData != null && localData != null && !jsonData.equals(localData)) { - // Value is in both caches, but they differ - apply conflict resolution strategy - valueToReturn = conflictResolution.resolve(key, jsonData, localCache, localData, this); - } else { - valueToReturn = jsonData != null ? jsonData : localData; - } - return Cache.convert(valueToReturn, clazz, mapper); + String jsonData = jedis.hget(cacheKey.toJsonKey(), "data"); + return Cache.convert(jsonData, clazz, mapper); } @Override @SuppressWarnings("deprecation") - public synchronized @Nullable T getViaInternalKey(K cacheKey, Class clazz) { - String jsonData = jedis == null ? null : jedis.hget(cacheKey.toJsonKey(), "data"); - if (localCache == null) { - return Cache.convert(jsonData, clazz, mapper); - } - String localData = localCache.getViaInternalKey(cacheKey, String.class); - // Value is in redis cache but not in local cache - if (localData == null && jsonData != null) { - localCache.putViaInternalKey(cacheKey, jsonData); - } - // Value is in local cache but not in redis cache - if (localData != null && jsonData == null && jedis != null) { - jedis.hset(cacheKey.toJsonKey(), "data", localData); - } - String valueToReturn; - // Value is in both caches, but they differ - if (jsonData != null && localData != null && !jsonData.equals(localData)) { - valueToReturn = conflictResolution.resolve(cacheKey.toJsonKey(), localData, localCache, jsonData, this); - } else { - valueToReturn = jsonData != null ? jsonData : localData; - } - return Cache.convert(valueToReturn, clazz, mapper); + public @Nullable T getViaInternalKey(K cacheKey, Class clazz) { + String jsonData = jedis.hget(cacheKey.toJsonKey(), "data"); + return Cache.convert(jsonData, clazz, mapper); } /** * Stores a string value in the cache. - * The value is stored in both Redis (if available) and the local cache (if configured). * When storing in Redis, a timestamp is also recorded. * * @param key The cache key to store the value under @@ -186,14 +108,9 @@ public synchronized T get(String key, Class clazz) { @Override public synchronized void put(String key, String value) { K cacheKey = cacheParameter.createCacheKey(key); - if (jedis != null) { - String jsonKey = cacheKey.toJsonKey(); - jedis.hset(jsonKey, "data", value); - jedis.hset(jsonKey, "timestamp", String.valueOf(Instant.now().getEpochSecond())); - } - if (localCache != null) { - localCache.put(key, value); - } + String jsonKey = cacheKey.toJsonKey(); + jedis.hset(jsonKey, "data", value); + jedis.hset(jsonKey, "timestamp", String.valueOf(Instant.now().getEpochSecond())); } /** @@ -224,14 +141,9 @@ public synchronized void putViaInternalKey(K key, T value) { } catch (JsonProcessingException e) { throw new IllegalArgumentException("Could not serialize object", e); } - if (jedis != null) { - String jsonKey = key.toJsonKey(); - jedis.hset(jsonKey, "data", data); - jedis.hset(jsonKey, "timestamp", String.valueOf(Instant.now().getEpochSecond())); - } - if (localCache != null) { - localCache.putViaInternalKey(key, data); - } + String jsonKey = key.toJsonKey(); + jedis.hset(jsonKey, "data", data); + jedis.hset(jsonKey, "timestamp", String.valueOf(Instant.now().getEpochSecond())); } @Override From b239c1452916099df296d3355b9fa6f2566b8b33 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:49:19 +0200 Subject: [PATCH 06/34] docs: update caching documentation to include HierarchicalCache --- docs/caching.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/caching.md b/docs/caching.md index 599bbed3..97641857 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -15,13 +15,16 @@ LiSSA implements a sophisticated caching system to improve performance and ensur - [`ClassifierCacheParameter`](../src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/classifier/ClassifierCacheParameter.java): Configuration for classifier caches (model name, seed, temperature) - [`EmbeddingCacheParameter`](../src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/embedding/EmbeddingCacheParameter.java): Configuration for embedding caches (model name) 2. **Cache Implementations** + - [`Hierarchical Cache`](../src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java): Two cache levels with a synchronization mechanism + - Changes are applied to both levels + - Reads use a Conflict Resolution Strategy to ensure consistent results + - If a cache entry is missing in one level during a read, it is also written to the other level - [`LocalCache`](../src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java): File-based cache implementation that stores data in JSON format - Implements dirty tracking to optimize writes - Automatically saves changes on shutdown - Supports atomic writes using temporary files - - [`RedisCache`](../src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java): Redis-based cache implementation with fallback to local cache + - [`RedisCache`](../src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java): Redis-based cache implementation - Uses Redis for high-performance caching - - Falls back to local cache if Redis is unavailable - Supports both string and object serialization 3. **Cache Management** - [`CacheManager`](../src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java): Central manager for cache instances From b4aebe616409f1bdc44a5d56733cc57c57fdf52e Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:31:24 +0200 Subject: [PATCH 07/34] feat: support environment-based cache configuration --- env-template | 39 ++++-- .../kastel/sdq/lissa/ratlr/cache/Cache.java | 30 +++++ .../sdq/lissa/ratlr/cache/CacheManager.java | 126 ++++++++++++++++-- .../ratlr/cache/CacheReplacementStrategy.java | 112 +++++++--------- .../sdq/lissa/ratlr/cache/LocalCache.java | 3 + 5 files changed, 222 insertions(+), 88 deletions(-) diff --git a/env-template b/env-template index 1dfc0d5d..af62b87a 100644 --- a/env-template +++ b/env-template @@ -1,13 +1,26 @@ -OPENWEBUI_URL=https://domain.tldr/api -OPENWEBUI_API_KEY= - -OLLAMA_EMBEDDING_HOST= -#OLLAMA_EMBEDDING_PASSWORD= -#OLLAMA_EMBEDDING_USER= - -OLLAMA_HOST= -#OLLAMA_PASSWORD= -#OLLAMA_USER= - -OPENAI_ORGANIZATION_ID= -OPENAI_API_KEY= +OPENWEBUI_URL=https://domain.tldr/api +OPENWEBUI_API_KEY= + +OLLAMA_EMBEDDING_HOST= +#OLLAMA_EMBEDDING_PASSWORD= +#OLLAMA_EMBEDDING_USER= + +OLLAMA_HOST= +#OLLAMA_PASSWORD= +#OLLAMA_USER= + +OPENAI_ORGANIZATION_ID= +OPENAI_API_KEY= + +# Cache strategy for handling conflicts between different cache levels +# Valid values: NONE, ERROR, OVERWRITE_SECONDARY +#CACHE_CONFLICT_RESOLUTION=ERROR + +# Cache hierarchy - comma-separated list of cache types to use +# Examples: +# LOCAL - only local file-based cache +# LOCAL,REDIS - local cache as primary, Redis as secondary fallback +# REDIS,LOCAL - Redis as primary, local cache as secondary fallback +# Valid cache types: LOCAL, REDIS +#CACHE_HIERARCHY=REDIS,LOCAL + diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java index 86b1c71c..42ed40d3 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java @@ -1,6 +1,8 @@ /* Licensed under MIT 2025-2026. */ package edu.kit.kastel.sdq.lissa.ratlr.cache; +import static edu.kit.kastel.sdq.lissa.ratlr.cache.LocalCache.LOCAL_CACHE_NAME; + import org.jspecify.annotations.Nullable; import com.fasterxml.jackson.core.JsonProcessingException; @@ -115,4 +117,32 @@ public interface Cache { throw new IllegalArgumentException("Could not deserialize object", e); } } + + /** + * Factory method to create a cache instance by type name. + * Supported types: + *

    + *
  • "local" - LocalCache for file-based storage
  • + *
  • "redis" - RedisCache for Redis-based storage
  • + *
  • "hierarchical" - HierarchicalCache for multi-layer caching (requires primary and secondary cache)
  • + *
+ * + * @param The type of cache key + * @param type The cache type name (case-insensitive) + * @param cacheDir The directory for local cache storage + * @param parameters The cache parameters + * @param mapper The ObjectMapper for JSON operations + * @return A cache instance of the specified type + * @throws IllegalArgumentException If the type is not recognized or the cache cannot be created + */ + static Cache createByType( + String type, CacheParameter parameters, @Nullable String cacheDir, @Nullable ObjectMapper mapper) { + return switch (type) { + case LOCAL_CACHE_NAME -> new LocalCache<>(cacheDir, parameters); + case "redis" -> new RedisCache<>(parameters, mapper); + default -> + throw new IllegalArgumentException( + "Unknown cache type: " + type + ". Supported types: local, redis, hierarchical"); + }; + } } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index de0490cb..1d4561bd 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -4,14 +4,19 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.ObjectMapper; import edu.kit.kastel.sdq.lissa.ratlr.configuration.ModuleConfiguration; +import edu.kit.kastel.sdq.lissa.ratlr.utils.Environment; import redis.clients.jedis.exceptions.JedisConnectionException; @@ -27,9 +32,13 @@ public final class CacheManager { */ public static final String DEFAULT_CACHE_DIRECTORY = "cache"; + /** + * The default cache hierarchy: LOCAL only. + */ + private static final String DEFAULT_CACHE_HIERARCHY = "LOCAL"; + /** * The default strategy for handling cache conflicts between local and Redis caches. - * Redis values take precedence over local cache values in case of conflicts. */ private static final CacheReplacementStrategy DEFAULT_CONFLICT_RESOLUTION = CacheReplacementStrategy.ERROR; @@ -43,6 +52,8 @@ public final class CacheManager { private final CacheReplacementStrategy conflictResolution; private final Map> caches = new HashMap<>(); + private static final Logger logger = LoggerFactory.getLogger(CacheManager.class); + /** * Sets the cache directory for the default cache manager instance. * This method must be called before using the default instance. @@ -62,11 +73,37 @@ public static synchronized void setCacheDir(@Nullable String directory) throws I public static synchronized void setCacheDir(ModuleConfiguration configuration) throws IOException { String cacheDir = configuration.argumentAsString(CACHE_DIR_CONFIGURATION_KEY, DEFAULT_CACHE_DIRECTORY); - CacheReplacementStrategy conflictResolution = configuration.argumentAsEnum( - "conflict_resolution", DEFAULT_CONFLICT_RESOLUTION, CacheReplacementStrategy.class); + CacheReplacementStrategy conflictResolution = readConflictResolutionStrategy(); defaultInstanceManager = new CacheManager(Path.of(cacheDir), conflictResolution); } + /** + * Reads the cache conflict resolution strategy from environment variables. + * This method: + *
    + *
  1. First checks the environment variable CACHE_CONFLICT_RESOLUTION
  2. + *
  3. If not found, uses the default strategy ({@link #DEFAULT_CONFLICT_RESOLUTION})
  4. + *
+ * + * @return The cache conflict resolution strategy + * @throws IllegalArgumentException If the environment variable value is set but invalid + */ + private static CacheReplacementStrategy readConflictResolutionStrategy() { + String strategyValue = Environment.getenv("CACHE_CONFLICT_RESOLUTION"); + if (strategyValue == null) { + return DEFAULT_CONFLICT_RESOLUTION; + } + + try { + return CacheReplacementStrategy.valueOf(strategyValue.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Invalid CACHE_CONFLICT_RESOLUTION value: " + strategyValue + ". See " + + CacheReplacementStrategy.class + " for valid options.", + e); + } + } + /** * Creates a new cache manager instance using the specified cache directory and conflict resolution strategy. * The directory will be created if it doesn't exist. @@ -148,18 +185,81 @@ private Cache getCache(String name, CacheParameter pa return cached; } + Cache cache = buildCacheHierarchy(name, parameters); + caches.put(name, cache); + return cache; + } + + /** + * Builds a cache hierarchy based on the configured cache types. + * The hierarchy is read from the CACHE_HIERARCHY environment variable. + * Caches are layered in the order specified: the first cache is the primary layer, + * the second is the secondary layer, etc. + * If only one cache type is specified, it is returned directly without layering. + * + * @param The type of cache key + * @param cacheName The name of the cache + * @param parameters The cache parameters + * @return The configured cache instance + */ + private Cache buildCacheHierarchy(String cacheName, CacheParameter parameters) { + String hierarchyConfig = Environment.getenv("CACHE_HIERARCHY"); + if (hierarchyConfig == null) { + hierarchyConfig = DEFAULT_CACHE_HIERARCHY; + } + + List cacheTypes = parseCacheHierarchy(hierarchyConfig); ObjectMapper mapper = new ObjectMapper(); - LocalCache localCache = new LocalCache<>(directoryOfCaches + "/" + name + ".json", parameters); - try { - RedisCache redisCache = new RedisCache<>(parameters, mapper); - HierarchicalCache cache = - new HierarchicalCache<>(parameters, redisCache, localCache, conflictResolution); - caches.put(name, cache); - return cache; - } catch (JedisConnectionException e) { - caches.put(name, localCache); - return localCache; + String cacheFilePath = directoryOfCaches + "/" + cacheName + ".json"; + + // Create cache instances for each type, skipping those that fail to initialize + List> createdCaches = new ArrayList<>(); + for (String cacheType : cacheTypes) { + try { + Cache cache = Cache.createByType(cacheType, parameters, cacheFilePath, mapper); + createdCaches.add(cache); + logger.debug("Created cache type: {}", cacheType); + } catch (JedisConnectionException e) { + logger.warn( + "Failed to initialize cache type '{}': {}. Skipping this cache layer.", + cacheType, + e.getMessage()); + } + } + + if (createdCaches.isEmpty()) { + return new LocalCache<>(cacheFilePath, parameters); + } + + Cache layeredCache = createdCaches.getFirst(); + for (int i = 1; i < createdCaches.size(); i++) { + layeredCache = new HierarchicalCache<>(parameters, layeredCache, createdCaches.get(i), conflictResolution); + } + return layeredCache; + } + + /** + * Parses the cache hierarchy configuration string into a list of cache types. + * The input should be a comma-separated list of cache types (case-insensitive). + * Example: "LOCAL,REDIS" or "local,redis,my_cache" + * + * @param hierarchyConfig The hierarchy configuration string + * @return A list of cache types in order + * @throws IllegalArgumentException If the configuration is empty or invalid + */ + // TODO: Support 'REDIS, LOCAL' + private List parseCacheHierarchy(String hierarchyConfig) { + String[] types = hierarchyConfig.split(","); + List cacheTypes = new ArrayList<>(); + + for (String type : types) { + String trimmed = type.trim(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("Cache hierarchy contains empty cache type"); + } + cacheTypes.add(trimmed.toLowerCase()); } + return cacheTypes; } /** diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java index 4f96c384..dff1c4a2 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java @@ -1,21 +1,27 @@ /* Licensed under MIT 2025-2026. */ package edu.kit.kastel.sdq.lissa.ratlr.cache; +import java.util.Objects; + import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Defines strategies for handling cache value replacement when conflicts occur between Redis and local cache. + * Defines strategies for handling cache value replacement when conflicts occur between cache layers. * A conflict occurs when the same key exists in both caches but with different values. */ public enum CacheReplacementStrategy { /** * Does not replace conflicting values - leaves both cache values as they are. - * The Redis value will be returned when reading. + * The primary value will be returned when reading. */ NONE, + /** + * Does not replace conflicting values - leaves both cache values as they are. + * If a conflict is detected an exception will be thrown. + */ ERROR { /** * Throws an exception when a conflict is detected between the two caches. @@ -23,70 +29,48 @@ public enum CacheReplacementStrategy { @Override public @Nullable T resolve( String key, - @Nullable T firstValue, - Cache firstCache, - @Nullable T secondValue, - Cache secondCache) { - super.resolve(key, firstValue, firstCache, secondValue, secondCache); - if (firstValue == secondValue) { - return firstValue; + @Nullable T primaryValue, + Cache primaryCache, + @Nullable T secondaryValue, + Cache secondaryCache) { + super.resolve(key, primaryValue, primaryCache, secondaryValue, secondaryCache); + if (primaryValue == null || Objects.deepEquals(primaryValue, secondaryValue)) { + return primaryValue; } logger.error( - "Cache inconsistency detected for key {}, values: {} (first cache), {} (second cache)", + "Cache inconsistency detected for key {}, values: {} (primary cache), {} (secondary cache)", key, - firstValue, - secondValue); + primaryValue, + secondaryValue); throw new IllegalStateException("Cache inconsistency detected for key " + key); } }, - OVERWRITE_FIRST { - /** - * Overwrites the first cache value with the second cache value in case of a conflict, and returns the second cache value. - */ - @Override - public @Nullable T resolve( - String key, - @Nullable T firstValue, - Cache firstCache, - @Nullable T secondValue, - Cache secondCache) { - super.resolve(key, firstValue, firstCache, secondValue, secondCache); - if (firstValue == secondValue) { - return firstValue; - } - logger.warn( - "Cache inconsistency detected for key {}, overwriting first cache value with second cache value: {} -> {}", - key, - firstValue, - secondValue); - firstCache.put(key, secondValue); - return secondValue; - } - }, - - OVERWRITE_SECOND { + /** + * Replaces the conflicting value in the secondary cache with the value from the primary cache. + */ + OVERWRITE_SECONDARY { /** - * Overwrites the second cache value with the first cache value in case of a conflict, and returns the first cache value. + * Overwrites the secondary cache value with the primary cache value in case of a conflict, and returns the primary cache value. */ @Override public @Nullable T resolve( String key, - @Nullable T firstValue, - Cache firstCache, - @Nullable T secondValue, - Cache secondCache) { - super.resolve(key, firstValue, firstCache, secondValue, secondCache); - if (firstValue == secondValue) { - return firstValue; + @Nullable T primaryValue, + Cache primaryCache, + @Nullable T secondaryValue, + Cache secondaryCache) { + super.resolve(key, primaryValue, primaryCache, secondaryValue, secondaryCache); + if (primaryValue == null || Objects.deepEquals(primaryValue, secondaryValue)) { + return primaryValue; } logger.warn( - "Cache inconsistency detected for key {}, overwriting second cache value with first cache value: {} -> {}", + "Cache inconsistency detected for key {}, overwriting secondary cache value with primary cache value: {} -> {}", key, - secondValue, - firstValue); - secondCache.put(key, firstValue); - return firstValue; + secondaryValue, + primaryValue); + secondaryCache.put(key, primaryValue); + return primaryValue; } }; @@ -96,26 +80,30 @@ public enum CacheReplacementStrategy { * Resolves a conflict between two caches by applying the appropriate replacement strategy. * If a value is null in one cache but not the other, it will be copied to the cache where it is missing. *

- * The default implementation does not perform any replacement and simply returns the first value. + * The default implementation does not perform any replacement and simply returns the primary value. * * @param The type of cache key used in both caches * @param The type of the cache values * @param key The cache key where the conflict occurred - * @param firstValue The value of the first cache - * @param firstCache The first cache where the value was found - * @param secondValue The value of the second cache - * @param secondCache The second cache where the value was found + * @param primaryValue The value of the primary cache + * @param primaryCache The primary cache where the value was found + * @param secondaryValue The value of the secondary cache + * @param secondaryCache The secondary cache where the value was found * * @return The resolved cache value to be used (may be null) */ public @Nullable T resolve( - String key, @Nullable T firstValue, Cache firstCache, @Nullable T secondValue, Cache secondCache) { - if (firstValue == null && secondValue != null) { - firstCache.put(key, secondValue); + String key, + @Nullable T primaryValue, + Cache primaryCache, + @Nullable T secondaryValue, + Cache secondaryCache) { + if (primaryValue == null && secondaryValue != null) { + primaryCache.put(key, secondaryValue); } - if (firstValue != null && secondValue == null) { - secondCache.put(key, firstValue); + if (primaryValue != null && secondaryValue == null) { + secondaryCache.put(key, primaryValue); } - return firstValue; + return primaryValue; } } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java index ee1ac69e..4db56a86 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java @@ -23,6 +23,9 @@ * @param The type of cache key used in this cache */ class LocalCache implements Cache { + + public static final String LOCAL_CACHE_NAME = "local"; + private final ObjectMapper mapper; /** From 213500418ec7cbe5ce2eee4e6eb9012269bddfb8 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Mon, 4 May 2026 12:21:07 +0200 Subject: [PATCH 08/34] revert: remove module config for caches, instead get cache configurations - aside from the local cache dir for replication - from individual environment variables. --- .../kastel/sdq/lissa/ratlr/Evaluation.java | 2 +- .../sdq/lissa/ratlr/cache/CacheManager.java | 66 +++++++++++-------- .../EvaluationConfiguration.java | 20 +----- 3 files changed, 41 insertions(+), 47 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/Evaluation.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/Evaluation.java index bfd3e900..6b2d2ab4 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/Evaluation.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/Evaluation.java @@ -198,7 +198,7 @@ public Evaluation(EvaluationConfiguration config) throws IOException { * @throws IOException If there are issues reading the configuration */ private void setup() throws IOException { - configuration.initializeCache(); + CacheManager.setCacheDir(configuration.cacheDir()); ContextStore contextStore = new ContextStore(); diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index 1d4561bd..aae3e106 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -15,7 +15,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; -import edu.kit.kastel.sdq.lissa.ratlr.configuration.ModuleConfiguration; import edu.kit.kastel.sdq.lissa.ratlr.utils.Environment; import redis.clients.jedis.exceptions.JedisConnectionException; @@ -42,14 +41,10 @@ public final class CacheManager { */ private static final CacheReplacementStrategy DEFAULT_CONFLICT_RESOLUTION = CacheReplacementStrategy.ERROR; - /** - * The configuration key for specifying the cache directory in a ModuleConfiguration. - */ - public static final String CACHE_DIR_CONFIGURATION_KEY = "cache_dir"; - private static @Nullable CacheManager defaultInstanceManager; private final Path directoryOfCaches; private final CacheReplacementStrategy conflictResolution; + private final List hierarchyConfig; private final Map> caches = new HashMap<>(); private static final Logger logger = LoggerFactory.getLogger(CacheManager.class); @@ -61,20 +56,25 @@ public final class CacheManager { * @param directory The path to the cache directory, or null to use the default directory * @throws IOException If the cache directory cannot be created */ - @Deprecated(forRemoval = false) public static synchronized void setCacheDir(@Nullable String directory) throws IOException { - if (directory == null) { - directory = DEFAULT_CACHE_DIRECTORY; - } - - ModuleConfiguration config = new ModuleConfiguration("cache", Map.of(CACHE_DIR_CONFIGURATION_KEY, directory)); - setCacheDir(config); + defaultInstanceManager = new CacheManager( + Path.of(directory == null ? DEFAULT_CACHE_DIRECTORY : directory), readConflictResolutionStrategy()); } - public static synchronized void setCacheDir(ModuleConfiguration configuration) throws IOException { - String cacheDir = configuration.argumentAsString(CACHE_DIR_CONFIGURATION_KEY, DEFAULT_CACHE_DIRECTORY); - CacheReplacementStrategy conflictResolution = readConflictResolutionStrategy(); - defaultInstanceManager = new CacheManager(Path.of(cacheDir), conflictResolution); + /** + * Sets the cache directory and hierarchy configuration for the default cache manager instance. + * This method must be called before using the default instance. + * + * @param directory The path to the cache directory, or null to use the default directory + * @param hierarchyConfig The cache hierarchy configuration strings (e.g., {"LOCAL","REDIS"}) + * @throws IOException If the cache directory cannot be created + */ + public static synchronized void setCacheDir(@Nullable String directory, List hierarchyConfig) + throws IOException { + defaultInstanceManager = new CacheManager( + Path.of(directory == null ? DEFAULT_CACHE_DIRECTORY : directory), + CacheReplacementStrategy.NONE, + hierarchyConfig); } /** @@ -120,6 +120,23 @@ public CacheManager(Path cacheDir, CacheReplacementStrategy conflictResolution) } this.directoryOfCaches = cacheDir; this.conflictResolution = conflictResolution; + String hierarchyConfig = Environment.getenv("CACHE_HIERARCHY"); + if (hierarchyConfig == null) { + hierarchyConfig = DEFAULT_CACHE_HIERARCHY; + } + this.hierarchyConfig = parseCacheHierarchy(hierarchyConfig); + } + + public CacheManager(Path cacheDir, CacheReplacementStrategy conflictResolution, List hierarchyConfig) + throws IOException { + if (!Files.exists(cacheDir)) Files.createDirectories(cacheDir); + if (!Files.isDirectory(cacheDir)) { + throw new IllegalArgumentException("path is not a directory: " + cacheDir); + } + + this.directoryOfCaches = cacheDir; + this.conflictResolution = conflictResolution; + this.hierarchyConfig = hierarchyConfig; } /** @@ -137,7 +154,7 @@ public static CacheManager getDefaultInstance() { /** * Resets the default cache manager instance. * This method is intended for testing purposes only to allow clean state between tests. - * After calling this method, {@link #setCacheDir(String)} or {@link #setCacheDir(ModuleConfiguration)} + * After calling this method, {@link #setCacheDir(String)} * must be called again before using the default instance. */ static synchronized void resetDefaultInstance() { @@ -203,18 +220,11 @@ private Cache getCache(String name, CacheParameter pa * @return The configured cache instance */ private Cache buildCacheHierarchy(String cacheName, CacheParameter parameters) { - String hierarchyConfig = Environment.getenv("CACHE_HIERARCHY"); - if (hierarchyConfig == null) { - hierarchyConfig = DEFAULT_CACHE_HIERARCHY; - } - - List cacheTypes = parseCacheHierarchy(hierarchyConfig); ObjectMapper mapper = new ObjectMapper(); String cacheFilePath = directoryOfCaches + "/" + cacheName + ".json"; - // Create cache instances for each type, skipping those that fail to initialize List> createdCaches = new ArrayList<>(); - for (String cacheType : cacheTypes) { + for (String cacheType : hierarchyConfig) { try { Cache cache = Cache.createByType(cacheType, parameters, cacheFilePath, mapper); createdCaches.add(cache); @@ -247,8 +257,8 @@ private Cache buildCacheHierarchy(String cacheName, Cach * @return A list of cache types in order * @throws IllegalArgumentException If the configuration is empty or invalid */ - // TODO: Support 'REDIS, LOCAL' - private List parseCacheHierarchy(String hierarchyConfig) { + // TODO: Support quotes (and spaces) like 'REDIS, LOCAL' + private static List parseCacheHierarchy(String hierarchyConfig) { String[] types = hierarchyConfig.split(","); List cacheTypes = new ArrayList<>(); diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/EvaluationConfiguration.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/EvaluationConfiguration.java index 9f4dcb7d..daa294ae 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/EvaluationConfiguration.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/EvaluationConfiguration.java @@ -1,7 +1,6 @@ /* Licensed under MIT 2025-2026. */ package edu.kit.kastel.sdq.lissa.ratlr.configuration; -import java.io.IOException; import java.io.UncheckedIOException; import java.util.List; @@ -13,7 +12,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; -import edu.kit.kastel.sdq.lissa.ratlr.cache.CacheManager; import edu.kit.kastel.sdq.lissa.ratlr.classifier.Classifier; import edu.kit.kastel.sdq.lissa.ratlr.context.ContextStore; @@ -29,8 +27,7 @@ * via a {@link edu.kit.kastel.sdq.lissa.ratlr.context.ContextStore} passed to their factory methods. *

* - * @param cacheDir Directory for caching intermediate results. Either this or {@link #cache} must be set, but not both. - * @param cache Configuration for the caching of LLM calls. Either this or {@link #cacheDir} must be set, but not both. + * @param cacheDir Directory for caching intermediate results. * @param goldStandardConfiguration Configuration for gold standard evaluation. * @param sourceArtifactProvider Configuration for the source artifact provider. * @param targetArtifactProvider Configuration for the target artifact provider. @@ -48,8 +45,7 @@ */ @RecordBuilder() public record EvaluationConfiguration( - @JsonProperty("cache_dir") @Nullable String cacheDir, - @JsonProperty("cache") @Nullable ModuleConfiguration cache, + @JsonProperty("cache_dir") String cacheDir, @JsonProperty("gold_standard_configuration") GoldStandardConfiguration goldStandardConfiguration, @JsonProperty("source_artifact_provider") ModuleConfiguration sourceArtifactProvider, @JsonProperty("target_artifact_provider") ModuleConfiguration targetArtifactProvider, @@ -147,16 +143,4 @@ public Classifier createClassifier(ContextStore contextStore) { ? Classifier.createClassifier(classifier, contextStore) : Classifier.createMultiStageClassifier(classifiers, contextStore); } - - public void initializeCache() throws IOException { - if ((cacheDir == null) == (cache == null)) { - throw new IllegalStateException("Either 'cache_dir' or 'cache' must be set, but not both."); - } - - if (cacheDir != null) { - CacheManager.setCacheDir(cacheDir); - } else { - CacheManager.setCacheDir(cache); - } - } } From 11aa1bb18cdd4f87bda46c54cba86307af66ac7d Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Mon, 4 May 2026 12:22:21 +0200 Subject: [PATCH 09/34] test: enhance CacheTest with new entry writing, retrieval, serialization, and backward compatibility checks --- .../sdq/lissa/ratlr/cache/CacheTest.java | 156 ++++++++++++++---- .../cache/test-local-cache-sample.json | 1 + 2 files changed, 125 insertions(+), 32 deletions(-) create mode 100644 src/test/resources/cache/test-local-cache-sample.json diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java index e020915d..91bfb6f3 100644 --- a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java +++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java @@ -6,15 +6,22 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Map; +import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import edu.kit.kastel.sdq.lissa.ratlr.configuration.ModuleConfiguration; +import edu.kit.kastel.sdq.lissa.ratlr.utils.KeyGenerator; +/** + * Unit tests for LocalCache implementation. + * These tests ensure that the local cache correctly persists and retrieves cache entries + * while maintaining backward compatibility with existing cache files. + */ +@NullMarked class CacheTest { @TempDir private Path tempCacheDir; @@ -32,38 +39,123 @@ void teardown() { } @Test - void testCacheDirectoryCreatedFromModuleConfiguration() throws IOException { - CacheManager.resetDefaultInstance(); - Path customCacheDir = tempCacheDir.resolve("custom_cache"); - assertFalse(Files.exists(customCacheDir), "Custom cache directory should not exist before initialization"); - assertThrows( - IllegalStateException.class, - CacheManager::getDefaultInstance, - "Default CacheManager instance should be null before initialization"); - - ModuleConfiguration config = new ModuleConfiguration("cache", Map.of("cache_dir", customCacheDir.toString())); - CacheManager.setCacheDir(config); - - assertTrue(Files.exists(customCacheDir), "Custom cache directory should be created after initialization"); - assertDoesNotThrow( - CacheManager::getDefaultInstance, - "Default CacheManager instance should be initialized after setting cache dir"); + @DisplayName("New cache entries are written to cache file") + void testWriteNewEntry() throws IOException { + Cache cache = createLocalCache(); + + cache.put("key1", "value1"); + cache.flush(); + + Path cacheFile = tempCacheDir.resolve("test_cache.json"); + assertTrue(Files.exists(cacheFile)); + String content = Files.readString(cacheFile); + assertTrue(content.contains("value1")); } @Test - void testCacheDirectoryCreatedFromCacheDir() throws IOException { - CacheManager.resetDefaultInstance(); - Path customCacheDir = tempCacheDir.resolve("custom_cache"); - assertFalse(Files.exists(customCacheDir), "Custom cache directory should not exist before initialization"); - assertThrows( - IllegalStateException.class, - CacheManager::getDefaultInstance, - "Default CacheManager instance should be null before initialization"); - CacheManager.setCacheDir(customCacheDir.toString()); - - assertTrue(Files.exists(customCacheDir), "Custom cache directory should be created after initialization"); - assertDoesNotThrow( - CacheManager::getDefaultInstance, - "Default CacheManager instance should be initialized after setting cache dir"); + @DisplayName("Existing cache entries are retrieved from cache file") + void testRetrieveExistingEntry() { + Cache cache1 = createLocalCache(); + cache1.put("key1", "value1"); + cache1.flush(); + + Cache cache2 = createLocalCache(); + String value = cache2.get("key1", String.class); + + assertEquals("value1", value); + } + + @Test + @DisplayName("Objects are serialized and deserialized correctly") + void testObjectSerialization() { + Cache cache = createLocalCache(); + TestObject obj = new TestObject("test", 42); + cache.put("key1", obj); + cache.flush(); + + Cache cache2 = createLocalCache(); + TestObject retrieved = cache2.get("key1", TestObject.class); + + assertNotNull(retrieved); + assertEquals("test", retrieved.name); + assertEquals(42, retrieved.value); + } + + @Test + @DisplayName("Legacy cache files are backward compatible") + void testBackwardCompatibility() throws IOException { + Path sourceCacheFile = Path.of("src/test/resources/cache/test-local-cache-sample.json"); + Path cacheFile = tempCacheDir.resolve("test_cache.json"); + Files.copy(sourceCacheFile, cacheFile); + + Cache cache = createLocalCache(); + String value1 = cache.get("test-key-1", String.class); + String value2 = cache.get("test-key-2", String.class); + String value3 = cache.get("test-key-3", String.class); + + assertEquals("test-value-1", value1); + assertEquals("test-value-2", value2); + assertEquals("test-value-3", value3); + } + + // Helper classes and methods + + /** + * Simple test object for serialization/deserialization testing + */ + static class TestObject { + public String name = ""; + public int value; + + TestObject() { + // For Jackson deserialization + } + + TestObject(String name, int value) { + this.name = name; + this.value = value; + } + } + + /** + * Mock CacheKey implementation for testing + */ + static class TestCacheKey implements CacheKey { + private final String localKeyValue; + + private TestCacheKey(String content) { + this.localKeyValue = KeyGenerator.generateKey(content); + } + + static TestCacheKey of(CacheParameter cacheParameter, String content) { + return new TestCacheKey(content); + } + + @Override + public String localKey() { + return localKeyValue; + } + } + + /** + * Mock CacheParameter implementation for testing + */ + static class TestCacheParameter implements CacheParameter { + @Override + public String parameters() { + return "test-cache"; + } + + @Override + public TestCacheKey createCacheKey(String content) { + return TestCacheKey.of(this, content); + } + } + + /** + * Factory method to create a LocalCache instance for testing + */ + private Cache createLocalCache() { + return new LocalCache<>(tempCacheDir.resolve("test_cache.json").toString(), new TestCacheParameter()); } } diff --git a/src/test/resources/cache/test-local-cache-sample.json b/src/test/resources/cache/test-local-cache-sample.json new file mode 100644 index 00000000..393112d8 --- /dev/null +++ b/src/test/resources/cache/test-local-cache-sample.json @@ -0,0 +1 @@ +{"a77ed447-329b-3d78-9206-894fe61c39c4":"test-value-3","2f80bb0a-fd35-369d-ba09-af947f0de0cf":"test-value-2","478af172-93d7-3e4e-87dc-4d7e36ce3d77":"test-value-1"} \ No newline at end of file From 68b5c9e3fd1870533171c7c68ff94014fd66d4e1 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Mon, 4 May 2026 12:45:34 +0200 Subject: [PATCH 10/34] fix: resolve sonar cube issues --- .../kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java | 10 +++++----- .../kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index aae3e106..63e58296 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -120,11 +120,11 @@ public CacheManager(Path cacheDir, CacheReplacementStrategy conflictResolution) } this.directoryOfCaches = cacheDir; this.conflictResolution = conflictResolution; - String hierarchyConfig = Environment.getenv("CACHE_HIERARCHY"); - if (hierarchyConfig == null) { - hierarchyConfig = DEFAULT_CACHE_HIERARCHY; + String hierarchyString = Environment.getenv("CACHE_HIERARCHY"); + if (hierarchyString == null) { + hierarchyString = DEFAULT_CACHE_HIERARCHY; } - this.hierarchyConfig = parseCacheHierarchy(hierarchyConfig); + this.hierarchyConfig = parseCacheHierarchy(hierarchyString); } public CacheManager(Path cacheDir, CacheReplacementStrategy conflictResolution, List hierarchyConfig) @@ -221,7 +221,7 @@ private Cache getCache(String name, CacheParameter pa */ private Cache buildCacheHierarchy(String cacheName, CacheParameter parameters) { ObjectMapper mapper = new ObjectMapper(); - String cacheFilePath = directoryOfCaches + "/" + cacheName + ".json"; + String cacheFilePath = directoryOfCaches.resolve(cacheName + ".json").toString(); // Create cache instances for each type, skipping those that fail to initialize List> createdCaches = new ArrayList<>(); for (String cacheType : hierarchyConfig) { diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java index 91bfb6f3..43116b48 100644 --- a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java +++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java @@ -107,6 +107,7 @@ static class TestObject { public String name = ""; public int value; + @SuppressWarnings("unused") TestObject() { // For Jackson deserialization } @@ -127,6 +128,7 @@ private TestCacheKey(String content) { this.localKeyValue = KeyGenerator.generateKey(content); } + @SuppressWarnings("unused") static TestCacheKey of(CacheParameter cacheParameter, String content) { return new TestCacheKey(content); } From 4cadcf7ba13b5207764f74e3e85f30d2460648b9 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Mon, 4 May 2026 13:04:58 +0200 Subject: [PATCH 11/34] refactor: streamline cache manager configuration --- .../sdq/lissa/ratlr/cache/CacheManager.java | 75 ++++++++----------- 1 file changed, 30 insertions(+), 45 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index 63e58296..0b90c46b 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -57,24 +57,7 @@ public final class CacheManager { * @throws IOException If the cache directory cannot be created */ public static synchronized void setCacheDir(@Nullable String directory) throws IOException { - defaultInstanceManager = new CacheManager( - Path.of(directory == null ? DEFAULT_CACHE_DIRECTORY : directory), readConflictResolutionStrategy()); - } - - /** - * Sets the cache directory and hierarchy configuration for the default cache manager instance. - * This method must be called before using the default instance. - * - * @param directory The path to the cache directory, or null to use the default directory - * @param hierarchyConfig The cache hierarchy configuration strings (e.g., {"LOCAL","REDIS"}) - * @throws IOException If the cache directory cannot be created - */ - public static synchronized void setCacheDir(@Nullable String directory, List hierarchyConfig) - throws IOException { - defaultInstanceManager = new CacheManager( - Path.of(directory == null ? DEFAULT_CACHE_DIRECTORY : directory), - CacheReplacementStrategy.NONE, - hierarchyConfig); + defaultInstanceManager = new CacheManager(Path.of(directory == null ? DEFAULT_CACHE_DIRECTORY : directory)); } /** @@ -105,26 +88,28 @@ private static CacheReplacementStrategy readConflictResolutionStrategy() { } /** - * Creates a new cache manager instance using the specified cache directory and conflict resolution strategy. + * Reads the cache hierarchy configuration from environment variables or uses the default if it's not set. + * + * @return The cache hierarchy configuration string + */ + private static String readHierarchyString() { + String hierarchyString = Environment.getenv("CACHE_HIERARCHY"); + if (hierarchyString == null) { + return DEFAULT_CACHE_HIERARCHY; + } + return hierarchyString; + } + + /** + * Creates a new cache manager instance using the specified cache directory. * The directory will be created if it doesn't exist. * * @param cacheDir The path to the cache directory - * @param conflictResolution The strategy for resolving conflicts between caches * @throws IOException If the cache directory cannot be created * @throws IllegalArgumentException If the path exists but is not a directory */ - public CacheManager(Path cacheDir, CacheReplacementStrategy conflictResolution) throws IOException { - if (!Files.exists(cacheDir)) Files.createDirectories(cacheDir); - if (!Files.isDirectory(cacheDir)) { - throw new IllegalArgumentException("path is not a directory: " + cacheDir); - } - this.directoryOfCaches = cacheDir; - this.conflictResolution = conflictResolution; - String hierarchyString = Environment.getenv("CACHE_HIERARCHY"); - if (hierarchyString == null) { - hierarchyString = DEFAULT_CACHE_HIERARCHY; - } - this.hierarchyConfig = parseCacheHierarchy(hierarchyString); + public CacheManager(Path cacheDir) throws IOException { + this(cacheDir, readConflictResolutionStrategy(), parseCacheHierarchy(readHierarchyString())); } public CacheManager(Path cacheDir, CacheReplacementStrategy conflictResolution, List hierarchyConfig) @@ -151,19 +136,6 @@ public static CacheManager getDefaultInstance() { return defaultInstanceManager; } - /** - * Resets the default cache manager instance. - * This method is intended for testing purposes only to allow clean state between tests. - * After calling this method, {@link #setCacheDir(String)} - * must be called again before using the default instance. - */ - static synchronized void resetDefaultInstance() { - if (defaultInstanceManager != null) { - defaultInstanceManager.flush(); - } - defaultInstanceManager = null; - } - /** * Gets a cache instance for the specified name. * This method is designed for internal use by model implementations. @@ -281,4 +253,17 @@ public void flush() { cache.flush(); } } + + /** + * Resets the default cache manager instance. + * This method is intended for testing purposes only to allow clean state between tests. + * After calling this method, {@link #setCacheDir(String)} + * must be called again before using the default instance. + */ + static synchronized void resetDefaultInstance() { + if (defaultInstanceManager != null) { + defaultInstanceManager.flush(); + } + defaultInstanceManager = null; + } } From 53ebb058301b60962578d110c25d761b44356cf4 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Mon, 4 May 2026 13:27:29 +0200 Subject: [PATCH 12/34] refactor: update cache conflict resolution to replacement strategy terminology --- docs/caching.md | 34 ++++++++++++++-- env-template | 6 +-- .../sdq/lissa/ratlr/cache/CacheManager.java | 39 ++++++++++++------- .../ratlr/cache/CacheReplacementStrategy.java | 2 +- 4 files changed, 59 insertions(+), 22 deletions(-) diff --git a/docs/caching.md b/docs/caching.md index 97641857..12dc2a4c 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -62,6 +62,16 @@ Parameters are used to: 2. Create cache keys from content (via `createCacheKey()` method) 3. Validate cache consistency when retrieving existing caches +### Cache Replacement Strategies + +When using hierarchical caches with multiple layers (e.g., Redis and local cache), the system detects and resolves conflicts between layers: + +- **NONE**: Does not replace conflicting values; leaves both cache layers as they are. Primary value is returned on read. +- **ERROR** (default): Throws an exception if a cache conflict is detected, ensuring data consistency by failing fast. +- **OVERWRITE**: Automatically overwrites the secondary cache value with the primary cache value when a conflict is detected, and logs a warning. + +The replacement strategy for cache conflicts is configured via the `CACHE_REPLACEMENT_STRATEGY` environment variable. + ### Cache API The `Cache` interface provides two API levels: @@ -84,7 +94,20 @@ The `Cache` interface provides two API levels: "cache_dir": "./cache/path" // Directory for cache storage } ``` -2. **Redis Setup** +2. **Environment Variables** + + The caching system supports the following environment variables: + - **CACHE_HIERARCHY**: Comma-separated list of cache types in order (e.g., "LOCAL,REDIS") + - Default: "LOCAL" + - Supported values: "LOCAL", "REDIS" + - **CACHE_REPLACEMENT_STRATEGY**: Strategy for handling conflicts between cache layers + - Default: "ERROR" + - Supported values: "NONE", "ERROR", "OVERWRITE" + - **REDIS_URL**: Redis connection URL for RedisCache + - Default: "redis://localhost:6379" + - Example: "redis://redis-server:6379" + +3. **Redis Setup** To use Redis for caching, you need to set up a Redis server. Here's a recommended Docker Compose configuration: ```yaml @@ -104,10 +127,13 @@ The `Cache` interface provides two API levels: To use Redis with LiSSA: 1. Start the Redis server using Docker Compose - 2. The system will automatically use Redis if available - 3. If Redis is unavailable, it will fall back to local file-based caching (useful for replication packages) + 2. Set environment variables if needed: + - `CACHE_HIERARCHY=REDIS,LOCAL` to use Redis with local fallback + - `REDIS_URL=redis://your-redis-host:6379` if not using the default + 3. The system will automatically use Redis if available + 4. If Redis is unavailable, it will fall back to local file-based caching (useful for replication packages) -3. **Best Practices** +4. **Best Practices** - Use the cache directory specified in the configuration - Clear the cache directory if you encounter issues diff --git a/env-template b/env-template index af62b87a..47b8097c 100644 --- a/env-template +++ b/env-template @@ -13,10 +13,10 @@ OPENAI_ORGANIZATION_ID= OPENAI_API_KEY= # Cache strategy for handling conflicts between different cache levels -# Valid values: NONE, ERROR, OVERWRITE_SECONDARY -#CACHE_CONFLICT_RESOLUTION=ERROR +# Valid values: NONE, ERROR, OVERWRITE +#CACHE_REPLACEMENT_STRATEGY=ERROR -# Cache hierarchy - comma-separated list of cache types to use +# Cache hierarchy - ascending comma-separated list of cache types to use # Examples: # LOCAL - only local file-based cache # LOCAL,REDIS - local cache as primary, Redis as secondary fallback diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index 0b90c46b..9dd9cd85 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -39,11 +39,11 @@ public final class CacheManager { /** * The default strategy for handling cache conflicts between local and Redis caches. */ - private static final CacheReplacementStrategy DEFAULT_CONFLICT_RESOLUTION = CacheReplacementStrategy.ERROR; + private static final CacheReplacementStrategy DEFAULT_REPLACEMENT_STRATEGY = CacheReplacementStrategy.ERROR; private static @Nullable CacheManager defaultInstanceManager; private final Path directoryOfCaches; - private final CacheReplacementStrategy conflictResolution; + private final CacheReplacementStrategy replacementStrategy; private final List hierarchyConfig; private final Map> caches = new HashMap<>(); @@ -61,27 +61,27 @@ public static synchronized void setCacheDir(@Nullable String directory) throws I } /** - * Reads the cache conflict resolution strategy from environment variables. + * Reads the cache replacement strategy from environment variables. * This method: *
    - *
  1. First checks the environment variable CACHE_CONFLICT_RESOLUTION
  2. - *
  3. If not found, uses the default strategy ({@link #DEFAULT_CONFLICT_RESOLUTION})
  4. + *
  5. First checks the environment variable CACHE_REPLACEMENT_STRATEGY
  6. + *
  7. If not found, uses the default strategy ({@link #DEFAULT_REPLACEMENT_STRATEGY})
  8. *
* - * @return The cache conflict resolution strategy + * @return The cache replacement strategy * @throws IllegalArgumentException If the environment variable value is set but invalid */ - private static CacheReplacementStrategy readConflictResolutionStrategy() { - String strategyValue = Environment.getenv("CACHE_CONFLICT_RESOLUTION"); + private static CacheReplacementStrategy readCacheReplacementStrategy() { + String strategyValue = Environment.getenv("CACHE_REPLACEMENT_STRATEGY"); if (strategyValue == null) { - return DEFAULT_CONFLICT_RESOLUTION; + return DEFAULT_REPLACEMENT_STRATEGY; } try { return CacheReplacementStrategy.valueOf(strategyValue.toUpperCase()); } catch (IllegalArgumentException e) { throw new IllegalArgumentException( - "Invalid CACHE_CONFLICT_RESOLUTION value: " + strategyValue + ". See " + "Invalid CACHE_REPLACEMENT_STRATEGY value: " + strategyValue + ". See " + CacheReplacementStrategy.class + " for valid options.", e); } @@ -109,10 +109,21 @@ private static String readHierarchyString() { * @throws IllegalArgumentException If the path exists but is not a directory */ public CacheManager(Path cacheDir) throws IOException { - this(cacheDir, readConflictResolutionStrategy(), parseCacheHierarchy(readHierarchyString())); + this(cacheDir, readCacheReplacementStrategy(), parseCacheHierarchy(readHierarchyString())); } - public CacheManager(Path cacheDir, CacheReplacementStrategy conflictResolution, List hierarchyConfig) + /** + * Creates a new cache manager instance with the specified cache directory, replacement strategy, and cache + * hierarchy configuration. + * The directory will be created if it doesn't exist. + * + * @param cacheDir The path to the cache directory + * @param replacementStrategy The strategy for handling conflicts between cache layers + * @param hierarchyConfig The list of cache types in the hierarchy order + * @throws IOException If the cache directory cannot be created + * @throws IllegalArgumentException If the path exists but is not a directory + */ + public CacheManager(Path cacheDir, CacheReplacementStrategy replacementStrategy, List hierarchyConfig) throws IOException { if (!Files.exists(cacheDir)) Files.createDirectories(cacheDir); if (!Files.isDirectory(cacheDir)) { @@ -120,7 +131,7 @@ public CacheManager(Path cacheDir, CacheReplacementStrategy conflictResolution, } this.directoryOfCaches = cacheDir; - this.conflictResolution = conflictResolution; + this.replacementStrategy = replacementStrategy; this.hierarchyConfig = hierarchyConfig; } @@ -215,7 +226,7 @@ private Cache buildCacheHierarchy(String cacheName, Cach Cache layeredCache = createdCaches.getFirst(); for (int i = 1; i < createdCaches.size(); i++) { - layeredCache = new HierarchicalCache<>(parameters, layeredCache, createdCaches.get(i), conflictResolution); + layeredCache = new HierarchicalCache<>(parameters, layeredCache, createdCaches.get(i), replacementStrategy); } return layeredCache; } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java index dff1c4a2..7cf7d131 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java @@ -49,7 +49,7 @@ public enum CacheReplacementStrategy { /** * Replaces the conflicting value in the secondary cache with the value from the primary cache. */ - OVERWRITE_SECONDARY { + OVERWRITE { /** * Overwrites the secondary cache value with the primary cache value in case of a conflict, and returns the primary cache value. */ From 8ca62605076d187ecbf9c83088243e1f81655d64 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Mon, 4 May 2026 13:30:03 +0200 Subject: [PATCH 13/34] revert: remove unused argumentAsEnum method from ModuleConfiguration --- .../configuration/ModuleConfiguration.java | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/ModuleConfiguration.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/ModuleConfiguration.java index 7a082a92..b4841f57 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/ModuleConfiguration.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/configuration/ModuleConfiguration.java @@ -237,30 +237,6 @@ public > String argumentAsStringByEnumIndex( return value; } - /** - * Retrieves an argument as an enum value by name, using a default value if not found. - * The argument value must match one of the enum constant names (case-insensitive). - * - * @param The enum type - * @param key The key of the argument to retrieve - * @param enumClass The class of the enum type - * @param defaultValue The default enum value to use if the argument is not found - * @return The enum value corresponding to the argument, or the default value - * @throws IllegalStateException If the configuration has been finalized - * @throws IllegalArgumentException If the argument value doesn't match any enum constant - */ - public > E argumentAsEnum(String key, E defaultValue, Class enumClass) { - String value = argumentAsString(key, defaultValue.name()); - try { - return Enum.valueOf(enumClass, value.toUpperCase()); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException( - "Argument with key " + key + " has value '" + value + "' which is not a valid " - + enumClass.getSimpleName() + " enum constant", - e); - } - } - /** * Creates a new ModuleConfiguration with the specified argument modified. * This method creates a copy of the current configuration with one argument changed, From c1ff1845e59a2a2c4d95ac27baeb1bcde69b42de Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Mon, 4 May 2026 13:39:15 +0200 Subject: [PATCH 14/34] refactor: enhance cache hierarchy parsing to support quoted strings and spaces --- .../edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index 9dd9cd85..fbba9f50 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -234,15 +234,14 @@ private Cache buildCacheHierarchy(String cacheName, Cach /** * Parses the cache hierarchy configuration string into a list of cache types. * The input should be a comma-separated list of cache types (case-insensitive). - * Example: "LOCAL,REDIS" or "local,redis,my_cache" + * Supports quoted strings to handle spaces: e.g., 'REDIS, LOCAL' or "LOCAL, REDIS". * * @param hierarchyConfig The hierarchy configuration string * @return A list of cache types in order * @throws IllegalArgumentException If the configuration is empty or invalid */ - // TODO: Support quotes (and spaces) like 'REDIS, LOCAL' private static List parseCacheHierarchy(String hierarchyConfig) { - String[] types = hierarchyConfig.split(","); + String[] types = hierarchyConfig.replace("'", "").replace('"', ' ').split(","); List cacheTypes = new ArrayList<>(); for (String type : types) { From 0940083311f5063f79593c4008f7a77ca2f647dd Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Tue, 5 May 2026 14:32:43 +0200 Subject: [PATCH 15/34] refactor: make default cache configuration identical to previous behaviour --- docs/caching.md | 8 ++++---- .../kit/kastel/sdq/lissa/ratlr/cache/Cache.java | 17 +++++++++++++---- .../sdq/lissa/ratlr/cache/CacheManager.java | 6 ++++-- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/caching.md b/docs/caching.md index 12dc2a4c..d1d5fa1d 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -66,8 +66,8 @@ Parameters are used to: When using hierarchical caches with multiple layers (e.g., Redis and local cache), the system detects and resolves conflicts between layers: -- **NONE**: Does not replace conflicting values; leaves both cache layers as they are. Primary value is returned on read. -- **ERROR** (default): Throws an exception if a cache conflict is detected, ensuring data consistency by failing fast. +- **NONE** (default): Does not replace conflicting values; leaves both cache layers as they are. Primary value is returned on read. +- **ERROR**: Throws an exception if a cache conflict is detected, ensuring data consistency by failing fast. - **OVERWRITE**: Automatically overwrites the secondary cache value with the primary cache value when a conflict is detected, and logs a warning. The replacement strategy for cache conflicts is configured via the `CACHE_REPLACEMENT_STRATEGY` environment variable. @@ -98,10 +98,10 @@ The `Cache` interface provides two API levels: The caching system supports the following environment variables: - **CACHE_HIERARCHY**: Comma-separated list of cache types in order (e.g., "LOCAL,REDIS") - - Default: "LOCAL" + - Default: "REDIS, LOCAL" - Supported values: "LOCAL", "REDIS" - **CACHE_REPLACEMENT_STRATEGY**: Strategy for handling conflicts between cache layers - - Default: "ERROR" + - Default: "NONE" - Supported values: "NONE", "ERROR", "OVERWRITE" - **REDIS_URL**: Redis connection URL for RedisCache - Default: "redis://localhost:6379" diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java index 42ed40d3..299dd093 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java @@ -138,11 +138,20 @@ public interface Cache { static Cache createByType( String type, CacheParameter parameters, @Nullable String cacheDir, @Nullable ObjectMapper mapper) { return switch (type) { - case LOCAL_CACHE_NAME -> new LocalCache<>(cacheDir, parameters); - case "redis" -> new RedisCache<>(parameters, mapper); + case LOCAL_CACHE_NAME -> { + if (cacheDir == null) { + throw new IllegalArgumentException("Cache directory must be provided for local cache"); + } + yield new LocalCache<>(cacheDir, parameters); + } + case "redis" -> { + if (mapper == null) { + throw new IllegalArgumentException("ObjectMapper must be provided for Redis cache"); + } + yield new RedisCache<>(parameters, mapper); + } default -> - throw new IllegalArgumentException( - "Unknown cache type: " + type + ". Supported types: local, redis, hierarchical"); + throw new IllegalArgumentException("Unknown cache type: " + type + ". Supported types: local, redis"); }; } } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index fbba9f50..d840819a 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -34,12 +34,14 @@ public final class CacheManager { /** * The default cache hierarchy: LOCAL only. */ - private static final String DEFAULT_CACHE_HIERARCHY = "LOCAL"; + // TODO: TO fail fast remove Redis from the default hierarchy and throw an error if Redis is configured but not + // available + private static final String DEFAULT_CACHE_HIERARCHY = "REDIS, LOCAL"; /** * The default strategy for handling cache conflicts between local and Redis caches. */ - private static final CacheReplacementStrategy DEFAULT_REPLACEMENT_STRATEGY = CacheReplacementStrategy.ERROR; + private static final CacheReplacementStrategy DEFAULT_REPLACEMENT_STRATEGY = CacheReplacementStrategy.NONE; private static @Nullable CacheManager defaultInstanceManager; private final Path directoryOfCaches; From 1f4b55e6923477be4a9df8f738e68fd99c3be549 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Tue, 5 May 2026 14:34:05 +0200 Subject: [PATCH 16/34] refactor: improve cache inconsistency handling and resolution methods --- .../ratlr/cache/CacheReplacementStrategy.java | 112 +++++++++++++++--- .../lissa/ratlr/cache/HierarchicalCache.java | 3 +- 2 files changed, 96 insertions(+), 19 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java index 7cf7d131..e7a9ec56 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java @@ -33,16 +33,36 @@ public enum CacheReplacementStrategy { Cache primaryCache, @Nullable T secondaryValue, Cache secondaryCache) { - super.resolve(key, primaryValue, primaryCache, secondaryValue, secondaryCache); - if (primaryValue == null || Objects.deepEquals(primaryValue, secondaryValue)) { - return primaryValue; + if (primaryValue != null && secondaryValue != null && !Objects.deepEquals(primaryValue, secondaryValue)) { + logger.error( + "Cache inconsistency detected for key {}, values: {} (primary cache), {} (secondary cache)", + key, + primaryValue, + secondaryValue); + throw new IllegalStateException("Cache inconsistency detected for key " + key); + } + return super.resolve(key, primaryValue, primaryCache, secondaryValue, secondaryCache); + } + + /** + * Throws an exception when a conflict is detected between the two caches. + */ + @Override + @Nullable T resolveViaInternalKey( + K key, + @Nullable T primaryValue, + Cache primaryCache, + @Nullable T secondaryValue, + Cache secondaryCache) { + if (primaryValue != null && secondaryValue != null && !Objects.deepEquals(primaryValue, secondaryValue)) { + logger.error( + "Cache inconsistency detected for key {}, values: {} (primary cache), {} (secondary cache)", + key, + primaryValue, + secondaryValue); + throw new IllegalStateException("Cache inconsistency detected for key " + key); } - logger.error( - "Cache inconsistency detected for key {}, values: {} (primary cache), {} (secondary cache)", - key, - primaryValue, - secondaryValue); - throw new IllegalStateException("Cache inconsistency detected for key " + key); + return super.resolveViaInternalKey(key, primaryValue, primaryCache, secondaryValue, secondaryCache); } }, @@ -60,17 +80,38 @@ public enum CacheReplacementStrategy { Cache primaryCache, @Nullable T secondaryValue, Cache secondaryCache) { - super.resolve(key, primaryValue, primaryCache, secondaryValue, secondaryCache); - if (primaryValue == null || Objects.deepEquals(primaryValue, secondaryValue)) { + if (primaryValue != null && secondaryValue != null && !Objects.deepEquals(primaryValue, secondaryValue)) { + logger.warn( + "Cache inconsistency detected for key {}, overwriting secondary cache value with primary cache value: {} -> {}", + key, + secondaryValue, + primaryValue); + secondaryCache.put(key, primaryValue); return primaryValue; } - logger.warn( - "Cache inconsistency detected for key {}, overwriting secondary cache value with primary cache value: {} -> {}", - key, - secondaryValue, - primaryValue); - secondaryCache.put(key, primaryValue); - return primaryValue; + return super.resolve(key, primaryValue, primaryCache, secondaryValue, secondaryCache); + } + + /** + * Overwrites the secondary cache value with the primary cache value in case of a conflict, and returns the primary cache value. + */ + @Override + @Nullable T resolveViaInternalKey( + K key, + @Nullable T primaryValue, + Cache primaryCache, + @Nullable T secondaryValue, + Cache secondaryCache) { + if (primaryValue != null && secondaryValue != null && !Objects.deepEquals(primaryValue, secondaryValue)) { + logger.warn( + "Cache inconsistency detected for key {}, overwriting secondary cache value with primary cache value: {} -> {}", + key, + secondaryValue, + primaryValue); + secondaryCache.putViaInternalKey(key, primaryValue); + return primaryValue; + } + return super.resolveViaInternalKey(key, primaryValue, primaryCache, secondaryValue, secondaryCache); } }; @@ -100,9 +141,44 @@ public enum CacheReplacementStrategy { Cache secondaryCache) { if (primaryValue == null && secondaryValue != null) { primaryCache.put(key, secondaryValue); + return secondaryValue; } if (primaryValue != null && secondaryValue == null) { secondaryCache.put(key, primaryValue); + return primaryValue; + } + return primaryValue; + } + + /** + * Resolves a conflict between two caches by applying the appropriate replacement strategy. + * If a value is null in one cache but not the other, it will be copied to the cache where it is missing. + *

+ * The default implementation does not perform any replacement and simply returns the primary value. + * + * @param The type of cache key used in both caches + * @param The type of the cache values + * @param key The cache key where the conflict occurred + * @param primaryValue The value of the primary cache + * @param primaryCache The primary cache where the value was found + * @param secondaryValue The value of the secondary cache + * @param secondaryCache The secondary cache where the value was found + * + * @return The resolved cache value to be used (may be null) + */ + @Deprecated + @Nullable T resolveViaInternalKey( + K key, + @Nullable T primaryValue, + Cache primaryCache, + @Nullable T secondaryValue, + Cache secondaryCache) { + if (primaryValue == null && secondaryValue != null) { + primaryCache.putViaInternalKey(key, secondaryValue); + return secondaryValue; + } + if (primaryValue != null && secondaryValue == null) { + secondaryCache.putViaInternalKey(key, primaryValue); } return primaryValue; } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java index da8b40c8..21108fa6 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java @@ -66,7 +66,8 @@ class HierarchicalCache implements Cache { public synchronized @Nullable T getViaInternalKey(K key, Class clazz) { T primaryValue = primaryCache.getViaInternalKey(key, clazz); T secondaryValue = secondaryCache.getViaInternalKey(key, clazz); - return conflictResolution.resolve(key.toJsonKey(), primaryValue, primaryCache, secondaryValue, secondaryCache); + return conflictResolution.resolveViaInternalKey( + key, primaryValue, primaryCache, secondaryValue, secondaryCache); } @Override From c1db4b56f8d637f24054d9dc4d038ba9e2fbdbb2 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Tue, 5 May 2026 14:43:16 +0200 Subject: [PATCH 17/34] refactor: mark resolveViaInternalKey method as deprecated with clarification --- .../sdq/lissa/ratlr/cache/CacheReplacementStrategy.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java index e7a9ec56..42cc8a6b 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java @@ -48,6 +48,7 @@ public enum CacheReplacementStrategy { * Throws an exception when a conflict is detected between the two caches. */ @Override + @Deprecated(forRemoval = false) @Nullable T resolveViaInternalKey( K key, @Nullable T primaryValue, @@ -96,6 +97,7 @@ public enum CacheReplacementStrategy { * Overwrites the secondary cache value with the primary cache value in case of a conflict, and returns the primary cache value. */ @Override + @Deprecated(forRemoval = false) @Nullable T resolveViaInternalKey( K key, @Nullable T primaryValue, @@ -165,8 +167,9 @@ public enum CacheReplacementStrategy { * @param secondaryCache The secondary cache where the value was found * * @return The resolved cache value to be used (may be null) + * @deprecated This method exposes internal cache key handling and should not be used in general code. */ - @Deprecated + @Deprecated(forRemoval = false) @Nullable T resolveViaInternalKey( K key, @Nullable T primaryValue, From 0e00965f7a7a1c22589430e0fa005a94cf38961f Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Tue, 5 May 2026 15:18:01 +0200 Subject: [PATCH 18/34] feat: add test-specific environment configuration and enforce env overwriting restrictions --- .../sdq/lissa/ratlr/utils/Environment.java | 27 +++++++++++++++++-- .../sdq/lissa/ratlr/ArchitectureTest.java | 15 +++++++++++ .../e2e/Requirement2RequirementE2ETest.java | 18 ++++--------- src/test/resources/.env-test | 7 +++++ 4 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 src/test/resources/.env-test diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java index 2c2dd4cb..aa7f23ce 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java @@ -1,4 +1,4 @@ -/* Licensed under MIT 2025. */ +/* Licensed under MIT 2025-2026. */ package edu.kit.kastel.sdq.lissa.ratlr.utils; import java.nio.file.Files; @@ -34,7 +34,7 @@ public final class Environment { private static final Logger logger = LoggerFactory.getLogger(Environment.class); /** The loaded .env configuration, or null if no .env file exists */ - private static final @Nullable Dotenv DOTENV = load(); + private static @Nullable Dotenv DOTENV = load(); private Environment() { throw new IllegalAccessError("Utility class"); @@ -104,4 +104,27 @@ public static String getenvNonNull(String key) { return null; } } + + /** + * Overwrites the current .env configuration with a new one from the specified path. + * This method: + *

    + *
  1. Checks if a .env file exists at the given path
  2. + *
  3. If found, loads and sets the new configuration
  4. + *
  5. If not found, logs a warning and retains the existing configuration
  6. + *
+ * + * The method is synchronized to ensure thread safety when updating the configuration. + * + * @param path The path to the new .env file + * @return The updated Dotenv configuration, or null if no .env file exists at the given path + */ + public static synchronized @Nullable Dotenv overwrite(Path path) { + if (Files.exists(path)) { + DOTENV = Dotenv.configure().filename(path.toString()).load(); + } else { + logger.warn("No .env file found at '{}', using system environment variables", path); + } + return DOTENV; + } } diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java index 9d81d3d6..7d39e22f 100644 --- a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java +++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java @@ -372,4 +372,19 @@ public void check(JavaClass clazz, ConditionEvents events) { .callMethod(CacheManager.class, "resetDefaultInstance") .because( "CacheManager.resetDefaultInstance() is only intended for testing purposes in CacheTest and must not be used elsewhere"); + + /** + * Rule that enforces that Environment.overwrite() is only called from test classes. + *

+ * The overwrite() method is intended for testing purposes to override environment variables. + * For production usage the regular .env file shall be used. + */ + @ArchTest + static final ArchRule environmentOverwriteOnlyInTests = noClasses() + .that() + .haveNameNotMatching(".*Test*") + .should() + .callMethod(Environment.class, "overwrite", Path.class) + .because( + "Environment.overwrite() is only intended for testing purposes and may not be used elsewhere. Use the regular .env instead."); } diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/e2e/Requirement2RequirementE2ETest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/e2e/Requirement2RequirementE2ETest.java index 30aa038d..7a4298b0 100644 --- a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/e2e/Requirement2RequirementE2ETest.java +++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/e2e/Requirement2RequirementE2ETest.java @@ -5,7 +5,6 @@ import static edu.kit.kastel.sdq.lissa.ratlr.Statistics.getTraceLinksFromGoldStandard; import java.io.File; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -13,7 +12,7 @@ import java.util.stream.Stream; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -23,20 +22,13 @@ import edu.kit.kastel.sdq.lissa.ratlr.Evaluation; import edu.kit.kastel.sdq.lissa.ratlr.Optimization; import edu.kit.kastel.sdq.lissa.ratlr.knowledge.TraceLink; +import edu.kit.kastel.sdq.lissa.ratlr.utils.Environment; class Requirement2RequirementE2ETest { - @BeforeEach - void setUp() throws IOException { - File envFile = new File(".env"); - if (!envFile.exists() && System.getenv("CI") != null) { - Files.writeString(envFile.toPath(), """ -OLLAMA_EMBEDDING_HOST=http://localhost:11434 -OLLAMA_HOST=http://localhost:11434 -OPENAI_ORGANIZATION_ID=DUMMY -OPENAI_API_KEY=sk-DUMMY -"""); - } + @BeforeAll + static void init() { + Environment.overwrite(Path.of("src/test/resources/.env-test")); } @Test diff --git a/src/test/resources/.env-test b/src/test/resources/.env-test new file mode 100644 index 00000000..2b7c364e --- /dev/null +++ b/src/test/resources/.env-test @@ -0,0 +1,7 @@ +OLLAMA_EMBEDDING_HOST=http://localhost:11434 +OLLAMA_HOST=http://localhost:11434 +OPENAI_ORGANIZATION_ID=DUMMY +OPENAI_API_KEY=sk-DUMMY +CACHE_HIERARCHY=LOCAL +CACHE_REPLACEMENT_STRATEGY=ERROR + From ae893759ebc6b9e7fdb3870887ec21250f6b5c9b Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Tue, 5 May 2026 17:58:28 +0200 Subject: [PATCH 19/34] refactor: update documentation for CacheManagers reset usage and remove obsolete cache type description --- src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java | 1 - .../java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java index 299dd093..efe126c6 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java @@ -124,7 +124,6 @@ public interface Cache { *

    *
  • "local" - LocalCache for file-based storage
  • *
  • "redis" - RedisCache for Redis-based storage
  • - *
  • "hierarchical" - HierarchicalCache for multi-layer caching (requires primary and secondary cache)
  • *
* * @param The type of cache key diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java index 7d39e22f..09374416 100644 --- a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java +++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java @@ -359,7 +359,7 @@ public void check(JavaClass clazz, ConditionEvents events) { }); /** - * Rule that enforces that CacheManager.resetDefaultInstance() is only called from the CacheTest class. + * Rule that enforces that CacheManager.resetDefaultInstance() is only called from Test classes. *

* The resetDefaultInstance() method should only be used to reset the singleton state between tests. * It must never be called from production code or other test classes. From f5aea191abdc96e1b62da5768809a9f46bae73ec Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Tue, 5 May 2026 18:29:43 +0200 Subject: [PATCH 20/34] refactor: add deprecated javadoc annotation and standardize dotenv variable naming --- .../ratlr/cache/CacheReplacementStrategy.java | 4 ++++ .../kastel/sdq/lissa/ratlr/utils/Environment.java | 14 ++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java index 42cc8a6b..cd11d176 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java @@ -25,6 +25,8 @@ public enum CacheReplacementStrategy { ERROR { /** * Throws an exception when a conflict is detected between the two caches. + * + * @deprecated This method exposes internal cache key handling and should not be used in general code. */ @Override public @Nullable T resolve( @@ -95,6 +97,8 @@ public enum CacheReplacementStrategy { /** * Overwrites the secondary cache value with the primary cache value in case of a conflict, and returns the primary cache value. + * + * @deprecated This method exposes internal cache key handling and should not be used in general code. */ @Override @Deprecated(forRemoval = false) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java index aa7f23ce..b50efe50 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java @@ -34,7 +34,7 @@ public final class Environment { private static final Logger logger = LoggerFactory.getLogger(Environment.class); /** The loaded .env configuration, or null if no .env file exists */ - private static @Nullable Dotenv DOTENV = load(); + private static @Nullable Dotenv dotenv = load(); private Environment() { throw new IllegalAccessError("Utility class"); @@ -53,7 +53,7 @@ private Environment() { * @return The value of the environment variable, or null if not found */ public static @Nullable String getenv(String key) { - String dotenvValue = DOTENV == null ? null : DOTENV.get(key); + String dotenvValue = dotenv == null ? null : dotenv.get(key); if (dotenvValue != null) return dotenvValue; return System.getenv(key); } @@ -93,8 +93,8 @@ public static String getenvNonNull(String key) { * @return The loaded Dotenv configuration, or null if no .env file exists */ private static synchronized @Nullable Dotenv load() { - if (DOTENV != null) { - return DOTENV; + if (dotenv != null) { + return dotenv; } if (Files.exists(Path.of(".env"))) { @@ -117,14 +117,12 @@ public static String getenvNonNull(String key) { * The method is synchronized to ensure thread safety when updating the configuration. * * @param path The path to the new .env file - * @return The updated Dotenv configuration, or null if no .env file exists at the given path */ - public static synchronized @Nullable Dotenv overwrite(Path path) { + public static synchronized void overwrite(Path path) { if (Files.exists(path)) { - DOTENV = Dotenv.configure().filename(path.toString()).load(); + dotenv = Dotenv.configure().filename(path.toString()).load(); } else { logger.warn("No .env file found at '{}', using system environment variables", path); } - return DOTENV; } } From 574d9fa885c61f9509edc113485720972f2589b3 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Tue, 26 May 2026 13:53:22 +0200 Subject: [PATCH 21/34] refactor: simplify cache creation logic and enforce non-null parameters --- .../kit/kastel/sdq/lissa/ratlr/cache/Cache.java | 14 ++------------ .../kastel/sdq/lissa/ratlr/cache/LocalCache.java | 4 ++-- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java index efe126c6..d3785aef 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java @@ -137,18 +137,8 @@ public interface Cache { static Cache createByType( String type, CacheParameter parameters, @Nullable String cacheDir, @Nullable ObjectMapper mapper) { return switch (type) { - case LOCAL_CACHE_NAME -> { - if (cacheDir == null) { - throw new IllegalArgumentException("Cache directory must be provided for local cache"); - } - yield new LocalCache<>(cacheDir, parameters); - } - case "redis" -> { - if (mapper == null) { - throw new IllegalArgumentException("ObjectMapper must be provided for Redis cache"); - } - yield new RedisCache<>(parameters, mapper); - } + case LOCAL_CACHE_NAME -> new LocalCache<>(cacheDir, parameters); + case "redis" -> new RedisCache<>(parameters, mapper); default -> throw new IllegalArgumentException("Unknown cache type: " + type + ". Supported types: local, redis"); }; diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java index 4db56a86..a55eb3fb 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java @@ -56,8 +56,8 @@ class LocalCache implements Cache { * @param cacheParameter The cache parameter configuration */ LocalCache(String cacheFile, CacheParameter cacheParameter) { - this.cacheParameter = cacheParameter; - this.cacheFile = new File(cacheFile); + this.cacheParameter = Objects.requireNonNull(cacheParameter); + this.cacheFile = new File(Objects.requireNonNull(cacheFile)); mapper = new ObjectMapper(); createLocalStore(); } From b8cb713154ea2efc94a451ee21e9411d5da68eae Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Tue, 26 May 2026 13:53:31 +0200 Subject: [PATCH 22/34] test: added Mockito tests for hierarchical cache behaviour --- pom.xml | 6 + .../ratlr/cache/CacheReplacementStrategy.java | 1 + .../cache/CacheReplacementStrategyTest.java | 334 ++++++++++++++++++ .../ratlr/cache/HierarchicalCacheTest.java | 151 ++++++++ 4 files changed, 492 insertions(+) create mode 100644 src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategyTest.java create mode 100644 src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCacheTest.java diff --git a/pom.xml b/pom.xml index 3deeddd8..2b404e14 100644 --- a/pom.xml +++ b/pom.xml @@ -124,6 +124,12 @@ junit-jupiter-params test + + org.mockito + mockito-junit-jupiter + 5.7.0 + test + org.slf4j slf4j-simple diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java index cd11d176..3be1d93c 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java @@ -14,6 +14,7 @@ public enum CacheReplacementStrategy { /** * Does not replace conflicting values - leaves both cache values as they are. + * If any value is null while the other is present the missing value is backfilled. * The primary value will be returned when reading. */ NONE, diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategyTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategyTest.java new file mode 100644 index 00000000..fff171a5 --- /dev/null +++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategyTest.java @@ -0,0 +1,334 @@ +/* Licensed under MIT 2025-2026. */ +package edu.kit.kastel.sdq.lissa.ratlr.cache; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.util.Objects; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import edu.kit.kastel.sdq.lissa.ratlr.utils.KeyGenerator; + +/** + * Comprehensive tests for CacheReplacementStrategy enum implementations. + * These tests verify correct conflict resolution behavior with various data types + * including strings, objects, and null values. + */ +@NullMarked +class CacheReplacementStrategyTest { + private static final String TEST_KEY = "test-key"; + private static final String TEST_VALUE = "test-value"; + private static final TestObject TEST_OBJECT = new TestObject("test", 42); + private static final String CONFLICTING_VALUE = "conflicting-value"; + + @TempDir + private Path tempCacheDir; + + private Cache primaryCache; + private Cache secondaryCache; + private TestCacheKey cacheKeyInstance; + + @BeforeEach + void setUp() { + primaryCache = createLocalCache("primary"); + secondaryCache = createLocalCache("secondary"); + cacheKeyInstance = TestCacheKey.of(new TestCacheParameter(), "test"); + } + + /** + * Factory method to create a LocalCache instance for testing + */ + private Cache createLocalCache(String cachePrefix) { + return new LocalCache<>( + tempCacheDir.resolve(cachePrefix + "_cache.json").toString(), new TestCacheParameter()); + } + + // ==================== NONE Strategy String Tests ==================== + + @Test + @DisplayName("NONE strategy: with string values - identical") + void testNoneStrategyStringIdentical() { + // Given both caches have identical values + primaryCache.put(TEST_KEY, TEST_VALUE); + secondaryCache.put(TEST_KEY, TEST_VALUE); + + CacheReplacementStrategy strategy = CacheReplacementStrategy.NONE; + String primaryValue = primaryCache.get(TEST_KEY, String.class); + String secondaryValue = secondaryCache.get(TEST_KEY, String.class); + + // When resolving the values + String result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache); + + // Then the primary value is returned and caches are unchanged + assertEquals(TEST_VALUE, result); + assertEquals(TEST_VALUE, primaryCache.get(TEST_KEY, String.class)); + assertEquals(TEST_VALUE, secondaryCache.get(TEST_KEY, String.class)); + } + + @Test + @DisplayName("NONE strategy: with string values - conflicting") + void testNoneStrategyStringConflicting() { + // Given primary and secondary have different values + primaryCache.put(TEST_KEY, TEST_VALUE); + secondaryCache.put(TEST_KEY, CONFLICTING_VALUE); + + CacheReplacementStrategy strategy = CacheReplacementStrategy.NONE; + String primaryValue = primaryCache.get(TEST_KEY, String.class); + String secondaryValue = secondaryCache.get(TEST_KEY, String.class); + + // When resolving the conflict + String result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache); + + // Then the primary value is returned and caches remain unchanged + assertEquals(TEST_VALUE, result); + assertEquals(TEST_VALUE, primaryCache.get(TEST_KEY, String.class)); + assertEquals(CONFLICTING_VALUE, secondaryCache.get(TEST_KEY, String.class)); + } + + @Test + @DisplayName("NONE strategy: with null primary") + void testNoneStrategyNullPrimary() { + // Given primary is null but secondary has a value + secondaryCache.put(TEST_KEY, TEST_VALUE); + + CacheReplacementStrategy strategy = CacheReplacementStrategy.NONE; + String primaryValue = primaryCache.get(TEST_KEY, String.class); + String secondaryValue = secondaryCache.get(TEST_KEY, String.class); + + // When resolving with null primary + String result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache); + + // Then the secondary value is backfilled to primary and returned + assertEquals(TEST_VALUE, result); + assertEquals(TEST_VALUE, primaryCache.get(TEST_KEY, String.class)); + assertEquals(TEST_VALUE, secondaryCache.get(TEST_KEY, String.class)); + } + + @Test + @DisplayName("NONE strategy: with object values - deep equal but different instances") + void testNoneStrategyObjectDeepEqual() { + // Given both caches have objects with same content but different instances + TestObject obj1 = new TestObject("test", 42); + TestObject obj2 = new TestObject("test", 42); + + primaryCache.put(TEST_KEY, obj1); + secondaryCache.put(TEST_KEY, obj2); + + CacheReplacementStrategy strategy = CacheReplacementStrategy.NONE; + TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class); + TestObject secondaryValue = secondaryCache.get(TEST_KEY, TestObject.class); + + // When resolving + TestObject result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache); + + // Then primary value is returned (deep equal objects are considered same) + assertEquals(obj1, result); + assertEquals(obj2, result); + } + + // ==================== ERROR Strategy Conflict Tests ==================== + + @Test + @DisplayName("ERROR strategy: throws when string values conflict") + void testErrorStrategyStringConflict() { + // Given primary and secondary have conflicting string values + primaryCache.put(TEST_KEY, TEST_VALUE); + secondaryCache.put(TEST_KEY, CONFLICTING_VALUE); + + CacheReplacementStrategy strategy = CacheReplacementStrategy.ERROR; + String primaryValue = primaryCache.get(TEST_KEY, String.class); + String secondaryValue = secondaryCache.get(TEST_KEY, String.class); + + // When resolving conflicting values + // Then an exception is thrown + assertThrows( + IllegalStateException.class, + () -> strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache)); + } + + @Test + @DisplayName("ERROR strategy: tolerates null vs non-null in different layers") + void testErrorStrategyNullTolerance() { + // Given primary has a value but secondary is null + primaryCache.put(TEST_KEY, TEST_VALUE); + + CacheReplacementStrategy strategy = CacheReplacementStrategy.ERROR; + String primaryValue = primaryCache.get(TEST_KEY, String.class); + String secondaryValue = secondaryCache.get(TEST_KEY, String.class); + + // When resolving with one null value + String result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache); + + // Then no exception is thrown and primary value is returned + assertEquals(TEST_VALUE, result); + } + + @Test + @DisplayName("ERROR strategy: accepts identical objects") + void testErrorStrategyIdenticalObjects() { + // Given both caches have equal objects + TestObject obj1 = new TestObject("test", 42); + TestObject obj2 = new TestObject("test", 42); + + primaryCache.put(TEST_KEY, obj1); + secondaryCache.put(TEST_KEY, obj2); + + CacheReplacementStrategy strategy = CacheReplacementStrategy.ERROR; + TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class); + TestObject secondaryValue = secondaryCache.get(TEST_KEY, TestObject.class); + + // When resolving identical (deep equal) objects + TestObject result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache); + + // Then no exception and primary value is returned + assertEquals(obj1, result); + } + + // ==================== OVERWRITE Strategy Tests ==================== + + @Test + @DisplayName("OVERWRITE strategy: overwrites secondary on conflict") + void testOverwriteStrategyObjectConflict() { + // Given secondary has a different value + TestObject secondary = new TestObject("secondary", 2); + + primaryCache.put(TEST_KEY, TEST_OBJECT); + secondaryCache.put(TEST_KEY, secondary); + + CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE; + TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class); + TestObject secondaryValue = secondaryCache.get(TEST_KEY, TestObject.class); + + // When resolving the conflict + TestObject result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache); + + // Then primary value is used and secondary is overwritten + assertEquals(TEST_OBJECT, result); + assertEquals(TEST_OBJECT, secondaryCache.get(TEST_KEY, TestObject.class)); + } + + @Test + @DisplayName("OVERWRITE strategy: does not overwrite when values are identical") + void testOverwriteStrategyNoOverwriteOnIdentical() { + // Given both caches have the same value + primaryCache.put(TEST_KEY, TEST_OBJECT); + secondaryCache.put(TEST_KEY, TEST_OBJECT); + + CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE; + TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class); + TestObject secondaryValue = secondaryCache.get(TEST_KEY, TestObject.class); + + // When resolving identical values + TestObject result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache); + + // Then no overwrite occurs + assertEquals(TEST_OBJECT, result); + } + + @Test + @DisplayName("OVERWRITE strategy: does backfill when secondary is null") + void testOverwriteStrategyNoOverwriteWhenSecondaryNull() { + // Given primary has value but secondary is empty + primaryCache.put(TEST_KEY, TEST_OBJECT); + + CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE; + TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class); + TestObject secondaryValue = secondaryCache.get(TEST_KEY, TestObject.class); + + // When resolving with null secondary + TestObject result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache); + + // Then value is backfilled to secondary + assertEquals(TEST_OBJECT, result); + assertEquals(TEST_OBJECT, secondaryCache.get(TEST_KEY, TestObject.class)); + } + + @Test + @DisplayName("OVERWRITE strategy: handles null primary with non-null secondary") + void testOverwriteStrategyNullPrimary() { + // Given secondary has value but primary is empty + secondaryCache.put(TEST_KEY, TEST_OBJECT); + + CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE; + TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class); + TestObject secondaryValue = secondaryCache.get(TEST_KEY, TestObject.class); + + // When resolving with null primary + TestObject result = strategy.resolve(TEST_KEY, primaryValue, primaryCache, secondaryValue, secondaryCache); + + // Then secondary value is backfilled to primary + assertEquals(TEST_OBJECT, result); + assertEquals(TEST_OBJECT, primaryCache.get(TEST_KEY, TestObject.class)); + } + + // ==================== Helper Classes ==================== + + static class TestObject { + public String name; + public int value; + + @SuppressWarnings("unused") + TestObject() { + // For Jackson deserialization + } + + TestObject(String name, int value) { + this.name = name; + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof TestObject)) return false; + TestObject that = (TestObject) o; + return value == that.value && Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name, value); + } + } + + static class TestCacheKey implements CacheKey { + private final String keyValue; + + private TestCacheKey(String content) { + this.keyValue = KeyGenerator.generateKey(content); + } + + static TestCacheKey of(CacheParameter cacheParameter, String content) { + // Access cacheParameter to satisfy architecture test requirements + String parameters = cacheParameter.parameters(); + return new TestCacheKey(content + parameters); + } + + @Override + public String toJsonKey() { + return keyValue; + } + + @Override + public String localKey() { + return keyValue; + } + } + + static class TestCacheParameter implements CacheParameter { + @Override + public String parameters() { + return "test-cache"; + } + + @Override + public TestCacheKey createCacheKey(String content) { + return TestCacheKey.of(this, content); + } + } +} diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCacheTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCacheTest.java new file mode 100644 index 00000000..50393975 --- /dev/null +++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCacheTest.java @@ -0,0 +1,151 @@ +/* Licensed under MIT 2025-2026. */ +package edu.kit.kastel.sdq.lissa.ratlr.cache; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import edu.kit.kastel.sdq.lissa.ratlr.utils.KeyGenerator; + +/** + * Tests for HierarchicalCache layer synchronization behavior. + * These tests verify that HierarchicalCache correctly synchronizes reads and writes across + * multiple cache layers without requiring a real Redis or Docker instance. + * + * For tests of conflict resolution strategies, see {@link CacheReplacementStrategyTest}. + */ +@NullMarked +@MockitoSettings(strictness = Strictness.LENIENT) +@ExtendWith(MockitoExtension.class) +class HierarchicalCacheTest { + private static final String TEST_KEY = "test-key"; + + @Mock + private Cache primaryCache; + + @Mock + private Cache secondaryCache; + + @Mock + private CacheParameter cacheParameter; + + private TestCacheKey cacheKeyInstance; + + @BeforeEach + void setUp() { + cacheKeyInstance = TestCacheKey.of(cacheParameter, "test"); + when(cacheParameter.createCacheKey(anyString())).thenReturn(cacheKeyInstance); + } + + @Test + @DisplayName("put() writes to both primary and secondary cache") + void testPutObjectWritesToBothCaches() { + HierarchicalCache cache = + new HierarchicalCache<>(cacheParameter, primaryCache, secondaryCache, CacheReplacementStrategy.NONE); + + TestObject testObj = new TestObject("test", 42); + cache.put(TEST_KEY, testObj); + + verify(primaryCache).put(eq(TEST_KEY), same(testObj)); + verify(secondaryCache).put(eq(TEST_KEY), same(testObj)); + } + + @Test + @DisplayName("containsKey() returns true if primary cache contains key") + void testContainsKeyInPrimary() { + HierarchicalCache cache = + new HierarchicalCache<>(cacheParameter, primaryCache, secondaryCache, CacheReplacementStrategy.NONE); + + when(primaryCache.containsKey(TEST_KEY)).thenReturn(true); + when(secondaryCache.containsKey(TEST_KEY)).thenReturn(false); + + assertTrue(cache.containsKey(TEST_KEY)); + } + + @Test + @DisplayName("containsKey() returns true if secondary cache contains key") + void testContainsKeyInSecondary() { + HierarchicalCache cache = + new HierarchicalCache<>(cacheParameter, primaryCache, secondaryCache, CacheReplacementStrategy.NONE); + + when(primaryCache.containsKey(TEST_KEY)).thenReturn(false); + when(secondaryCache.containsKey(TEST_KEY)).thenReturn(true); + + assertTrue(cache.containsKey(TEST_KEY)); + } + + @Test + @DisplayName("containsKey() returns false if neither cache contains key") + void testContainsKeyInNeither() { + HierarchicalCache cache = + new HierarchicalCache<>(cacheParameter, primaryCache, secondaryCache, CacheReplacementStrategy.NONE); + + when(primaryCache.containsKey(TEST_KEY)).thenReturn(false); + when(secondaryCache.containsKey(TEST_KEY)).thenReturn(false); + + assertFalse(cache.containsKey(TEST_KEY)); + } + + @Test + @DisplayName("flush() flushes both caches") + void testFlushBothCaches() { + HierarchicalCache cache = + new HierarchicalCache<>(cacheParameter, primaryCache, secondaryCache, CacheReplacementStrategy.NONE); + + cache.flush(); + + verify(primaryCache).flush(); + verify(secondaryCache).flush(); + } + + // ==================== Helper Classes ==================== + + static class TestObject { + public String name; + public int value; + + @SuppressWarnings("unused") + TestObject() { + // For Jackson deserialization + } + + TestObject(String name, int value) { + this.name = name; + this.value = value; + } + } + + static class TestCacheKey implements CacheKey { + private final String keyValue; + + private TestCacheKey(String content) { + this.keyValue = KeyGenerator.generateKey(content); + } + + static TestCacheKey of(CacheParameter cacheParameter, String content) { + // Access cacheParameter to satisfy architecture test requirements + String parameters = cacheParameter.parameters(); + return new TestCacheKey(content + parameters); + } + + @Override + public String toJsonKey() { + return keyValue; + } + + @Override + public String localKey() { + return keyValue; + } + } +} From 63380ff5e6dd959c3036425e20cbb13d7fc74d1d Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Tue, 26 May 2026 14:26:24 +0200 Subject: [PATCH 23/34] refactor: introduce helper methods for cache value insertion using appropriate methods to avoid serialization conflicts --- .../sdq/lissa/ratlr/cache/CacheManager.java | 1 + .../ratlr/cache/CacheReplacementStrategy.java | 51 ++++++++++++++++--- .../lissa/ratlr/cache/HierarchicalCache.java | 2 +- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index d840819a..4d4818ab 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -223,6 +223,7 @@ private Cache buildCacheHierarchy(String cacheName, Cach } if (createdCaches.isEmpty()) { + // TODO throw error if Redis is configured but unavailable return new LocalCache<>(cacheFilePath, parameters); } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java index 3be1d93c..fad7a3b8 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java @@ -90,7 +90,7 @@ public enum CacheReplacementStrategy { key, secondaryValue, primaryValue); - secondaryCache.put(key, primaryValue); + putValue(secondaryCache, key, primaryValue); return primaryValue; } return super.resolve(key, primaryValue, primaryCache, secondaryValue, secondaryCache); @@ -115,7 +115,7 @@ public enum CacheReplacementStrategy { key, secondaryValue, primaryValue); - secondaryCache.putViaInternalKey(key, primaryValue); + putValueViaInternalKey(secondaryCache, key, primaryValue); return primaryValue; } return super.resolveViaInternalKey(key, primaryValue, primaryCache, secondaryValue, secondaryCache); @@ -124,6 +124,45 @@ public enum CacheReplacementStrategy { private static final Logger logger = LoggerFactory.getLogger(CacheReplacementStrategy.class); + /** + * Helper method to put a value in the cache, handling String values specially + * to avoid double-serialization. + * String values are stored using the String-specific overload to prevent + * JSON serialization that would add quotes around the value. + * + * @param The type of cache key + * @param The type of the value + * @param cache The cache to put the value into + * @param key The cache key (as a String) + * @param value The value to put + */ + private static void putValue(Cache cache, String key, T value) { + if (value instanceof String stringValue) { + cache.put(key, stringValue); + } else { + cache.put(key, value); + } + } + + /** + * Helper method to put a value in the cache using internal key handling, + * handling String values specially to avoid double-serialization. + * + * @param The type of cache key + * @param The type of the value + * @param cache The cache to put the value into + * @param key The internal cache key + * @param value The value to put + */ + @Deprecated(forRemoval = false) + private static void putValueViaInternalKey(Cache cache, K key, T value) { + if (value instanceof String stringValue) { + cache.put(String.valueOf(key), stringValue); + } else { + cache.putViaInternalKey(key, value); + } + } + /** * Resolves a conflict between two caches by applying the appropriate replacement strategy. * If a value is null in one cache but not the other, it will be copied to the cache where it is missing. @@ -147,11 +186,11 @@ public enum CacheReplacementStrategy { @Nullable T secondaryValue, Cache secondaryCache) { if (primaryValue == null && secondaryValue != null) { - primaryCache.put(key, secondaryValue); + putValue(primaryCache, key, secondaryValue); return secondaryValue; } if (primaryValue != null && secondaryValue == null) { - secondaryCache.put(key, primaryValue); + putValue(secondaryCache, key, primaryValue); return primaryValue; } return primaryValue; @@ -182,11 +221,11 @@ public enum CacheReplacementStrategy { @Nullable T secondaryValue, Cache secondaryCache) { if (primaryValue == null && secondaryValue != null) { - primaryCache.putViaInternalKey(key, secondaryValue); + putValueViaInternalKey(primaryCache, key, secondaryValue); return secondaryValue; } if (primaryValue != null && secondaryValue == null) { - secondaryCache.putViaInternalKey(key, primaryValue); + putValueViaInternalKey(secondaryCache, key, primaryValue); } return primaryValue; } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java index 21108fa6..c2bceeb1 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java @@ -10,7 +10,7 @@ *

* The cache hierarchy operates as follows: * 1. Attempts to retrieve/store values in the primary cache - * 2. Falls back to secondary cache if primary is unavailable + * 2. Falls back to secondary cache if missing in the primary * 3. Automatically synchronizes values between layers when needed * 4. Applies conflict resolution strategy when values differ between layers * From 148c2f73c57fbcb3a87adc797cce243550a81433 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:18:29 +0200 Subject: [PATCH 24/34] refactor: replace string-based cache type with enum for improved type safety --- .../kastel/sdq/lissa/ratlr/cache/Cache.java | 42 +++++++++---------- .../sdq/lissa/ratlr/cache/CacheManager.java | 4 +- .../sdq/lissa/ratlr/cache/CacheType.java | 7 ++++ 3 files changed, 28 insertions(+), 25 deletions(-) create mode 100644 src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheType.java diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java index d3785aef..0e23d8cd 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java @@ -1,8 +1,6 @@ /* Licensed under MIT 2025-2026. */ package edu.kit.kastel.sdq.lissa.ratlr.cache; -import static edu.kit.kastel.sdq.lissa.ratlr.cache.LocalCache.LOCAL_CACHE_NAME; - import org.jspecify.annotations.Nullable; import com.fasterxml.jackson.core.JsonProcessingException; @@ -19,8 +17,8 @@ public interface Cache { /** * Retrieves a value from the cache and deserializes it to the specified type. * - * @param The type to deserialize the cached value to - * @param key The cache key to look up + * @param The type to deserialize the cached value to + * @param key The cache key to look up * @param clazz The class of the type to deserialize to * @return The deserialized value, or null if not found */ @@ -30,8 +28,8 @@ public interface Cache { * Retrieves a value from the cache and deserializes it to the specified type. * DO NOT USE UNLESS YOU KNOW WHAT YOU ARE DOING. * - * @param The type to deserialize the cached value to - * @param key The cache key to look up + * @param The type to deserialize the cached value to + * @param key The cache key to look up * @param clazz The class of the type to deserialize to * @return The deserialized value, or null if not found * @deprecated This method exposes internal cache key handling and should not be used in general code. @@ -42,7 +40,7 @@ public interface Cache { /** * Stores a string value in the cache. * - * @param key The cache key to store the value under + * @param key The cache key to store the value under * @param value The string value to store */ void put(String key, String value); @@ -50,8 +48,8 @@ public interface Cache { /** * Stores a string value in the cache. * - * @param The type of the value to store - * @param key The cache key to store the value under + * @param The type of the value to store + * @param key The cache key to store the value under * @param value The value to store * @deprecated This method exposes internal cache key handling and should not be used in general code. */ @@ -62,8 +60,8 @@ public interface Cache { * Stores an object value in the cache. * The object will be serialized before storage. * - * @param The type of the value to store - * @param key The cache key to store the value under + * @param The type of the value to store + * @param key The cache key to store the value under * @param value The object value to store */ void put(String key, T value); @@ -95,10 +93,10 @@ public interface Cache { * Converts a JSON string to an object of the specified type. * If the target type is String, the JSON string is returned as is. * - * @param The type to convert to + * @param The type to convert to * @param jsonData The JSON string to convert - * @param clazz The class of the target type - * @param mapper The ObjectMapper instance to use for deserialization + * @param clazz The class of the target type + * @param mapper The ObjectMapper instance to use for deserialization * @return The converted object, or null if jsonData is null * @throws IllegalArgumentException If the JSON cannot be deserialized to the target type */ @@ -126,21 +124,19 @@ public interface Cache { *

  • "redis" - RedisCache for Redis-based storage
  • * * - * @param The type of cache key - * @param type The cache type name (case-insensitive) - * @param cacheDir The directory for local cache storage + * @param The type of cache key + * @param type The cache type name (case-insensitive) + * @param cacheDir The directory for local cache storage * @param parameters The cache parameters - * @param mapper The ObjectMapper for JSON operations + * @param mapper The ObjectMapper for JSON operations * @return A cache instance of the specified type * @throws IllegalArgumentException If the type is not recognized or the cache cannot be created */ static Cache createByType( - String type, CacheParameter parameters, @Nullable String cacheDir, @Nullable ObjectMapper mapper) { + CacheType type, CacheParameter parameters, @Nullable String cacheDir, @Nullable ObjectMapper mapper) { return switch (type) { - case LOCAL_CACHE_NAME -> new LocalCache<>(cacheDir, parameters); - case "redis" -> new RedisCache<>(parameters, mapper); - default -> - throw new IllegalArgumentException("Unknown cache type: " + type + ". Supported types: local, redis"); + case LOCAL -> new LocalCache<>(cacheDir, parameters); + case REDIS -> new RedisCache<>(parameters, mapper); }; } } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index 4d4818ab..2a639e9c 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -211,7 +211,7 @@ private Cache buildCacheHierarchy(String cacheName, Cach List> createdCaches = new ArrayList<>(); for (String cacheType : hierarchyConfig) { try { - Cache cache = Cache.createByType(cacheType, parameters, cacheFilePath, mapper); + Cache cache = Cache.createByType(CacheType.valueOf(cacheType), parameters, cacheFilePath, mapper); createdCaches.add(cache); logger.debug("Created cache type: {}", cacheType); } catch (JedisConnectionException e) { @@ -252,7 +252,7 @@ private static List parseCacheHierarchy(String hierarchyConfig) { if (trimmed.isEmpty()) { throw new IllegalArgumentException("Cache hierarchy contains empty cache type"); } - cacheTypes.add(trimmed.toLowerCase()); + cacheTypes.add(trimmed.toUpperCase()); } return cacheTypes; } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheType.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheType.java new file mode 100644 index 00000000..db69b29d --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheType.java @@ -0,0 +1,7 @@ +/* Licensed under MIT 2026. */ +package edu.kit.kastel.sdq.lissa.ratlr.cache; + +public enum CacheType { + LOCAL, + REDIS, +} From 1cfe0c7cee55896a5e2c2deff9e2fce0d86a8cf1 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:33:18 +0200 Subject: [PATCH 25/34] refactor: update cache hierarchy configuration to use enum and enforce non-empty list. Also fail fast if configured caches are unavailable --- .../sdq/lissa/ratlr/cache/CacheManager.java | 60 +++++++++---------- .../ratlr/cache/CacheReplacementStrategy.java | 4 +- .../lissa/ratlr/cache/HierarchicalCache.java | 14 +++-- .../sdq/lissa/ratlr/cache/RedisCache.java | 8 +-- .../sdq/lissa/ratlr/cache/CacheTest.java | 7 +++ src/test/resources/.env-test | 3 +- 6 files changed, 49 insertions(+), 47 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index 2a639e9c..9ba29c08 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -5,9 +5,11 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; @@ -17,8 +19,6 @@ import edu.kit.kastel.sdq.lissa.ratlr.utils.Environment; -import redis.clients.jedis.exceptions.JedisConnectionException; - /** * Manages caching operations in the LiSSA framework. * This class provides a centralized way to create and access caches for different purposes, @@ -34,9 +34,7 @@ public final class CacheManager { /** * The default cache hierarchy: LOCAL only. */ - // TODO: TO fail fast remove Redis from the default hierarchy and throw an error if Redis is configured but not - // available - private static final String DEFAULT_CACHE_HIERARCHY = "REDIS, LOCAL"; + private static final String DEFAULT_CACHE_HIERARCHY = "LOCAL"; /** * The default strategy for handling cache conflicts between local and Redis caches. @@ -46,7 +44,7 @@ public final class CacheManager { private static @Nullable CacheManager defaultInstanceManager; private final Path directoryOfCaches; private final CacheReplacementStrategy replacementStrategy; - private final List hierarchyConfig; + private final List hierarchyConfig; private final Map> caches = new HashMap<>(); private static final Logger logger = LoggerFactory.getLogger(CacheManager.class); @@ -80,11 +78,11 @@ private static CacheReplacementStrategy readCacheReplacementStrategy() { } try { - return CacheReplacementStrategy.valueOf(strategyValue.toUpperCase()); + return CacheReplacementStrategy.valueOf(strategyValue.trim().toUpperCase()); } catch (IllegalArgumentException e) { throw new IllegalArgumentException( - "Invalid CACHE_REPLACEMENT_STRATEGY value: " + strategyValue + ". See " - + CacheReplacementStrategy.class + " for valid options.", + "Invalid CACHE_REPLACEMENT_STRATEGY value: " + strategyValue + ".\n" + + Arrays.toString(CacheReplacementStrategy.values()) + " are valid options.", e); } } @@ -121,19 +119,22 @@ public CacheManager(Path cacheDir) throws IOException { * * @param cacheDir The path to the cache directory * @param replacementStrategy The strategy for handling conflicts between cache layers - * @param hierarchyConfig The list of cache types in the hierarchy order + * @param hierarchyConfig Non-empty list of cache types in the hierarchy order. * @throws IOException If the cache directory cannot be created * @throws IllegalArgumentException If the path exists but is not a directory */ - public CacheManager(Path cacheDir, CacheReplacementStrategy replacementStrategy, List hierarchyConfig) + public CacheManager(Path cacheDir, CacheReplacementStrategy replacementStrategy, List hierarchyConfig) throws IOException { if (!Files.exists(cacheDir)) Files.createDirectories(cacheDir); if (!Files.isDirectory(cacheDir)) { throw new IllegalArgumentException("path is not a directory: " + cacheDir); } - this.directoryOfCaches = cacheDir; - this.replacementStrategy = replacementStrategy; + this.directoryOfCaches = Objects.requireNonNull(cacheDir); + this.replacementStrategy = Objects.requireNonNull(replacementStrategy); + if (hierarchyConfig.isEmpty()) { + throw new IllegalArgumentException("Cache hierarchy configuration must contain at least one cache type"); + } this.hierarchyConfig = hierarchyConfig; } @@ -209,22 +210,10 @@ private Cache buildCacheHierarchy(String cacheName, Cach String cacheFilePath = directoryOfCaches.resolve(cacheName + ".json").toString(); // Create cache instances for each type, skipping those that fail to initialize List> createdCaches = new ArrayList<>(); - for (String cacheType : hierarchyConfig) { - try { - Cache cache = Cache.createByType(CacheType.valueOf(cacheType), parameters, cacheFilePath, mapper); - createdCaches.add(cache); - logger.debug("Created cache type: {}", cacheType); - } catch (JedisConnectionException e) { - logger.warn( - "Failed to initialize cache type '{}': {}. Skipping this cache layer.", - cacheType, - e.getMessage()); - } - } - - if (createdCaches.isEmpty()) { - // TODO throw error if Redis is configured but unavailable - return new LocalCache<>(cacheFilePath, parameters); + for (CacheType cacheType : hierarchyConfig) { + Cache cache = Cache.createByType(cacheType, parameters, cacheFilePath, mapper); + createdCaches.add(cache); + logger.debug("Created cache type: {}", cacheType); } Cache layeredCache = createdCaches.getFirst(); @@ -243,16 +232,23 @@ private Cache buildCacheHierarchy(String cacheName, Cach * @return A list of cache types in order * @throws IllegalArgumentException If the configuration is empty or invalid */ - private static List parseCacheHierarchy(String hierarchyConfig) { + private static List parseCacheHierarchy(String hierarchyConfig) { String[] types = hierarchyConfig.replace("'", "").replace('"', ' ').split(","); - List cacheTypes = new ArrayList<>(); + List cacheTypes = new ArrayList<>(); for (String type : types) { String trimmed = type.trim(); if (trimmed.isEmpty()) { throw new IllegalArgumentException("Cache hierarchy contains empty cache type"); } - cacheTypes.add(trimmed.toUpperCase()); + try { + cacheTypes.add(CacheType.valueOf(trimmed.toUpperCase())); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Invalid CACHE_HIERARCHY value: " + trimmed + ".\n" + Arrays.toString(CacheType.values()) + + " are valid options.", + e); + } } return cacheTypes; } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java index fad7a3b8..1478fd7b 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java @@ -26,8 +26,6 @@ public enum CacheReplacementStrategy { ERROR { /** * Throws an exception when a conflict is detected between the two caches. - * - * @deprecated This method exposes internal cache key handling and should not be used in general code. */ @Override public @Nullable T resolve( @@ -49,6 +47,8 @@ public enum CacheReplacementStrategy { /** * Throws an exception when a conflict is detected between the two caches. + * + * @deprecated This method exposes internal cache key handling and should not be used in general code. */ @Override @Deprecated(forRemoval = false) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java index c2bceeb1..0b8e9102 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java @@ -1,6 +1,8 @@ /* Licensed under MIT 2025-2026. */ package edu.kit.kastel.sdq.lissa.ratlr.cache; +import java.util.Objects; + import org.jspecify.annotations.Nullable; /** @@ -39,8 +41,8 @@ class HierarchicalCache implements Cache { * Creates a new hierarchical cache instance. * * @param cacheParameter The cache parameter configuration - * @param primaryCache The primary cache (e.g., Redis), or null if not available - * @param secondaryCache The secondary cache (e.g., local file), or null if not available + * @param primaryCache The primary cache (e.g., Redis) + * @param secondaryCache The secondary cache (e.g., local file) * @param conflictResolution Strategy for resolving conflicts between cache layers */ HierarchicalCache( @@ -48,10 +50,10 @@ class HierarchicalCache implements Cache { Cache primaryCache, Cache secondaryCache, CacheReplacementStrategy conflictResolution) { - this.cacheParameter = cacheParameter; - this.primaryCache = primaryCache; - this.secondaryCache = secondaryCache; - this.conflictResolution = conflictResolution; + this.cacheParameter = Objects.requireNonNull(cacheParameter); + this.primaryCache = Objects.requireNonNull(primaryCache); + this.secondaryCache = Objects.requireNonNull(secondaryCache); + this.conflictResolution = Objects.requireNonNull(conflictResolution); } @Override diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java index 8c2f353d..9e2a7b8d 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java @@ -5,14 +5,13 @@ import java.util.*; import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import edu.kit.kastel.sdq.lissa.ratlr.utils.Environment; +import redis.clients.jedis.RedisClient; import redis.clients.jedis.UnifiedJedis; /** @@ -24,7 +23,6 @@ * @param The type of cache key used in this cache */ class RedisCache implements Cache { - private static final Logger logger = LoggerFactory.getLogger(RedisCache.class); private final CacheParameter cacheParameter; private final ObjectMapper mapper; @@ -71,7 +69,7 @@ private void createRedisConnection() { if (Environment.getenv("REDIS_URL") != null) { redisUrl = Environment.getenv("REDIS_URL"); } - jedis = new UnifiedJedis(redisUrl); + jedis = RedisClient.create(redisUrl); // Check if connection is working jedis.ping(); } @@ -93,7 +91,7 @@ private void createRedisConnection() { @Override @SuppressWarnings("deprecation") - public @Nullable T getViaInternalKey(K cacheKey, Class clazz) { + public synchronized @Nullable T getViaInternalKey(K cacheKey, Class clazz) { String jsonData = jedis.hget(cacheKey.toJsonKey(), "data"); return Cache.convert(jsonData, clazz, mapper); } diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java index 43116b48..e2a8c6fe 100644 --- a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java +++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java @@ -9,11 +9,13 @@ import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import edu.kit.kastel.sdq.lissa.ratlr.utils.Environment; import edu.kit.kastel.sdq.lissa.ratlr.utils.KeyGenerator; /** @@ -26,6 +28,11 @@ class CacheTest { @TempDir private Path tempCacheDir; + @BeforeAll + static void init() { + Environment.overwrite(Path.of("src/test/resources/.env-test")); + } + @BeforeEach void setup() throws IOException { // Reset the default cache manager singleton for each test diff --git a/src/test/resources/.env-test b/src/test/resources/.env-test index 2b7c364e..47a579c4 100644 --- a/src/test/resources/.env-test +++ b/src/test/resources/.env-test @@ -1,7 +1,6 @@ OLLAMA_EMBEDDING_HOST=http://localhost:11434 OLLAMA_HOST=http://localhost:11434 OPENAI_ORGANIZATION_ID=DUMMY -OPENAI_API_KEY=sk-DUMMY +OPENAI_API_KEY=DUMMY CACHE_HIERARCHY=LOCAL CACHE_REPLACEMENT_STRATEGY=ERROR - From 62b0b0c062aaec712cf3b6af06235e8152fe3d9f Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:35:32 +0200 Subject: [PATCH 26/34] refactor: unify json serialization --- .../kastel/sdq/lissa/ratlr/cache/Cache.java | 8 +- .../ratlr/cache/CacheReplacementStrategy.java | 51 ++------- .../sdq/lissa/ratlr/cache/LocalCache.java | 46 +++----- .../cache/CacheReplacementStrategyTest.java | 102 +++++++++++++++++- 4 files changed, 121 insertions(+), 86 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java index 0e23d8cd..68993c03 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/Cache.java @@ -105,13 +105,13 @@ public interface Cache { if (jsonData == null) { return null; } - if (clazz == String.class) { - return (T) jsonData; - } - try { return mapper.readValue(jsonData, clazz); } catch (JsonProcessingException e) { + // Backward compatibility for unserialized String values + if (clazz == String.class) { + return (T) jsonData; + } throw new IllegalArgumentException("Could not deserialize object", e); } } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java index 1478fd7b..be9d4f33 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java @@ -90,7 +90,7 @@ public enum CacheReplacementStrategy { key, secondaryValue, primaryValue); - putValue(secondaryCache, key, primaryValue); + secondaryCache.put(key, primaryValue); return primaryValue; } return super.resolve(key, primaryValue, primaryCache, secondaryValue, secondaryCache); @@ -115,7 +115,7 @@ public enum CacheReplacementStrategy { key, secondaryValue, primaryValue); - putValueViaInternalKey(secondaryCache, key, primaryValue); + secondaryCache.putViaInternalKey(key, primaryValue); return primaryValue; } return super.resolveViaInternalKey(key, primaryValue, primaryCache, secondaryValue, secondaryCache); @@ -124,45 +124,6 @@ public enum CacheReplacementStrategy { private static final Logger logger = LoggerFactory.getLogger(CacheReplacementStrategy.class); - /** - * Helper method to put a value in the cache, handling String values specially - * to avoid double-serialization. - * String values are stored using the String-specific overload to prevent - * JSON serialization that would add quotes around the value. - * - * @param The type of cache key - * @param The type of the value - * @param cache The cache to put the value into - * @param key The cache key (as a String) - * @param value The value to put - */ - private static void putValue(Cache cache, String key, T value) { - if (value instanceof String stringValue) { - cache.put(key, stringValue); - } else { - cache.put(key, value); - } - } - - /** - * Helper method to put a value in the cache using internal key handling, - * handling String values specially to avoid double-serialization. - * - * @param The type of cache key - * @param The type of the value - * @param cache The cache to put the value into - * @param key The internal cache key - * @param value The value to put - */ - @Deprecated(forRemoval = false) - private static void putValueViaInternalKey(Cache cache, K key, T value) { - if (value instanceof String stringValue) { - cache.put(String.valueOf(key), stringValue); - } else { - cache.putViaInternalKey(key, value); - } - } - /** * Resolves a conflict between two caches by applying the appropriate replacement strategy. * If a value is null in one cache but not the other, it will be copied to the cache where it is missing. @@ -186,11 +147,11 @@ private static void putValueViaInternalKey(Cache cach @Nullable T secondaryValue, Cache secondaryCache) { if (primaryValue == null && secondaryValue != null) { - putValue(primaryCache, key, secondaryValue); + primaryCache.put(key, secondaryValue); return secondaryValue; } if (primaryValue != null && secondaryValue == null) { - putValue(secondaryCache, key, primaryValue); + secondaryCache.put(key, primaryValue); return primaryValue; } return primaryValue; @@ -221,11 +182,11 @@ private static void putValueViaInternalKey(Cache cach @Nullable T secondaryValue, Cache secondaryCache) { if (primaryValue == null && secondaryValue != null) { - putValueViaInternalKey(primaryCache, key, secondaryValue); + primaryCache.putViaInternalKey(key, secondaryValue); return secondaryValue; } if (primaryValue != null && secondaryValue == null) { - putValueViaInternalKey(secondaryCache, key, primaryValue); + secondaryCache.putViaInternalKey(key, primaryValue); } return primaryValue; } diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java index a55eb3fb..eff1d2fc 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java @@ -24,8 +24,6 @@ */ class LocalCache implements Cache { - public static final String LOCAL_CACHE_NAME = "local"; - private final ObjectMapper mapper; /** @@ -145,14 +143,7 @@ public synchronized void write() { @Override public synchronized void put(String key, String value) { K cacheKey = cacheParameter.createCacheKey(key); - String old = cache.put(cacheKey.localKey(), value); - if (old == null || !old.equals(value)) { - dirty++; - } - - if (dirty > MAX_DIRTY) { - write(); - } + putViaInternalKey(cacheKey, value); } /** @@ -167,37 +158,26 @@ public synchronized void put(String key, String value) { @Override @Deprecated(forRemoval = false) public synchronized void putViaInternalKey(K cacheKey, T value) { + String jsonValue; try { - String jsonValue = mapper.writeValueAsString(Objects.requireNonNull(value)); - String old = cache.put(cacheKey.localKey(), jsonValue); - if (old == null || !old.equals(jsonValue)) { - dirty++; - } - - if (dirty > MAX_DIRTY) { - write(); - } + jsonValue = mapper.writeValueAsString(Objects.requireNonNull(value)); } catch (JsonProcessingException e) { throw new IllegalArgumentException("Could not serialize object", e); } + String old = cache.put(cacheKey.localKey(), jsonValue); + if (old == null || !old.equals(jsonValue)) { + dirty++; + } + + if (dirty > MAX_DIRTY) { + write(); + } } @Override public synchronized void put(String key, T value) { - try { - String jsonValue = mapper.writeValueAsString(Objects.requireNonNull(value)); - K cacheKey = cacheParameter.createCacheKey(key); - String old = cache.put(cacheKey.localKey(), jsonValue); - if (old == null || !old.equals(jsonValue)) { - dirty++; - } - - if (dirty > MAX_DIRTY) { - write(); - } - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("Could not serialize object", e); - } + K cacheKey = cacheParameter.createCacheKey(key); + putViaInternalKey(cacheKey, value); } @Override diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategyTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategyTest.java index fff171a5..2fbadaeb 100644 --- a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategyTest.java +++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategyTest.java @@ -56,6 +56,8 @@ void testNoneStrategyStringIdentical() { // Given both caches have identical values primaryCache.put(TEST_KEY, TEST_VALUE); secondaryCache.put(TEST_KEY, TEST_VALUE); + assertEquals(TEST_VALUE, primaryCache.get(TEST_KEY, String.class)); + assertEquals(TEST_VALUE, secondaryCache.get(TEST_KEY, String.class)); CacheReplacementStrategy strategy = CacheReplacementStrategy.NONE; String primaryValue = primaryCache.get(TEST_KEY, String.class); @@ -76,6 +78,8 @@ void testNoneStrategyStringConflicting() { // Given primary and secondary have different values primaryCache.put(TEST_KEY, TEST_VALUE); secondaryCache.put(TEST_KEY, CONFLICTING_VALUE); + assertEquals(TEST_VALUE, primaryCache.get(TEST_KEY, String.class)); + assertEquals(CONFLICTING_VALUE, secondaryCache.get(TEST_KEY, String.class)); CacheReplacementStrategy strategy = CacheReplacementStrategy.NONE; String primaryValue = primaryCache.get(TEST_KEY, String.class); @@ -95,6 +99,8 @@ void testNoneStrategyStringConflicting() { void testNoneStrategyNullPrimary() { // Given primary is null but secondary has a value secondaryCache.put(TEST_KEY, TEST_VALUE); + assertNull(primaryCache.get(TEST_KEY, String.class)); + assertEquals(TEST_VALUE, secondaryCache.get(TEST_KEY, String.class)); CacheReplacementStrategy strategy = CacheReplacementStrategy.NONE; String primaryValue = primaryCache.get(TEST_KEY, String.class); @@ -118,6 +124,8 @@ void testNoneStrategyObjectDeepEqual() { primaryCache.put(TEST_KEY, obj1); secondaryCache.put(TEST_KEY, obj2); + assertEquals(obj1, primaryCache.get(TEST_KEY, TestObject.class)); + assertEquals(obj2, secondaryCache.get(TEST_KEY, TestObject.class)); CacheReplacementStrategy strategy = CacheReplacementStrategy.NONE; TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class); @@ -139,6 +147,8 @@ void testErrorStrategyStringConflict() { // Given primary and secondary have conflicting string values primaryCache.put(TEST_KEY, TEST_VALUE); secondaryCache.put(TEST_KEY, CONFLICTING_VALUE); + assertEquals(TEST_VALUE, primaryCache.get(TEST_KEY, String.class)); + assertEquals(CONFLICTING_VALUE, secondaryCache.get(TEST_KEY, String.class)); CacheReplacementStrategy strategy = CacheReplacementStrategy.ERROR; String primaryValue = primaryCache.get(TEST_KEY, String.class); @@ -156,6 +166,8 @@ void testErrorStrategyStringConflict() { void testErrorStrategyNullTolerance() { // Given primary has a value but secondary is null primaryCache.put(TEST_KEY, TEST_VALUE); + assertEquals(TEST_VALUE, primaryCache.get(TEST_KEY, String.class)); + assertNull(secondaryCache.get(TEST_KEY, String.class)); CacheReplacementStrategy strategy = CacheReplacementStrategy.ERROR; String primaryValue = primaryCache.get(TEST_KEY, String.class); @@ -177,6 +189,8 @@ void testErrorStrategyIdenticalObjects() { primaryCache.put(TEST_KEY, obj1); secondaryCache.put(TEST_KEY, obj2); + assertEquals(obj1, primaryCache.get(TEST_KEY, TestObject.class)); + assertEquals(obj2, secondaryCache.get(TEST_KEY, TestObject.class)); CacheReplacementStrategy strategy = CacheReplacementStrategy.ERROR; TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class); @@ -199,6 +213,8 @@ void testOverwriteStrategyObjectConflict() { primaryCache.put(TEST_KEY, TEST_OBJECT); secondaryCache.put(TEST_KEY, secondary); + assertEquals(TEST_OBJECT, primaryCache.get(TEST_KEY, TestObject.class)); + assertEquals(secondary, secondaryCache.get(TEST_KEY, TestObject.class)); CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE; TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class); @@ -218,6 +234,8 @@ void testOverwriteStrategyNoOverwriteOnIdentical() { // Given both caches have the same value primaryCache.put(TEST_KEY, TEST_OBJECT); secondaryCache.put(TEST_KEY, TEST_OBJECT); + assertEquals(TEST_OBJECT, primaryCache.get(TEST_KEY, TestObject.class)); + assertEquals(TEST_OBJECT, secondaryCache.get(TEST_KEY, TestObject.class)); CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE; TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class); @@ -235,6 +253,8 @@ void testOverwriteStrategyNoOverwriteOnIdentical() { void testOverwriteStrategyNoOverwriteWhenSecondaryNull() { // Given primary has value but secondary is empty primaryCache.put(TEST_KEY, TEST_OBJECT); + assertEquals(TEST_OBJECT, primaryCache.get(TEST_KEY, TestObject.class)); + assertNull(secondaryCache.get(TEST_KEY, TestObject.class)); CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE; TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class); @@ -253,6 +273,8 @@ void testOverwriteStrategyNoOverwriteWhenSecondaryNull() { void testOverwriteStrategyNullPrimary() { // Given secondary has value but primary is empty secondaryCache.put(TEST_KEY, TEST_OBJECT); + assertNull(primaryCache.get(TEST_KEY, TestObject.class)); + assertEquals(TEST_OBJECT, secondaryCache.get(TEST_KEY, TestObject.class)); CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE; TestObject primaryValue = primaryCache.get(TEST_KEY, TestObject.class); @@ -266,6 +288,74 @@ void testOverwriteStrategyNullPrimary() { assertEquals(TEST_OBJECT, primaryCache.get(TEST_KEY, TestObject.class)); } + // ==================== ViaInternalKey Strategy Tests ==================== + + @Test + @DisplayName("NONE strategy via internal key: string backfill to primary when secondary has value") + void testNoneStrategyViaInternalKeyStringBackfillPrimary() { + // Given secondary has a string value but primary is null + secondaryCache.putViaInternalKey(cacheKeyInstance, TEST_VALUE); + assertNull(primaryCache.getViaInternalKey(cacheKeyInstance, String.class)); + assertEquals(TEST_VALUE, secondaryCache.getViaInternalKey(cacheKeyInstance, String.class)); + + CacheReplacementStrategy strategy = CacheReplacementStrategy.NONE; + String primaryValue = primaryCache.getViaInternalKey(cacheKeyInstance, String.class); + String secondaryValue = secondaryCache.getViaInternalKey(cacheKeyInstance, String.class); + + // When resolving via internal key + String result = strategy.resolveViaInternalKey( + cacheKeyInstance, primaryValue, primaryCache, secondaryValue, secondaryCache); + + // Then the secondary value is backfilled to primary using internal key + assertEquals(TEST_VALUE, result); + // Verify it was stored under the internal key, not the string representation of the key + assertEquals(TEST_VALUE, primaryCache.getViaInternalKey(cacheKeyInstance, String.class)); + assertEquals(TEST_VALUE, secondaryCache.getViaInternalKey(cacheKeyInstance, String.class)); + } + + @Test + @DisplayName("OVERWRITE strategy via internal key: string overwrite of secondary cache") + void testOverwriteStrategyViaInternalKeyStringConflict() { + // Given both caches have different string values + primaryCache.putViaInternalKey(cacheKeyInstance, TEST_VALUE); + secondaryCache.putViaInternalKey(cacheKeyInstance, CONFLICTING_VALUE); + assertEquals(TEST_VALUE, primaryCache.getViaInternalKey(cacheKeyInstance, String.class)); + assertEquals(CONFLICTING_VALUE, secondaryCache.getViaInternalKey(cacheKeyInstance, String.class)); + + CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE; + String primaryValue = primaryCache.getViaInternalKey(cacheKeyInstance, String.class); + String secondaryValue = secondaryCache.getViaInternalKey(cacheKeyInstance, String.class); + + // When resolving via internal key + String result = strategy.resolveViaInternalKey( + cacheKeyInstance, primaryValue, primaryCache, secondaryValue, secondaryCache); + + // Then primary value overwrites secondary via internal key + assertEquals(TEST_VALUE, result); + // Verify the secondary was updated with the internal key, not a converted string key + assertEquals(TEST_VALUE, secondaryCache.getViaInternalKey(cacheKeyInstance, String.class)); + } + + @Test + @DisplayName("OVERWRITE strategy via internal key: string backfill to secondary when primary is null") + void testOverwriteStrategyViaInternalKeyStringBackfillSecondary() { + // Given primary is null but secondary has a string value + secondaryCache.putViaInternalKey(cacheKeyInstance, TEST_VALUE); + assertNull(primaryCache.getViaInternalKey(cacheKeyInstance, String.class)); + assertEquals(TEST_VALUE, secondaryCache.getViaInternalKey(cacheKeyInstance, String.class)); + + CacheReplacementStrategy strategy = CacheReplacementStrategy.OVERWRITE; + String primaryValue = primaryCache.getViaInternalKey(cacheKeyInstance, String.class); + String secondaryValue = secondaryCache.getViaInternalKey(cacheKeyInstance, String.class); + + // When resolving via internal key + String result = strategy.resolveViaInternalKey( + cacheKeyInstance, primaryValue, primaryCache, secondaryValue, secondaryCache); + + // Then the secondary value is backfilled to primary using internal key + assertEquals(TEST_VALUE, result); + assertEquals(TEST_VALUE, primaryCache.getViaInternalKey(cacheKeyInstance, String.class)); + } // ==================== Helper Classes ==================== static class TestObject { @@ -285,8 +375,7 @@ static class TestObject { @Override public boolean equals(Object o) { if (this == o) return true; - if (!(o instanceof TestObject)) return false; - TestObject that = (TestObject) o; + if (!(o instanceof TestObject that)) return false; return value == that.value && Objects.equals(name, that.name); } @@ -294,6 +383,11 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(name, value); } + + @Override + public String toString() { + return "TestObject{" + "name='" + name + '\'' + ", value=" + value + '}'; + } } static class TestCacheKey implements CacheKey { @@ -310,12 +404,12 @@ static TestCacheKey of(CacheParameter cacheParameter, String conte } @Override - public String toJsonKey() { + public String localKey() { return keyValue; } @Override - public String localKey() { + public String toString() { return keyValue; } } From 5d1b624b09b5cdbeba61174646dc261abfde9ccc Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:36:30 +0200 Subject: [PATCH 27/34] docs: remove resolved comment --- .../java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java index 9ba29c08..ef6afd28 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheManager.java @@ -208,7 +208,6 @@ private Cache getCache(String name, CacheParameter pa private Cache buildCacheHierarchy(String cacheName, CacheParameter parameters) { ObjectMapper mapper = new ObjectMapper(); String cacheFilePath = directoryOfCaches.resolve(cacheName + ".json").toString(); - // Create cache instances for each type, skipping those that fail to initialize List> createdCaches = new ArrayList<>(); for (CacheType cacheType : hierarchyConfig) { Cache cache = Cache.createByType(cacheType, parameters, cacheFilePath, mapper); From d95c0baa66f27711c699e3045291c0eea57ab9e7 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:06:50 +0200 Subject: [PATCH 28/34] docs: update redis behaviour when unavailable --- docs/caching.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/caching.md b/docs/caching.md index d1d5fa1d..565c6470 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -130,8 +130,7 @@ The `Cache` interface provides two API levels: 2. Set environment variables if needed: - `CACHE_HIERARCHY=REDIS,LOCAL` to use Redis with local fallback - `REDIS_URL=redis://your-redis-host:6379` if not using the default - 3. The system will automatically use Redis if available - 4. If Redis is unavailable, it will fall back to local file-based caching (useful for replication packages) + 3. If Redis is unavailable, but configured to be used the system will fail. 4. **Best Practices** From e0d17be35241e4b8f3ab216fba9921ed61f3f904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Fuch=C3=9F?= Date: Sat, 20 Jun 2026 23:32:29 +0200 Subject: [PATCH 29/34] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java index 09374416..6e40f595 100644 --- a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java +++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java @@ -367,7 +367,7 @@ public void check(JavaClass clazz, ConditionEvents events) { @ArchTest static final ArchRule cacheManagerResetOnlyInTests = noClasses() .that() - .haveNameNotMatching(".*Test*") + .haveNameNotMatching(".*Test.*") .should() .callMethod(CacheManager.class, "resetDefaultInstance") .because( From c2b321d904d0a287114acfeb2819c0209e76a88f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Fuch=C3=9F?= Date: Sat, 20 Jun 2026 23:48:50 +0200 Subject: [PATCH 30/34] Fix comments --- .../java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java index 6e40f595..db0086a5 100644 --- a/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java +++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/ArchitectureTest.java @@ -382,7 +382,7 @@ public void check(JavaClass clazz, ConditionEvents events) { @ArchTest static final ArchRule environmentOverwriteOnlyInTests = noClasses() .that() - .haveNameNotMatching(".*Test*") + .haveNameNotMatching(".*Test.*") .should() .callMethod(Environment.class, "overwrite", Path.class) .because( From d9e9c82d73d5435328e0e9a80743cb9418f36ba7 Mon Sep 17 00:00:00 2001 From: DanielDango <45388651+DanielDango@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:24:18 +0200 Subject: [PATCH 31/34] docs: fix old references to fallback behaviour if redis is unavailable --- docs/caching.md | 2 +- .../java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/caching.md b/docs/caching.md index 565c6470..85234d13 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -98,7 +98,7 @@ The `Cache` interface provides two API levels: The caching system supports the following environment variables: - **CACHE_HIERARCHY**: Comma-separated list of cache types in order (e.g., "LOCAL,REDIS") - - Default: "REDIS, LOCAL" + - Default: "LOCAL" - Supported values: "LOCAL", "REDIS" - **CACHE_REPLACEMENT_STRATEGY**: Strategy for handling conflicts between cache layers - Default: "NONE" diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java index 9e2a7b8d..0028a4c7 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/RedisCache.java @@ -80,7 +80,7 @@ private void createRedisConnection() { * @param The type to deserialize the value to * @param key The cache key to look up * @param clazz The class of the type to deserialize to - * @return The deserialized value, or null if not found or Redis is unavailable + * @return The deserialized value, or null if not found */ @Override public synchronized @Nullable T get(String key, Class clazz) { From 62281fd00fc05b503d213a2ae7352b666e04aa8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Fuch=C3=9F?= Date: Wed, 24 Jun 2026 10:03:17 +0200 Subject: [PATCH 32/34] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java index b50efe50..427e7328 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/utils/Environment.java @@ -34,7 +34,7 @@ public final class Environment { private static final Logger logger = LoggerFactory.getLogger(Environment.class); /** The loaded .env configuration, or null if no .env file exists */ - private static @Nullable Dotenv dotenv = load(); + private static volatile @Nullable Dotenv dotenv = load(); private Environment() { throw new IllegalAccessError("Utility class"); From a5b22784191f33f74c6b20f97e22b69aeb0a4da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Fuch=C3=9F?= Date: Wed, 15 Jul 2026 16:06:51 +0200 Subject: [PATCH 33/34] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java index eff1d2fc..f00aed0a 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java @@ -192,9 +192,7 @@ public void flush() { * @return true if this map contains a mapping for the specified key */ @Override - public boolean containsKey(String key) { - K cacheKey = cacheParameter.createCacheKey(key); - return cache.containsKey(cacheKey.localKey()); +public synchronized boolean containsKey(String key) { } @Override From 8bb72e5a175b0aae53e24e951ca70e59a89b9482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Fuch=C3=9F?= Date: Wed, 15 Jul 2026 16:09:21 +0200 Subject: [PATCH 34/34] Fix copilot fix --- .../java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java index f00aed0a..8abce5aa 100644 --- a/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java @@ -192,7 +192,9 @@ public void flush() { * @return true if this map contains a mapping for the specified key */ @Override -public synchronized boolean containsKey(String key) { + public synchronized boolean containsKey(String key) { + K cacheKey = cacheParameter.createCacheKey(key); + return cache.containsKey(cacheKey.localKey()); } @Override