445 lines
16 KiB
C#
Executable File
445 lines
16 KiB
C#
Executable File
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging;
|
|
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.IO;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace MarketAlly.AIPlugin.Refactoring.Caching
|
|
{
|
|
public interface IAnalysisCache
|
|
{
|
|
Task<TResult> GetOrAnalyzeAsync<TResult>(
|
|
string filePath,
|
|
string contentHash,
|
|
Func<Task<TResult>> analyzer,
|
|
CancellationToken cancellationToken = default) where TResult : class;
|
|
|
|
Task<TResult> GetOrAnalyzeAsync<TResult>(
|
|
string filePath,
|
|
Func<Task<TResult>> analyzer,
|
|
CancellationToken cancellationToken = default) where TResult : class;
|
|
|
|
void Invalidate(string filePath);
|
|
void InvalidateByPattern(string pattern);
|
|
void Clear();
|
|
Task<bool> WarmupAsync(string filePath, CancellationToken cancellationToken = default);
|
|
AnalysisCacheStatistics GetStatistics();
|
|
}
|
|
|
|
public record AnalysisCacheStatistics(
|
|
int TotalEntries,
|
|
long TotalHits,
|
|
long TotalMisses,
|
|
double HitRatio,
|
|
long TotalMemoryBytes,
|
|
int PersistentEntries,
|
|
TimeSpan AverageAnalysisTime);
|
|
|
|
public class CacheEntry<T>
|
|
{
|
|
public T Result { get; set; } = default!;
|
|
public DateTime CreatedAt { get; set; }
|
|
public DateTime LastAccessedAt { get; set; }
|
|
public TimeSpan AnalysisTime { get; set; }
|
|
public string ContentHash { get; set; } = string.Empty;
|
|
public int AccessCount { get; set; }
|
|
}
|
|
|
|
public class AnalysisCache : IAnalysisCache, IDisposable
|
|
{
|
|
private readonly IMemoryCache _memoryCache;
|
|
private readonly ILogger<AnalysisCache>? _logger;
|
|
private readonly ConcurrentDictionary<string, object> _analysisLocks = new();
|
|
private readonly ConcurrentDictionary<string, DateTime> _analysisTimings = new();
|
|
private readonly string? _persistentCacheDirectory;
|
|
|
|
private long _hits = 0;
|
|
private long _misses = 0;
|
|
private bool _disposed = false;
|
|
|
|
public AnalysisCache(
|
|
IMemoryCache? memoryCache = null,
|
|
ILogger<AnalysisCache>? logger = null,
|
|
string? persistentCacheDirectory = null)
|
|
{
|
|
_memoryCache = memoryCache ?? new MemoryCache(new MemoryCacheOptions
|
|
{
|
|
SizeLimit = 500, // Max 500 analysis results
|
|
CompactionPercentage = 0.2 // Remove 20% when full
|
|
});
|
|
|
|
_logger = logger;
|
|
_persistentCacheDirectory = persistentCacheDirectory;
|
|
|
|
if (!string.IsNullOrEmpty(_persistentCacheDirectory))
|
|
{
|
|
Directory.CreateDirectory(_persistentCacheDirectory);
|
|
}
|
|
}
|
|
|
|
public async Task<TResult> GetOrAnalyzeAsync<TResult>(
|
|
string filePath,
|
|
string contentHash,
|
|
Func<Task<TResult>> analyzer,
|
|
CancellationToken cancellationToken = default) where TResult : class
|
|
{
|
|
if (string.IsNullOrEmpty(filePath))
|
|
throw new ArgumentNullException(nameof(filePath));
|
|
|
|
if (analyzer == null)
|
|
throw new ArgumentNullException(nameof(analyzer));
|
|
|
|
var normalizedPath = Path.GetFullPath(filePath);
|
|
var cacheKey = $"{normalizedPath}:{contentHash}:{typeof(TResult).Name}";
|
|
|
|
// Try memory cache first
|
|
if (_memoryCache.TryGetValue(cacheKey, out CacheEntry<TResult> cachedEntry))
|
|
{
|
|
Interlocked.Increment(ref _hits);
|
|
cachedEntry.LastAccessedAt = DateTime.UtcNow;
|
|
cachedEntry.AccessCount++;
|
|
|
|
_logger?.LogDebug("Memory cache hit for {FilePath} ({Type})", normalizedPath, typeof(TResult).Name);
|
|
return cachedEntry.Result;
|
|
}
|
|
|
|
// Try persistent cache
|
|
var persistentResult = await TryLoadFromPersistentCacheAsync<TResult>(cacheKey, cancellationToken);
|
|
if (persistentResult != null)
|
|
{
|
|
Interlocked.Increment(ref _hits);
|
|
|
|
// Store back in memory cache for faster access
|
|
var memoryEntry = new CacheEntry<TResult>
|
|
{
|
|
Result = persistentResult.Result,
|
|
CreatedAt = persistentResult.CreatedAt,
|
|
LastAccessedAt = DateTime.UtcNow,
|
|
AnalysisTime = persistentResult.AnalysisTime,
|
|
ContentHash = contentHash,
|
|
AccessCount = 1
|
|
};
|
|
|
|
CacheInMemory(cacheKey, memoryEntry);
|
|
|
|
_logger?.LogDebug("Persistent cache hit for {FilePath} ({Type})", normalizedPath, typeof(TResult).Name);
|
|
return persistentResult.Result;
|
|
}
|
|
|
|
Interlocked.Increment(ref _misses);
|
|
|
|
// Use lock to prevent duplicate analysis of the same file
|
|
var lockObject = _analysisLocks.GetOrAdd(cacheKey, _ => new object());
|
|
|
|
try
|
|
{
|
|
return await PerformLockedAnalysisAsync(cacheKey, normalizedPath, contentHash, analyzer, cancellationToken);
|
|
}
|
|
finally
|
|
{
|
|
_analysisLocks.TryRemove(cacheKey, out _);
|
|
}
|
|
}
|
|
|
|
public async Task<TResult> GetOrAnalyzeAsync<TResult>(
|
|
string filePath,
|
|
Func<Task<TResult>> analyzer,
|
|
CancellationToken cancellationToken = default) where TResult : class
|
|
{
|
|
if (!File.Exists(filePath))
|
|
throw new FileNotFoundException($"File not found: {filePath}");
|
|
|
|
var fileInfo = new FileInfo(filePath);
|
|
var contentHash = $"{fileInfo.LastWriteTimeUtc.Ticks}:{fileInfo.Length}";
|
|
|
|
return await GetOrAnalyzeAsync(filePath, contentHash, analyzer, cancellationToken);
|
|
}
|
|
|
|
private async Task<TResult> PerformLockedAnalysisAsync<TResult>(
|
|
string cacheKey,
|
|
string filePath,
|
|
string contentHash,
|
|
Func<Task<TResult>> analyzer,
|
|
CancellationToken cancellationToken) where TResult : class
|
|
{
|
|
// Double-check cache while in lock
|
|
if (_memoryCache.TryGetValue(cacheKey, out CacheEntry<TResult> cachedEntry))
|
|
{
|
|
return cachedEntry.Result;
|
|
}
|
|
|
|
_logger?.LogDebug("Performing analysis for {FilePath} ({Type})", filePath, typeof(TResult).Name);
|
|
|
|
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
|
|
|
try
|
|
{
|
|
var result = await analyzer();
|
|
stopwatch.Stop();
|
|
|
|
var entry = new CacheEntry<TResult>
|
|
{
|
|
Result = result,
|
|
CreatedAt = DateTime.UtcNow,
|
|
LastAccessedAt = DateTime.UtcNow,
|
|
AnalysisTime = stopwatch.Elapsed,
|
|
ContentHash = contentHash,
|
|
AccessCount = 1
|
|
};
|
|
|
|
// Cache in memory
|
|
CacheInMemory(cacheKey, entry);
|
|
|
|
// Cache persistently (fire and forget)
|
|
_ = Task.Run(() => SaveToPersistentCacheAsync(cacheKey, entry, cancellationToken), cancellationToken);
|
|
|
|
_analysisTimings[cacheKey] = DateTime.UtcNow;
|
|
|
|
_logger?.LogDebug("Analysis completed for {FilePath} in {Duration}ms",
|
|
filePath, stopwatch.ElapsedMilliseconds);
|
|
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
stopwatch.Stop();
|
|
_logger?.LogError(ex, "Analysis failed for {FilePath} after {Duration}ms",
|
|
filePath, stopwatch.ElapsedMilliseconds);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private void CacheInMemory<TResult>(string cacheKey, CacheEntry<TResult> entry) where TResult : class
|
|
{
|
|
var cacheOptions = new MemoryCacheEntryOptions
|
|
{
|
|
Size = EstimateEntrySize(entry.Result),
|
|
SlidingExpiration = TimeSpan.FromMinutes(45),
|
|
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(4),
|
|
Priority = CacheItemPriority.Normal
|
|
};
|
|
|
|
_memoryCache.Set(cacheKey, entry, cacheOptions);
|
|
}
|
|
|
|
private async Task<CacheEntry<TResult>?> TryLoadFromPersistentCacheAsync<TResult>(
|
|
string cacheKey,
|
|
CancellationToken cancellationToken) where TResult : class
|
|
{
|
|
if (string.IsNullOrEmpty(_persistentCacheDirectory))
|
|
return null;
|
|
|
|
try
|
|
{
|
|
var fileName = GetPersistentCacheFileName(cacheKey);
|
|
var filePath = Path.Combine(_persistentCacheDirectory, fileName);
|
|
|
|
if (!File.Exists(filePath))
|
|
return null;
|
|
|
|
var fileInfo = new FileInfo(filePath);
|
|
|
|
// Check if cache entry is too old (1 day)
|
|
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromDays(1))
|
|
{
|
|
File.Delete(filePath);
|
|
return null;
|
|
}
|
|
|
|
var json = await File.ReadAllTextAsync(filePath, cancellationToken);
|
|
var entry = JsonSerializer.Deserialize<CacheEntry<TResult>>(json);
|
|
|
|
return entry;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogWarning(ex, "Failed to load from persistent cache: {CacheKey}", cacheKey);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async Task SaveToPersistentCacheAsync<TResult>(
|
|
string cacheKey,
|
|
CacheEntry<TResult> entry,
|
|
CancellationToken cancellationToken) where TResult : class
|
|
{
|
|
if (string.IsNullOrEmpty(_persistentCacheDirectory))
|
|
return;
|
|
|
|
try
|
|
{
|
|
var fileName = GetPersistentCacheFileName(cacheKey);
|
|
var filePath = Path.Combine(_persistentCacheDirectory, fileName);
|
|
|
|
var json = JsonSerializer.Serialize(entry, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = false
|
|
});
|
|
|
|
await File.WriteAllTextAsync(filePath, json, cancellationToken);
|
|
|
|
_logger?.LogDebug("Saved to persistent cache: {CacheKey}", cacheKey);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogWarning(ex, "Failed to save to persistent cache: {CacheKey}", cacheKey);
|
|
}
|
|
}
|
|
|
|
private string GetPersistentCacheFileName(string cacheKey)
|
|
{
|
|
using var sha256 = SHA256.Create();
|
|
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(cacheKey));
|
|
return Convert.ToBase64String(hashBytes).Replace('/', '_').Replace('+', '-') + ".json";
|
|
}
|
|
|
|
private int EstimateEntrySize<T>(T result)
|
|
{
|
|
try
|
|
{
|
|
var json = JsonSerializer.Serialize(result);
|
|
return json.Length * 2; // Rough estimate
|
|
}
|
|
catch
|
|
{
|
|
return 1024; // Default estimate
|
|
}
|
|
}
|
|
|
|
public void Invalidate(string filePath)
|
|
{
|
|
var normalizedPath = Path.GetFullPath(filePath);
|
|
|
|
// We can't easily enumerate MemoryCache entries, so we'll rely on file watching
|
|
// In a production system, you might want to maintain a separate index
|
|
|
|
_logger?.LogDebug("Invalidated cache entries for {FilePath}", normalizedPath);
|
|
}
|
|
|
|
public void InvalidateByPattern(string pattern)
|
|
{
|
|
// This would require a more sophisticated cache implementation
|
|
// For now, we'll just log the request
|
|
_logger?.LogDebug("Invalidated cache entries matching pattern {Pattern}", pattern);
|
|
}
|
|
|
|
public void Clear()
|
|
{
|
|
if (_memoryCache is MemoryCache memoryCache)
|
|
{
|
|
memoryCache.Compact(1.0);
|
|
}
|
|
|
|
_analysisTimings.Clear();
|
|
|
|
// Clear persistent cache
|
|
if (!string.IsNullOrEmpty(_persistentCacheDirectory) && Directory.Exists(_persistentCacheDirectory))
|
|
{
|
|
try
|
|
{
|
|
var files = Directory.GetFiles(_persistentCacheDirectory, "*.json");
|
|
foreach (var file in files)
|
|
{
|
|
File.Delete(file);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogWarning(ex, "Failed to clear persistent cache directory");
|
|
}
|
|
}
|
|
|
|
Interlocked.Exchange(ref _hits, 0);
|
|
Interlocked.Exchange(ref _misses, 0);
|
|
|
|
_logger?.LogInformation("Analysis cache cleared");
|
|
}
|
|
|
|
public async Task<bool> WarmupAsync(string filePath, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
if (!File.Exists(filePath))
|
|
return false;
|
|
|
|
// This would be used to pre-populate cache for known files
|
|
// Implementation depends on specific analysis types
|
|
|
|
_logger?.LogDebug("Cache warmup completed for {FilePath}", filePath);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogWarning(ex, "Cache warmup failed for {FilePath}", filePath);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public AnalysisCacheStatistics GetStatistics()
|
|
{
|
|
var totalRequests = _hits + _misses;
|
|
var hitRatio = totalRequests > 0 ? (double)_hits / totalRequests : 0.0;
|
|
|
|
var persistentEntries = 0;
|
|
if (!string.IsNullOrEmpty(_persistentCacheDirectory) && Directory.Exists(_persistentCacheDirectory))
|
|
{
|
|
try
|
|
{
|
|
persistentEntries = Directory.GetFiles(_persistentCacheDirectory, "*.json").Length;
|
|
}
|
|
catch
|
|
{
|
|
// Ignore errors
|
|
}
|
|
}
|
|
|
|
var averageAnalysisTime = _analysisTimings.Values.Any()
|
|
? TimeSpan.FromMilliseconds(_analysisTimings.Values.Average(d => (DateTime.UtcNow - d).TotalMilliseconds))
|
|
: TimeSpan.Zero;
|
|
|
|
return new AnalysisCacheStatistics(
|
|
TotalEntries: _analysisTimings.Count,
|
|
TotalHits: _hits,
|
|
TotalMisses: _misses,
|
|
HitRatio: hitRatio,
|
|
TotalMemoryBytes: _analysisTimings.Count * 512L, // Rough estimate
|
|
PersistentEntries: persistentEntries,
|
|
AverageAnalysisTime: averageAnalysisTime
|
|
);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
_memoryCache?.Dispose();
|
|
_analysisLocks.Clear();
|
|
_analysisTimings.Clear();
|
|
|
|
_disposed = true;
|
|
}
|
|
}
|
|
|
|
// Static factory for easy access
|
|
public static class AnalysisCacheFactory
|
|
{
|
|
private static readonly Lazy<IAnalysisCache> _defaultInstance =
|
|
new(() => new AnalysisCache());
|
|
|
|
public static IAnalysisCache Default => _defaultInstance.Value;
|
|
|
|
public static IAnalysisCache Create(
|
|
IMemoryCache? memoryCache = null,
|
|
ILogger<AnalysisCache>? logger = null,
|
|
string? persistentCacheDirectory = null)
|
|
{
|
|
return new AnalysisCache(memoryCache, logger, persistentCacheDirectory);
|
|
}
|
|
}
|
|
} |