From 3874da6d89a444ae731e9e74117901014f45ff27 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:01:49 +0000 Subject: [PATCH] Optimize TaskCache persistence with append-only format Co-authored-by: alstafeev <18335072+alstafeev@users.noreply.github.com> --- .../com/midscene/core/cache/TaskCache.java | 83 +++++++++---- .../midscene/core/cache/TaskCacheTest.java | 112 ++++++++++++++++++ 2 files changed, 175 insertions(+), 20 deletions(-) create mode 100644 midscene-core/src/test/java/com/midscene/core/cache/TaskCacheTest.java diff --git a/midscene-core/src/main/java/com/midscene/core/cache/TaskCache.java b/midscene-core/src/main/java/com/midscene/core/cache/TaskCache.java index e3bafa1c..3266d4d4 100644 --- a/midscene-core/src/main/java/com/midscene/core/cache/TaskCache.java +++ b/midscene-core/src/main/java/com/midscene/core/cache/TaskCache.java @@ -3,13 +3,16 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.MappingIterator; import com.fasterxml.jackson.databind.json.JsonMapper; import com.midscene.core.pojo.planning.PlanningResponse; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Collections; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import lombok.extern.log4j.Log4j2; @@ -27,6 +30,8 @@ public class TaskCache { private final Map memoryCache = new ConcurrentHashMap<>(); private CacheMode mode; private Path cacheFilePath; + private final Object fileLock = new Object(); + /** * Creates a new TaskCache with the given mode and optional file path. * @@ -125,7 +130,7 @@ public void put(String prompt, PlanningResponse response) { log.debug("Cached response for prompt key: {}", key.substring(0, 8)); if (cacheFilePath != null) { - saveToFile(); + appendToFile(key, response); } } @@ -241,38 +246,76 @@ private void loadFromFile() { } try { - String json = Files.readString(cacheFilePath); - Map loaded = MAPPER.readValue( - json, new TypeReference>() { - }); - memoryCache.putAll(loaded); - log.info("Loaded {} cache entries from {}", loaded.size(), cacheFilePath); + if (Files.size(cacheFilePath) == 0) { + return; + } + + try (com.fasterxml.jackson.core.JsonParser parser = MAPPER.createParser(cacheFilePath.toFile())) { + MappingIterator> it = MAPPER.readValues( + parser, new TypeReference>() { + }); + while (it.hasNext()) { + Map batch = it.next(); + memoryCache.putAll(batch); + } + } + log.info("Loaded {} cache entries from {}", memoryCache.size(), cacheFilePath); } catch (IOException e) { log.warn("Failed to load cache from file: {}", e.getMessage()); } } /** - * Saves cache entries to file. + * Appends a single cache entry to the file. */ - private void saveToFile() { + private void appendToFile(String key, PlanningResponse response) { if (cacheFilePath == null) { return; } - try { - // Ensure parent directory exists - Path parent = cacheFilePath.getParent(); - if (parent != null && !Files.exists(parent)) { - Files.createDirectories(parent); + synchronized (fileLock) { + try { + // Ensure parent directory exists + Path parent = cacheFilePath.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + + // We write a minimal JSON object for this entry + Map entry = Collections.singletonMap(key, response); + String json = MAPPER.writeValueAsString(entry); + + Files.writeString(cacheFilePath, json + System.lineSeparator(), + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (IOException e) { + log.warn("Failed to append cache to file: {}", e.getMessage()); } + } + } - String json = MAPPER.writerWithDefaultPrettyPrinter() - .writeValueAsString(memoryCache); - Files.writeString(cacheFilePath, json); - log.debug("Saved {} cache entries to {}", memoryCache.size(), cacheFilePath); - } catch (IOException e) { - log.warn("Failed to save cache to file: {}", e.getMessage()); + /** + * Saves (rewrites) all cache entries to file. + */ + private void saveToFile() { + if (cacheFilePath == null) { + return; + } + + synchronized (fileLock) { + try { + // Ensure parent directory exists + Path parent = cacheFilePath.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + + String json = MAPPER.writerWithDefaultPrettyPrinter() + .writeValueAsString(memoryCache); + Files.writeString(cacheFilePath, json, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + log.debug("Saved {} cache entries to {}", memoryCache.size(), cacheFilePath); + } catch (IOException e) { + log.warn("Failed to save cache to file: {}", e.getMessage()); + } } } diff --git a/midscene-core/src/test/java/com/midscene/core/cache/TaskCacheTest.java b/midscene-core/src/test/java/com/midscene/core/cache/TaskCacheTest.java new file mode 100644 index 00000000..c72452cf --- /dev/null +++ b/midscene-core/src/test/java/com/midscene/core/cache/TaskCacheTest.java @@ -0,0 +1,112 @@ +package com.midscene.core.cache; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.midscene.core.pojo.planning.PlanningResponse; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TaskCacheTest { + + @TempDir + Path tempDir; + + @Test + public void testPersistence() { + Path cacheFile = tempDir.resolve("cache.json"); + TaskCache cache = TaskCache.withFile(cacheFile); + + String prompt = "test_prompt"; + PlanningResponse response = new PlanningResponse(); + response.setLog("test log"); + + cache.put(prompt, response); + Assertions.assertTrue(cache.contains(prompt)); + + // Create new instance to simulate restart + TaskCache cache2 = TaskCache.withFile(cacheFile); + Assertions.assertTrue(cache2.contains(prompt)); + Assertions.assertEquals("test log", cache2.get(prompt).getLog()); + } + + @Test + public void testAppendOnlyFormat() throws IOException { + Path cacheFile = tempDir.resolve("append_cache.json"); + TaskCache cache = TaskCache.withFile(cacheFile); + + cache.put("p1", new PlanningResponse()); + cache.put("p2", new PlanningResponse()); + + // Check file content + String content = Files.readString(cacheFile); + // Should contain two JSON objects, likely on separate lines or just concatenated + // Since we use append with line separator + long lines = Files.lines(cacheFile).count(); + Assertions.assertEquals(2, lines); + } + + @Test + public void testLegacyFormatCompatibility() throws IOException { + Path cacheFile = tempDir.resolve("legacy_cache.json"); + + // Manually write legacy format (single map) + ObjectMapper mapper = new ObjectMapper(); + Map data = Map.of( + "legacy_key", new PlanningResponse() + ); + String legacyJson = mapper.writeValueAsString(data); + Files.writeString(cacheFile, legacyJson); + + // Load with TaskCache + TaskCache cache = TaskCache.withFile(cacheFile); + // We cannot check contains("legacy_key") because that would check hash("legacy_key") + // But we can verify the size is 1, meaning it loaded the entry. + Assertions.assertEquals(1, cache.size()); + } + + @Test + public void testInvalidateRewritesFile() throws IOException { + Path cacheFile = tempDir.resolve("invalidate_cache.json"); + TaskCache cache = TaskCache.withFile(cacheFile); + + cache.put("p1", new PlanningResponse()); + cache.put("p2", new PlanningResponse()); + + long linesBefore = Files.lines(cacheFile).count(); + Assertions.assertEquals(2, linesBefore); + + cache.invalidate("p1"); + + // After invalidate, it should have rewritten the file. + // It might be one line (legacy format used pretty printer? No, I used default pretty printer in saveToFile). + // If pretty printer is used, it's multiple lines. + // But let's check content. + TaskCache cache2 = TaskCache.withFile(cacheFile); + Assertions.assertEquals(1, cache2.size()); + Assertions.assertFalse(cache2.contains("p1")); + Assertions.assertTrue(cache2.contains("p2")); + } + + @Test + public void testClearTruncatesFile() throws IOException { + Path cacheFile = tempDir.resolve("clear_cache.json"); + TaskCache cache = TaskCache.withFile(cacheFile); + + cache.put("p1", new PlanningResponse()); + cache.clear(); + + // File should contain empty JSON object "{}" which is not 0 bytes + String content = Files.readString(cacheFile).trim().replace(" ", "").replace("\n", "").replace("\r", ""); + Assertions.assertEquals("{}", content); + Assertions.assertEquals(0, cache.size()); + + TaskCache cache2 = TaskCache.withFile(cacheFile); + Assertions.assertEquals(0, cache2.size()); + } +}