Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,365 @@
/*
* Copyright 2021-2025 Open Text.
*
* The only warranties for products and services of Open Text
* and its affiliates and licensors ("Open Text") are as may
* be set forth in the express warranty statements accompanying
* such products and services. Nothing herein should be construed
* as constituting an additional warranty. Open Text shall not be
* liable for technical or editorial errors or omissions contained
* herein. The information contained herein is subject to change
* without notice.
*/
package com.fortify.cli.util._common.helper;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.Function;

import com.fasterxml.jackson.databind.JsonNode;

import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
* Cache for fcli record-collecting operations. Provides background loading with
* progressive record access, suitable for both MCP and RPC servers.
*
* Features:
* - LRU cache with configurable size and TTL
* - Background async loading with partial result access
* - Cancel support for long-running collections
* - Thread-safe concurrent access
* - Support for session options through option resolver
*
* @author Ruud Senden
*/
@Slf4j
public class FcliRecordsCache {
private static final long DEFAULT_TTL = 10 * 60 * 1000; // 10 minutes
private static final int DEFAULT_MAX_ENTRIES = 5;
private static final int DEFAULT_BG_THREADS = 2;

private final long ttl;
private final int maxEntries;
private final Map<String, CacheEntry> cache;
private final Map<String, InProgressEntry> inProgress = new ConcurrentHashMap<>();
private final ExecutorService backgroundExecutor;
private Function<String, Map<String, String>> optionResolver;

public FcliRecordsCache() {
this(DEFAULT_MAX_ENTRIES, DEFAULT_TTL, DEFAULT_BG_THREADS);
}

public FcliRecordsCache(int maxEntries, long ttlMillis, int bgThreads) {
this.ttl = ttlMillis;
this.maxEntries = maxEntries;
// Use access-ordered LinkedHashMap for LRU behavior
this.cache = new LinkedHashMap<>(maxEntries, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, CacheEntry> eldest) {
return size() > maxEntries;
}
};
this.backgroundExecutor = Executors.newFixedThreadPool(bgThreads, r -> {
var t = new Thread(r, "fcli-cache-loader");
t.setDaemon(true);
return t;
});
log.info("Initialized FcliRecordsCache: maxEntries={} ttl={}ms bgThreads={}", maxEntries, ttlMillis, bgThreads);
}

/**
* Set a function to resolve default options for commands (e.g., session options).
*/
public void setOptionResolver(Function<String, Map<String, String>> resolver) {
this.optionResolver = resolver;
}

/**
* Get cached result, or start background collection if not cached.
* Returns null if result is already cached (caller should use getCached).
* Returns InProgressEntry if background collection started/exists.
*/
public InProgressEntry getOrStartBackground(String cacheKey, boolean refresh, String command) {
var cached = getCached(cacheKey);
if (!refresh && cached != null) {
return null; // Already cached
}

var existing = inProgress.get(cacheKey);
if (existing != null && !existing.isExpired(ttl)) {
return existing; // Already loading
}

return startNewBackgroundCollection(cacheKey, command);
}

/**
* Start a background collection and return immediately with the cacheKey.
*/
public String startBackgroundCollection(String command) {
var cacheKey = UUID.randomUUID().toString();
startNewBackgroundCollection(cacheKey, command);
return cacheKey;
}

private InProgressEntry startNewBackgroundCollection(String cacheKey, String command) {
var entry = new InProgressEntry(cacheKey, command);
inProgress.put(cacheKey, entry);

var future = buildCollectionFuture(entry, command);
future.whenComplete(createCompletionHandler(entry, cacheKey));

entry.setFuture(future);
log.debug("Started background collection: cacheKey={} command={}", cacheKey, command);

return entry;
}

private CompletableFuture<FcliToolResult> buildCollectionFuture(InProgressEntry entry, String command) {
// Resolve options before starting async execution
var defaultOptions = optionResolver != null ? optionResolver.apply(command) : null;

return CompletableFuture.supplyAsync(() -> {
var records = entry.getRecords();
var result = FcliRunnerHelper.collectRecords(command, record -> {
if (!Thread.currentThread().isInterrupted()) {
records.add(record);
}
}, defaultOptions);

if (Thread.currentThread().isInterrupted()) {
return null;
}

var fullResult = FcliToolResult.fromRecords(result, records);
if (result.getExitCode() == 0) {
put(entry.getCacheKey(), fullResult);
}
return fullResult;
}, backgroundExecutor);
}

private BiConsumer<FcliToolResult, Throwable> createCompletionHandler(InProgressEntry entry, String cacheKey) {
return (result, throwable) -> {
entry.setCompleted(true);
captureExecutionResult(entry, result, throwable);
cleanupFailedCollection(entry, cacheKey);
log.debug("Background collection completed: cacheKey={} exitCode={}", cacheKey, entry.getExitCode());
};
}

private void captureExecutionResult(InProgressEntry entry, FcliToolResult result, Throwable throwable) {
if (throwable != null) {
entry.setExitCode(999);
entry.setStderr(throwable.getMessage() != null ? throwable.getMessage() : "Background collection failed");
} else if (result != null) {
entry.setExitCode(result.getExitCode());
entry.setStderr(result.getStderr());
} else {
entry.setExitCode(999);
entry.setStderr("Cancelled");
}
}

private void cleanupFailedCollection(InProgressEntry entry, String cacheKey) {
if (entry.getExitCode() != 0) {
inProgress.remove(cacheKey);
}
}

/**
* Store a result in the cache.
*/
public void put(String cacheKey, FcliToolResult result) {
if (result == null) {
return;
}
synchronized (cache) {
cache.put(cacheKey, new CacheEntry(result));
}
log.debug("Cached result: cacheKey={} records={}", cacheKey, result.getRecords() != null ? result.getRecords().size() : 0);
}

/**
* Get a cached result if present and not expired.
*/
public FcliToolResult getCached(String cacheKey) {
synchronized (cache) {
var entry = cache.get(cacheKey);
return entry == null || entry.isExpired(ttl) ? null : entry.getFullResult();
}
}

/**
* Get an in-progress entry if exists.
*/
public InProgressEntry getInProgress(String cacheKey) {
return inProgress.get(cacheKey);
}

/**
* Wait for collection to complete (up to maxWaitMs) and return the result.
*/
public FcliToolResult waitForCompletion(String cacheKey, long maxWaitMs) {
var entry = inProgress.get(cacheKey);
if (entry == null) {
return getCached(cacheKey);
}

long start = System.currentTimeMillis();
while (!entry.isCompleted() && System.currentTimeMillis() - start < maxWaitMs) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}

if (entry.isCompleted()) {
inProgress.remove(cacheKey);
return getCached(cacheKey);
}

return null; // Still in progress
}

/**
* Cancel a background collection.
*/
public boolean cancel(String cacheKey) {
var entry = inProgress.get(cacheKey);
if (entry != null) {
entry.cancel();
inProgress.remove(cacheKey);
log.debug("Cancelled collection: cacheKey={}", cacheKey);
return true;
}
return false;
}

/**
* Clear a specific cache entry.
*/
public boolean clear(String cacheKey) {
boolean removed = false;
synchronized (cache) {
removed = cache.remove(cacheKey) != null;
}
var inProg = inProgress.remove(cacheKey);
if (inProg != null) {
inProg.cancel();
removed = true;
}
return removed;
}

/**
* Clear all cache entries.
*/
public void clearAll() {
synchronized (cache) {
cache.clear();
}
inProgress.values().forEach(InProgressEntry::cancel);
inProgress.clear();
log.debug("Cleared all cache entries");
}

/**
* Get cache statistics.
*/
public CacheStats getStats() {
int cached;
synchronized (cache) {
cached = cache.size();
}
return new CacheStats(cached, inProgress.size());
}

/**
* Shutdown the cache and background executor.
*/
public void shutdown() {
backgroundExecutor.shutdown();
try {
backgroundExecutor.awaitTermination(2, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
backgroundExecutor.shutdownNow();
log.info("FcliRecordsCache shutdown complete");
}

/**
* In-progress tracking entry giving access to partial records list.
*/
@Data
public static final class InProgressEntry {
private final String cacheKey;
private final String command;
private final long created = System.currentTimeMillis();
private final CopyOnWriteArrayList<JsonNode> records = new CopyOnWriteArrayList<>();
private volatile CompletableFuture<FcliToolResult> future;
private volatile boolean completed = false;
private volatile int exitCode = 0;
private volatile String stderr = "";

public InProgressEntry(String cacheKey, String command) {
this.cacheKey = cacheKey;
this.command = command;
}

public boolean isExpired(long ttl) {
return System.currentTimeMillis() > created + ttl;
}

public void setFuture(CompletableFuture<FcliToolResult> f) {
this.future = f;
}

public void cancel() {
if (future != null) {
future.cancel(true);
}
}

public int getLoadedCount() {
return records.size();
}

public List<JsonNode> getRecordsSnapshot() {
return List.copyOf(records);
}
}

@Data
@RequiredArgsConstructor
private static final class CacheEntry {
private final FcliToolResult fullResult;
private final long created = System.currentTimeMillis();

public boolean isExpired(long ttl) {
return System.currentTimeMillis() > created + ttl;
}
}

@Data
@RequiredArgsConstructor
public static final class CacheStats {
private final int cachedEntries;
private final int inProgressEntries;
}
}
Loading
Loading