mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 12:27:53 +08:00
feat(executor): retention sweep and per-conversation purge for tool-result spill files
This commit is contained in:
parent
7015d1513c
commit
86a6829102
@ -97,6 +97,23 @@ public class ToolResultProperties {
|
|||||||
*/
|
*/
|
||||||
private List<String> excludedTools = List.of("read_file", "read_workspace_memory_file");
|
private List<String> excludedTools = List.of("read_file", "read_workspace_memory_file");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Days to retain spill files before the scheduled cleanup deletes them.
|
||||||
|
* Spill files exist to let the model recover full tool output via
|
||||||
|
* {@code read_file} during the active conversation; once the conversation
|
||||||
|
* is dormant for this many days the agent is extremely unlikely to ever
|
||||||
|
* read the file again, and disk pressure starts to matter.
|
||||||
|
*/
|
||||||
|
private int retentionDays = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron expression for the spill-cleanup task. Defaults to once a day at
|
||||||
|
* 03:00 server-local time so cleanup runs during quiet hours. Set this
|
||||||
|
* to a Spring-recognised value (six-field cron) or change the bean
|
||||||
|
* wiring to disable it entirely.
|
||||||
|
*/
|
||||||
|
private String cleanupCron = "0 0 3 * * ?";
|
||||||
|
|
||||||
public boolean isEnabled() { return enabled; }
|
public boolean isEnabled() { return enabled; }
|
||||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||||
|
|
||||||
@ -130,6 +147,14 @@ public class ToolResultProperties {
|
|||||||
this.excludedTools = excludedTools == null ? List.of() : excludedTools;
|
this.excludedTools = excludedTools == null ? List.of() : excludedTools;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getRetentionDays() { return retentionDays; }
|
||||||
|
public void setRetentionDays(int retentionDays) { this.retentionDays = retentionDays; }
|
||||||
|
|
||||||
|
public String getCleanupCron() { return cleanupCron; }
|
||||||
|
public void setCleanupCron(String cleanupCron) {
|
||||||
|
this.cleanupCron = cleanupCron == null ? "" : cleanupCron;
|
||||||
|
}
|
||||||
|
|
||||||
/** O(1) membership test for the exclusion list, used on every tool result. */
|
/** O(1) membership test for the exclusion list, used on every tool result. */
|
||||||
public Set<String> excludedToolsSet() {
|
public Set<String> excludedToolsSet() {
|
||||||
return Set.copyOf(excludedTools);
|
return Set.copyOf(excludedTools);
|
||||||
|
|||||||
@ -0,0 +1,54 @@
|
|||||||
|
package vip.mate.agent.graph.executor;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives {@link ToolResultStorage#cleanupExpired()} on a cron schedule so
|
||||||
|
* spill files don't accumulate forever. Kept in its own class instead of
|
||||||
|
* inlined into {@link ToolResultStorage} for two reasons:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Tests can exercise {@code cleanupExpired()} directly without
|
||||||
|
* fighting the Spring scheduler.</li>
|
||||||
|
* <li>Deployments that want to disable the schedule entirely can simply
|
||||||
|
* leave this component out of the autoconfigure path.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>The cron expression comes from
|
||||||
|
* {@link ToolResultProperties#getCleanupCron()} (default {@code 0 0 3 * * ?},
|
||||||
|
* i.e. once a day at 03:00 server-local time). The retention horizon comes
|
||||||
|
* from {@link ToolResultProperties#getRetentionDays()}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ToolResultRetentionScheduler {
|
||||||
|
|
||||||
|
private final ToolResultStorage storage;
|
||||||
|
private final ToolResultProperties props;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron-fired hook. Failures are logged at WARN so they show up in
|
||||||
|
* standard log scrapes without aborting the scheduler thread — losing
|
||||||
|
* a single sweep is fine, the next one will catch the same files.
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "${mate.agent.tool-result.cleanup-cron:0 0 3 * * ?}")
|
||||||
|
public void cleanup() {
|
||||||
|
if (props.getRetentionDays() <= 0) {
|
||||||
|
log.debug("[ToolResultRetentionScheduler] retentionDays<=0, skipping sweep");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int deleted = storage.cleanupExpired();
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.info("[ToolResultRetentionScheduler] sweep deleted {} spill file(s) older than {} days",
|
||||||
|
deleted, props.getRetentionDays());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ToolResultRetentionScheduler] sweep failed: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -58,6 +58,15 @@ public class ToolResultStorage {
|
|||||||
/** D-6: monotonically increasing spill counter for observability. */
|
/** D-6: monotonically increasing spill counter for observability. */
|
||||||
private final java.util.concurrent.atomic.AtomicLong spillCount = new java.util.concurrent.atomic.AtomicLong();
|
private final java.util.concurrent.atomic.AtomicLong spillCount = new java.util.concurrent.atomic.AtomicLong();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workspace roots observed during this JVM's lifetime. Populated every
|
||||||
|
* time a successful spill resolves a base directory; consulted by the
|
||||||
|
* scheduled retention sweep and by {@link #purgeConversation} so we
|
||||||
|
* don't have to query the database for every workspace path. Cross-JVM
|
||||||
|
* orphans are not covered — that is documented in the cleanup javadoc.
|
||||||
|
*/
|
||||||
|
private final java.util.Set<Path> observedRoots = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
public ToolResultStorage(ToolResultProperties props) {
|
public ToolResultStorage(ToolResultProperties props) {
|
||||||
this.props = props;
|
this.props = props;
|
||||||
this.excludedToolsSnapshot = props.excludedToolsSet();
|
this.excludedToolsSnapshot = props.excludedToolsSet();
|
||||||
@ -270,15 +279,162 @@ public class ToolResultStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Path resolveBaseDir(String workspaceBasePath) {
|
private Path resolveBaseDir(String workspaceBasePath) {
|
||||||
|
Path base;
|
||||||
if (!props.getStorageBaseDir().isEmpty()) {
|
if (!props.getStorageBaseDir().isEmpty()) {
|
||||||
return Paths.get(props.getStorageBaseDir());
|
base = Paths.get(props.getStorageBaseDir());
|
||||||
|
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
|
||||||
|
base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
|
||||||
|
} else {
|
||||||
|
String tmp = System.getProperty("java.io.tmpdir");
|
||||||
|
if (tmp == null || tmp.isEmpty()) return null;
|
||||||
|
base = Paths.get(tmp, "mateclaw", "tool-results");
|
||||||
}
|
}
|
||||||
if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
|
// Register so the retention sweep and conversation-delete hook can
|
||||||
return Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
|
// reach this root even when the workspace path is no longer in scope.
|
||||||
|
observedRoots.add(base);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roots currently known to this instance. Exposed package-private so the
|
||||||
|
* scheduled retention sweep and unit tests can enumerate them without
|
||||||
|
* touching the underlying set directly.
|
||||||
|
*/
|
||||||
|
java.util.Set<Path> getObservedRoots() {
|
||||||
|
return java.util.Collections.unmodifiableSet(observedRoots);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort: delete every spill file and per-conversation directory
|
||||||
|
* older than {@link ToolResultProperties#getRetentionDays()} across all
|
||||||
|
* roots this storage has seen, plus the configured base dir and the
|
||||||
|
* tmpdir fallback. Returns the number of files deleted.
|
||||||
|
*
|
||||||
|
* <p>Workspaces that never received a spill in this JVM's lifetime are
|
||||||
|
* not covered. Persisting an observed-roots registry across restarts
|
||||||
|
* could fix that, but is intentionally out of scope — the operator-side
|
||||||
|
* remedy is to run a one-off cleanup with {@code storage-base-dir}
|
||||||
|
* pointed at the historical workspace.
|
||||||
|
*/
|
||||||
|
public int cleanupExpired() {
|
||||||
|
if (props.getRetentionDays() <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
long cutoffEpochMillis = System.currentTimeMillis()
|
||||||
|
- (long) props.getRetentionDays() * 24L * 60L * 60L * 1000L;
|
||||||
|
|
||||||
|
java.util.Set<Path> roots = new java.util.LinkedHashSet<>(observedRoots);
|
||||||
|
if (!props.getStorageBaseDir().isEmpty()) {
|
||||||
|
roots.add(Paths.get(props.getStorageBaseDir()));
|
||||||
}
|
}
|
||||||
String tmp = System.getProperty("java.io.tmpdir");
|
String tmp = System.getProperty("java.io.tmpdir");
|
||||||
if (tmp == null || tmp.isEmpty()) return null;
|
if (tmp != null && !tmp.isEmpty()) {
|
||||||
return Paths.get(tmp, "mateclaw", "tool-results");
|
roots.add(Paths.get(tmp, "mateclaw", "tool-results"));
|
||||||
|
}
|
||||||
|
|
||||||
|
int deleted = 0;
|
||||||
|
for (Path root : roots) {
|
||||||
|
deleted += deleteExpiredUnder(root, cutoffEpochMillis);
|
||||||
|
}
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.info("[ToolResultStorage] cleanup: {} spill files removed across {} root(s)",
|
||||||
|
deleted, roots.size());
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int deleteExpiredUnder(Path root, long cutoffEpochMillis) {
|
||||||
|
if (root == null || !java.nio.file.Files.isDirectory(root)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int deleted = 0;
|
||||||
|
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.walk(root, 2)) {
|
||||||
|
for (Path p : (Iterable<Path>) stream::iterator) {
|
||||||
|
if (p.equals(root)) continue;
|
||||||
|
if (!java.nio.file.Files.isRegularFile(p)) continue;
|
||||||
|
try {
|
||||||
|
long mtime = java.nio.file.Files.getLastModifiedTime(p).toMillis();
|
||||||
|
if (mtime < cutoffEpochMillis) {
|
||||||
|
java.nio.file.Files.deleteIfExists(p);
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
} catch (java.io.IOException ioe) {
|
||||||
|
log.warn("[ToolResultStorage] failed to inspect spill file {}: {}", p, ioe.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (java.io.IOException ioe) {
|
||||||
|
log.warn("[ToolResultStorage] cleanup walk failed under {}: {}", root, ioe.getMessage());
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
// Best-effort: remove emptied per-conversation directories.
|
||||||
|
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.list(root)) {
|
||||||
|
for (Path child : (Iterable<Path>) stream::iterator) {
|
||||||
|
if (!java.nio.file.Files.isDirectory(child)) continue;
|
||||||
|
try (java.util.stream.Stream<Path> kids = java.nio.file.Files.list(child)) {
|
||||||
|
if (kids.findAny().isEmpty()) {
|
||||||
|
java.nio.file.Files.deleteIfExists(child);
|
||||||
|
}
|
||||||
|
} catch (java.io.IOException ignored) {
|
||||||
|
// empty-check failure is not fatal — leave the directory alone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (java.io.IOException ioe) {
|
||||||
|
log.warn("[ToolResultStorage] empty-dir sweep failed under {}: {}", root, ioe.getMessage());
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete every spill file produced for {@code conversationId} across
|
||||||
|
* all observed roots, plus the configured base and tmpdir fallback.
|
||||||
|
* Called by {@code ConversationService.deleteConversation} so spill
|
||||||
|
* directories don't outlive the conversation that owns them.
|
||||||
|
*
|
||||||
|
* <p>Silently no-ops when nothing matches — a conversation that never
|
||||||
|
* spilled, or one whose workspace root was never observed in this JVM,
|
||||||
|
* is simply left alone. Returns the number of files deleted.
|
||||||
|
*/
|
||||||
|
public int purgeConversation(String conversationId) {
|
||||||
|
if (conversationId == null || conversationId.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
String safeConv = sanitize(conversationId);
|
||||||
|
java.util.Set<Path> roots = new java.util.LinkedHashSet<>(observedRoots);
|
||||||
|
if (!props.getStorageBaseDir().isEmpty()) {
|
||||||
|
roots.add(Paths.get(props.getStorageBaseDir()));
|
||||||
|
}
|
||||||
|
String tmp = System.getProperty("java.io.tmpdir");
|
||||||
|
if (tmp != null && !tmp.isEmpty()) {
|
||||||
|
roots.add(Paths.get(tmp, "mateclaw", "tool-results"));
|
||||||
|
}
|
||||||
|
int deleted = 0;
|
||||||
|
for (Path root : roots) {
|
||||||
|
Path convDir = root.resolve(safeConv);
|
||||||
|
if (!java.nio.file.Files.isDirectory(convDir)) continue;
|
||||||
|
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.list(convDir)) {
|
||||||
|
for (Path p : (Iterable<Path>) stream::iterator) {
|
||||||
|
try {
|
||||||
|
if (java.nio.file.Files.isRegularFile(p)) {
|
||||||
|
java.nio.file.Files.deleteIfExists(p);
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
} catch (java.io.IOException ioe) {
|
||||||
|
log.warn("[ToolResultStorage] failed to delete spill file {}: {}", p, ioe.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (java.io.IOException ioe) {
|
||||||
|
log.warn("[ToolResultStorage] purge walk failed under {}: {}", convDir, ioe.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
java.nio.file.Files.deleteIfExists(convDir);
|
||||||
|
} catch (java.io.IOException ignored) {
|
||||||
|
// non-empty after deletes (another writer raced us) — fine, leave it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.info("[ToolResultStorage] purged {} spill file(s) for conversation {}", deleted, conversationId);
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip path separators and reserved characters so user-supplied IDs cannot escape the directory. */
|
/** Strip path separators and reserved characters so user-supplied IDs cannot escape the directory. */
|
||||||
|
|||||||
@ -65,6 +65,19 @@ public class ConversationService {
|
|||||||
private final ChannelSessionMapper channelSessionMapper;
|
private final ChannelSessionMapper channelSessionMapper;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional spill store. Injected via a setter so the existing @RequiredArgsConstructor
|
||||||
|
* stays stable and tests that build the service directly don't need to wire
|
||||||
|
* tool-result storage. When present, deleteConversation also purges any spill
|
||||||
|
* files this conversation produced so they don't outlive the row that owned them.
|
||||||
|
*/
|
||||||
|
private vip.mate.agent.graph.executor.ToolResultStorage toolResultStorage;
|
||||||
|
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||||
|
public void setToolResultStorage(vip.mate.agent.graph.executor.ToolResultStorage toolResultStorage) {
|
||||||
|
this.toolResultStorage = toolResultStorage;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户的会话列表(返回 VO,包含 agentName/agentIcon/status)
|
* 获取用户的会话列表(返回 VO,包含 agentName/agentIcon/status)
|
||||||
*/
|
*/
|
||||||
@ -501,15 +514,34 @@ public class ConversationService {
|
|||||||
@Override
|
@Override
|
||||||
public void afterCommit() {
|
public void afterCommit() {
|
||||||
cleanAttachmentFiles(conversationId);
|
cleanAttachmentFiles(conversationId);
|
||||||
|
purgeToolResultSpill(conversationId);
|
||||||
eventPublisher.publishEvent(new ConversationDeletedEvent(conversationId));
|
eventPublisher.publishEvent(new ConversationDeletedEvent(conversationId));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
cleanAttachmentFiles(conversationId);
|
cleanAttachmentFiles(conversationId);
|
||||||
|
purgeToolResultSpill(conversationId);
|
||||||
eventPublisher.publishEvent(new ConversationDeletedEvent(conversationId));
|
eventPublisher.publishEvent(new ConversationDeletedEvent(conversationId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort: ask the spill store to delete every tool-result file this
|
||||||
|
* conversation produced. No-op when no spill store is wired in (legacy
|
||||||
|
* deployments or tests that don't need spill). Failures are logged but
|
||||||
|
* never propagated — leaving an extra file on disk is a small price
|
||||||
|
* compared to surfacing IO errors as a 500 on the delete endpoint.
|
||||||
|
*/
|
||||||
|
private void purgeToolResultSpill(String conversationId) {
|
||||||
|
if (toolResultStorage == null) return;
|
||||||
|
try {
|
||||||
|
toolResultStorage.purgeConversation(conversationId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[Conversation] tool-result spill purge failed for {}: {}",
|
||||||
|
conversationId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空会话消息(同时清理附件文件)
|
* 清空会话消息(同时清理附件文件)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -229,6 +229,11 @@ mate:
|
|||||||
excluded-tools:
|
excluded-tools:
|
||||||
- read_file
|
- read_file
|
||||||
- read_workspace_memory_file
|
- read_workspace_memory_file
|
||||||
|
# Spill files are deleted after this many days. Set to 0 to disable the
|
||||||
|
# scheduled sweep entirely (files still get purged when the conversation
|
||||||
|
# is deleted explicitly via ConversationService.deleteConversation).
|
||||||
|
retention-days: 7
|
||||||
|
cleanup-cron: "0 0 3 * * ?"
|
||||||
conversation:
|
conversation:
|
||||||
window:
|
window:
|
||||||
# 测试时临时调低:2000 token ≈ 2000 中文字,3 轮对话即可触发压缩
|
# 测试时临时调低:2000 token ≈ 2000 中文字,3 轮对话即可触发压缩
|
||||||
|
|||||||
@ -0,0 +1,161 @@
|
|||||||
|
package vip.mate.agent.graph.executor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.FileTime;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retention sweep + per-conversation purge for {@link ToolResultStorage}.
|
||||||
|
*
|
||||||
|
* <p>The store keeps a per-JVM "observed roots" registry — every time a
|
||||||
|
* spill resolves a directory, that directory is remembered so the
|
||||||
|
* retention sweep can reach it even after the workspace path has gone
|
||||||
|
* out of scope. These tests verify the registry behaviour, the
|
||||||
|
* mtime-based deletion contract, and the targeted per-conversation purge
|
||||||
|
* called from {@code ConversationService.deleteConversation}.
|
||||||
|
*/
|
||||||
|
class ToolResultStorageRetentionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void successfulSpillRegistersTheRoot(@TempDir Path tempDir) {
|
||||||
|
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 7);
|
||||||
|
|
||||||
|
// Trigger a spill so resolveBaseDir() is invoked.
|
||||||
|
String out = storage.persistIfOversized(
|
||||||
|
"x".repeat(500), "web_search", "call-1", "conv-A", tempDir.toString());
|
||||||
|
assertTrue(out.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX));
|
||||||
|
|
||||||
|
assertTrue(storage.getObservedRoots().contains(tempDir),
|
||||||
|
"successful spill must register its resolved root for later cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleanupDeletesFilesOlderThanRetention(@TempDir Path tempDir) throws Exception {
|
||||||
|
// retention = 1 day
|
||||||
|
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 1);
|
||||||
|
|
||||||
|
// Drop a "fresh" spill via the public API.
|
||||||
|
String fresh = storage.persistIfOversized(
|
||||||
|
"fresh".repeat(200), "web_search", "call-fresh", "conv-A", tempDir.toString());
|
||||||
|
assertTrue(fresh.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX));
|
||||||
|
Path freshFile = pathFromPreview(fresh);
|
||||||
|
|
||||||
|
// Drop a "stale" spill and back-date its mtime by 8 days.
|
||||||
|
String stale = storage.persistIfOversized(
|
||||||
|
"stale".repeat(200), "web_search", "call-stale", "conv-B", tempDir.toString());
|
||||||
|
Path staleFile = pathFromPreview(stale);
|
||||||
|
Files.setLastModifiedTime(staleFile,
|
||||||
|
FileTime.from(Instant.now().minus(8, ChronoUnit.DAYS)));
|
||||||
|
|
||||||
|
int deleted = storage.cleanupExpired();
|
||||||
|
|
||||||
|
assertEquals(1, deleted, "only the stale file should be removed");
|
||||||
|
assertTrue(Files.exists(freshFile), "fresh file must survive");
|
||||||
|
assertFalse(Files.exists(staleFile), "stale file must be deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleanupIsNoOpWhenRetentionDisabled(@TempDir Path tempDir) throws Exception {
|
||||||
|
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 0);
|
||||||
|
|
||||||
|
String stale = storage.persistIfOversized(
|
||||||
|
"stale".repeat(200), "web_search", "call-1", "conv-A", tempDir.toString());
|
||||||
|
Path staleFile = pathFromPreview(stale);
|
||||||
|
Files.setLastModifiedTime(staleFile,
|
||||||
|
FileTime.from(Instant.now().minus(100, ChronoUnit.DAYS)));
|
||||||
|
|
||||||
|
int deleted = storage.cleanupExpired();
|
||||||
|
assertEquals(0, deleted, "retentionDays<=0 must disable the sweep entirely");
|
||||||
|
assertTrue(Files.exists(staleFile), "stale file must remain when sweep is disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleanupRemovesEmptiedConversationDirectories(@TempDir Path tempDir) throws Exception {
|
||||||
|
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 1);
|
||||||
|
|
||||||
|
String stale = storage.persistIfOversized(
|
||||||
|
"stale".repeat(200), "web_search", "call-stale", "conv-old", tempDir.toString());
|
||||||
|
Path staleFile = pathFromPreview(stale);
|
||||||
|
Path staleDir = staleFile.getParent();
|
||||||
|
Files.setLastModifiedTime(staleFile,
|
||||||
|
FileTime.from(Instant.now().minus(8, ChronoUnit.DAYS)));
|
||||||
|
|
||||||
|
storage.cleanupExpired();
|
||||||
|
|
||||||
|
assertFalse(Files.exists(staleFile));
|
||||||
|
assertFalse(Files.exists(staleDir),
|
||||||
|
"the empty conv-old/ directory should be cleaned up too");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void purgeConversationDeletesAllFilesForOneConversation(@TempDir Path tempDir) throws Exception {
|
||||||
|
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 30);
|
||||||
|
|
||||||
|
// Two spills for conv-A, one spill for conv-B.
|
||||||
|
String a1 = storage.persistIfOversized(
|
||||||
|
"a1".repeat(200), "web_search", "call-a1", "conv-A", tempDir.toString());
|
||||||
|
String a2 = storage.persistIfOversized(
|
||||||
|
"a2".repeat(200), "web_search", "call-a2", "conv-A", tempDir.toString());
|
||||||
|
String b1 = storage.persistIfOversized(
|
||||||
|
"b1".repeat(200), "web_search", "call-b1", "conv-B", tempDir.toString());
|
||||||
|
Path af1 = pathFromPreview(a1);
|
||||||
|
Path af2 = pathFromPreview(a2);
|
||||||
|
Path bf1 = pathFromPreview(b1);
|
||||||
|
|
||||||
|
int deleted = storage.purgeConversation("conv-A");
|
||||||
|
|
||||||
|
assertEquals(2, deleted, "both A files should be deleted");
|
||||||
|
assertFalse(Files.exists(af1));
|
||||||
|
assertFalse(Files.exists(af2));
|
||||||
|
assertTrue(Files.exists(bf1), "conv-B files must not be touched by a conv-A purge");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void purgeConversationIsSilentForUnknownConversation(@TempDir Path tempDir) {
|
||||||
|
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 7);
|
||||||
|
|
||||||
|
// No spill at all → nothing to purge → 0, no exception.
|
||||||
|
int deleted = storage.purgeConversation("never-existed");
|
||||||
|
assertEquals(0, deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void purgeConversationHandlesBlankIdSafely(@TempDir Path tempDir) {
|
||||||
|
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 7);
|
||||||
|
|
||||||
|
assertEquals(0, storage.purgeConversation(null));
|
||||||
|
assertEquals(0, storage.purgeConversation(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ helpers
|
||||||
|
|
||||||
|
private static ToolResultStorage newStorage(Path tempDir, int threshold, int retentionDays) {
|
||||||
|
ToolResultProperties props = new ToolResultProperties();
|
||||||
|
props.setStorageBaseDir(tempDir.toString());
|
||||||
|
props.setPerResultThresholdChars(threshold);
|
||||||
|
props.setPreviewHeadChars(80);
|
||||||
|
props.setRetentionDays(retentionDays);
|
||||||
|
props.setExcludedTools(List.of());
|
||||||
|
return new ToolResultStorage(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path pathFromPreview(String preview) {
|
||||||
|
java.util.regex.Matcher m = java.util.regex.Pattern.compile("path=(\\S+)").matcher(preview);
|
||||||
|
assertTrue(m.find(), "preview must include path=...");
|
||||||
|
Path p = Path.of(m.group(1));
|
||||||
|
assertNotNull(p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user