MarketAlly.AIPlugin.Extensions/MarketAlly.AIPlugin.Refacto.../Caching/AnalysisCache.cs

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);
}
}
}