diff --git a/docs/caching.md b/docs/caching.md index 599bbed3..d1d5fa1d 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 @@ -59,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** (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. + ### Cache API The `Cache` interface provides two API levels: @@ -81,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: "REDIS, LOCAL" + - Supported values: "LOCAL", "REDIS" + - **CACHE_REPLACEMENT_STRATEGY**: Strategy for handling conflicts between cache layers + - Default: "NONE" + - 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 @@ -101,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 1dfc0d5d..47b8097c 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 +#CACHE_REPLACEMENT_STRATEGY=ERROR + +# 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 +# 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 c72e5d2c..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 @@ -1,8 +1,13 @@ /* 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; +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 +90,67 @@ 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); + } + } + + /** + * Factory method to create a cache instance by type name. + * Supported types: + *
    + *
  • "local" - LocalCache for file-based storage
  • + *
  • "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 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 -> { + 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"); + }; + } } 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..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 @@ -4,10 +4,20 @@ 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.utils.Environment; + +import redis.clients.jedis.exceptions.JedisConnectionException; /** * Manages caching operations in the LiSSA framework. @@ -21,15 +31,25 @@ public final class CacheManager { */ public static final String DEFAULT_CACHE_DIRECTORY = "cache"; + /** + * 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"; + /** * 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. */ - private static final boolean DEFAULT_REPLACE_LOCAL_CACHE_ON_CONFLICT = true; + private static final CacheReplacementStrategy DEFAULT_REPLACEMENT_STRATEGY = CacheReplacementStrategy.NONE; private static @Nullable CacheManager defaultInstanceManager; private final Path directoryOfCaches; - private final Map> caches = new HashMap<>(); + private final CacheReplacementStrategy replacementStrategy; + private final List hierarchyConfig; + 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. @@ -42,6 +62,46 @@ public static synchronized void setCacheDir(@Nullable String directory) throws I defaultInstanceManager = new CacheManager(Path.of(directory == null ? DEFAULT_CACHE_DIRECTORY : directory)); } + /** + * Reads the cache replacement strategy from environment variables. + * This method: + *
    + *
  1. First checks the environment variable CACHE_REPLACEMENT_STRATEGY
  2. + *
  3. If not found, uses the default strategy ({@link #DEFAULT_REPLACEMENT_STRATEGY})
  4. + *
+ * + * @return The cache replacement strategy + * @throws IllegalArgumentException If the environment variable value is set but invalid + */ + private static CacheReplacementStrategy readCacheReplacementStrategy() { + String strategyValue = Environment.getenv("CACHE_REPLACEMENT_STRATEGY"); + if (strategyValue == null) { + return DEFAULT_REPLACEMENT_STRATEGY; + } + + try { + return CacheReplacementStrategy.valueOf(strategyValue.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Invalid CACHE_REPLACEMENT_STRATEGY value: " + strategyValue + ". See " + + CacheReplacementStrategy.class + " for valid options.", + e); + } + } + + /** + * 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. @@ -51,11 +111,30 @@ public static synchronized void setCacheDir(@Nullable String directory) throws I * @throws IllegalArgumentException If the path exists but is not a directory */ public CacheManager(Path cacheDir) throws IOException { + this(cacheDir, readCacheReplacementStrategy(), parseCacheHierarchy(readHierarchyString())); + } + + /** + * 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)) { throw new IllegalArgumentException("path is not a directory: " + cacheDir); } + this.directoryOfCaches = cacheDir; + this.replacementStrategy = replacementStrategy; + this.hierarchyConfig = hierarchyConfig; } /** @@ -108,12 +187,75 @@ private Cache getCache(String name, CacheParameter pa return cached; } - LocalCache localCache = new LocalCache<>(directoryOfCaches + "/" + name + ".json", parameters); - RedisCache cache = new RedisCache<>(parameters, localCache, DEFAULT_REPLACE_LOCAL_CACHE_ON_CONFLICT); + 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) { + 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 (String cacheType : hierarchyConfig) { + 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), replacementStrategy); + } + 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). + * 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 + */ + private static List parseCacheHierarchy(String hierarchyConfig) { + String[] types = hierarchyConfig.replace("'", "").replace('"', ' ').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; + } + /** * Flushes all caches managed by this cache manager. * This ensures that all pending changes are written to disk. @@ -123,4 +265,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; + } } 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..cd11d176 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheReplacementStrategy.java @@ -0,0 +1,192 @@ +/* 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 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 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. + * + * @deprecated This method exposes internal cache key handling and should not be used in general code. + */ + @Override + public @Nullable T resolve( + String 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); + } + return super.resolve(key, primaryValue, primaryCache, secondaryValue, secondaryCache); + } + + /** + * Throws an exception when a conflict is detected between the two caches. + */ + @Override + @Deprecated(forRemoval = false) + @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); + } + return super.resolveViaInternalKey(key, primaryValue, primaryCache, secondaryValue, secondaryCache); + } + }, + + /** + * Replaces the conflicting value in the secondary cache with the value from the primary cache. + */ + OVERWRITE { + /** + * 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 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.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. + * + * @deprecated This method exposes internal cache key handling and should not be used in general code. + */ + @Override + @Deprecated(forRemoval = false) + @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); + } + }; + + private static final Logger logger = LoggerFactory.getLogger(CacheReplacementStrategy.class); + + /** + * 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) + */ + public @Nullable T resolve( + String key, + @Nullable T primaryValue, + Cache primaryCache, + @Nullable T secondaryValue, + 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 This method exposes internal cache key handling and should not be used in general code. + */ + @Deprecated(forRemoval = false) + @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 new file mode 100644 index 00000000..21108fa6 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/HierarchicalCache.java @@ -0,0 +1,110 @@ +/* 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.resolveViaInternalKey( + key, 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/LocalCache.java b/src/main/java/edu/kit/kastel/sdq/lissa/ratlr/cache/LocalCache.java index 5c8b3091..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 @@ -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,13 @@ * 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 { + + public static final String LOCAL_CACHE_NAME = "local"; + private final ObjectMapper mapper; /** @@ -47,6 +53,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 +121,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 +135,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 +164,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 be550e88..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,192 +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; - - /** - * Flag indicating whether to replace local cache entries on conflict with Redis values. - */ - private final boolean replaceLocalCacheOnConflict; + 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 - * @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, boolean replaceLocalCacheOnConflict) { + 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.replaceLocalCacheOnConflict = replaceLocalCacheOnConflict; } @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 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. * * @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 convert(jsonData, clazz); - } - String localData = localCache.get(key); - // 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); - } - // 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 = jsonData != null ? jsonData : localData; - return convert(valueToReturn, clazz); + 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 convert(jsonData, clazz); - } - String localData = localCache.getViaInternalKey(cacheKey); - // 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); - } - // 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); - } - - String 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); - } + 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 @@ -210,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())); } /** @@ -248,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 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..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 @@ -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"); @@ -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"))) { @@ -104,4 +104,25 @@ 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 + */ + public static synchronized void 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); + } + } } 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 fa896498..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 @@ -29,6 +29,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; @@ -356,4 +357,34 @@ public void check(JavaClass clazz, ConditionEvents events) { } } }); + + /** + * 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. + */ + @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"); + + /** + * 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/cache/CacheTest.java b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java new file mode 100644 index 00000000..43116b48 --- /dev/null +++ b/src/test/java/edu/kit/kastel/sdq/lissa/ratlr/cache/CacheTest.java @@ -0,0 +1,163 @@ +/* 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 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.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; + + @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 + @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 + @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; + + @SuppressWarnings("unused") + 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); + } + + @SuppressWarnings("unused") + 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/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 + 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