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

286 lines
10 KiB
C#
Executable File

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
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.Threading;
using System.Threading.Tasks;
namespace MarketAlly.AIPlugin.Refactoring.Caching
{
public interface ISyntaxTreeCache
{
Task<SyntaxTree> GetOrCreateAsync(string filePath, CancellationToken cancellationToken = default);
Task<SyntaxTree> GetOrCreateAsync(string filePath, string content, CancellationToken cancellationToken = default);
void Invalidate(string filePath);
void Clear();
CacheStatistics GetStatistics();
}
public record CacheStatistics(
int TotalEntries,
long TotalHits,
long TotalMisses,
double HitRatio,
long TotalMemoryBytes);
public class SyntaxTreeCache : ISyntaxTreeCache, IDisposable
{
private readonly IMemoryCache _cache;
private readonly ILogger<SyntaxTreeCache>? _logger;
private readonly ConcurrentDictionary<string, FileSystemWatcher> _watchers = new();
private readonly ConcurrentDictionary<string, DateTime> _lastModified = new();
private readonly object _statsLock = new();
private long _hits = 0;
private long _misses = 0;
private bool _disposed = false;
public SyntaxTreeCache(IMemoryCache? cache = null, ILogger<SyntaxTreeCache>? logger = null)
{
_cache = cache ?? new MemoryCache(new MemoryCacheOptions
{
SizeLimit = 1000, // Max 1000 entries
CompactionPercentage = 0.1 // Remove 10% when full
});
_logger = logger;
}
public async Task<SyntaxTree> GetOrCreateAsync(string filePath, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(filePath))
throw new ArgumentNullException(nameof(filePath));
var normalizedPath = Path.GetFullPath(filePath);
var fileInfo = new FileInfo(normalizedPath);
if (!fileInfo.Exists)
throw new FileNotFoundException($"File not found: {filePath}");
var cacheKey = GenerateCacheKey(normalizedPath, fileInfo.LastWriteTimeUtc);
if (_cache.TryGetValue(cacheKey, out SyntaxTree cachedTree))
{
Interlocked.Increment(ref _hits);
_logger?.LogDebug("Cache hit for {FilePath}", normalizedPath);
return cachedTree;
}
Interlocked.Increment(ref _misses);
_logger?.LogDebug("Cache miss for {FilePath}", normalizedPath);
// Parse the file
var content = await File.ReadAllTextAsync(normalizedPath, cancellationToken);
var syntaxTree = await ParseFileAsync(normalizedPath, content, cancellationToken);
// Cache the result
var cacheOptions = new MemoryCacheEntryOptions
{
Size = EstimateTreeSize(syntaxTree),
SlidingExpiration = TimeSpan.FromMinutes(30),
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(2),
Priority = CacheItemPriority.Normal
};
_cache.Set(cacheKey, syntaxTree, cacheOptions);
// Set up file watching for cache invalidation
EnsureFileWatcher(normalizedPath);
_lastModified[normalizedPath] = fileInfo.LastWriteTimeUtc;
return syntaxTree;
}
public async Task<SyntaxTree> GetOrCreateAsync(string filePath, string content, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(filePath))
throw new ArgumentNullException(nameof(filePath));
if (content == null)
throw new ArgumentNullException(nameof(content));
var normalizedPath = Path.GetFullPath(filePath);
var contentHash = ComputeContentHash(content);
var cacheKey = $"{normalizedPath}:{contentHash}";
if (_cache.TryGetValue(cacheKey, out SyntaxTree cachedTree))
{
Interlocked.Increment(ref _hits);
_logger?.LogDebug("Cache hit for content-based key {FilePath}", normalizedPath);
return cachedTree;
}
Interlocked.Increment(ref _misses);
_logger?.LogDebug("Cache miss for content-based key {FilePath}", normalizedPath);
var syntaxTree = await ParseFileAsync(normalizedPath, content, cancellationToken);
var cacheOptions = new MemoryCacheEntryOptions
{
Size = EstimateTreeSize(syntaxTree),
SlidingExpiration = TimeSpan.FromMinutes(30),
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(2),
Priority = CacheItemPriority.Normal
};
_cache.Set(cacheKey, syntaxTree, cacheOptions);
return syntaxTree;
}
public void Invalidate(string filePath)
{
var normalizedPath = Path.GetFullPath(filePath);
// Remove from last modified tracking
_lastModified.TryRemove(normalizedPath, out _);
// We can't easily remove specific entries from MemoryCache without knowing the exact key
// In a production system, you might want to use a more sophisticated cache implementation
// For now, we'll rely on the file watcher to handle invalidation on the next access
_logger?.LogDebug("Invalidated cache entries for {FilePath}", normalizedPath);
}
public void Clear()
{
if (_cache is MemoryCache memoryCache)
{
memoryCache.Compact(1.0); // Remove all entries
}
_lastModified.Clear();
// Reset statistics
lock (_statsLock)
{
_hits = 0;
_misses = 0;
}
_logger?.LogInformation("Cache cleared");
}
public CacheStatistics GetStatistics()
{
lock (_statsLock)
{
var totalRequests = _hits + _misses;
var hitRatio = totalRequests > 0 ? (double)_hits / totalRequests : 0.0;
// Estimate memory usage (rough approximation)
var memoryUsage = _lastModified.Count * 1024L; // Rough estimate
return new CacheStatistics(
TotalEntries: _lastModified.Count,
TotalHits: _hits,
TotalMisses: _misses,
HitRatio: hitRatio,
TotalMemoryBytes: memoryUsage
);
}
}
private async Task<SyntaxTree> ParseFileAsync(string filePath, string content, CancellationToken cancellationToken)
{
return await Task.Run(() =>
CSharpSyntaxTree.ParseText(content, path: filePath, cancellationToken: cancellationToken),
cancellationToken);
}
private string GenerateCacheKey(string filePath, DateTime lastModified)
{
return $"{filePath}:{lastModified.Ticks}";
}
private string ComputeContentHash(string content)
{
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(content));
return Convert.ToBase64String(hashBytes);
}
private int EstimateTreeSize(SyntaxTree syntaxTree)
{
// Rough estimation of memory size
// In practice, you might want a more accurate calculation
var text = syntaxTree.GetText();
return text.Length * 2; // Rough estimate: 2 bytes per character
}
private void EnsureFileWatcher(string filePath)
{
var directory = Path.GetDirectoryName(filePath);
var fileName = Path.GetFileName(filePath);
if (string.IsNullOrEmpty(directory) || _watchers.ContainsKey(filePath))
return;
try
{
var watcher = new FileSystemWatcher(directory, fileName)
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size,
EnableRaisingEvents = true
};
watcher.Changed += (sender, e) => OnFileChanged(e.FullPath);
watcher.Deleted += (sender, e) => OnFileChanged(e.FullPath);
_watchers[filePath] = watcher;
_logger?.LogDebug("File watcher set up for {FilePath}", filePath);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to set up file watcher for {FilePath}", filePath);
}
}
private void OnFileChanged(string filePath)
{
_logger?.LogDebug("File changed: {FilePath}", filePath);
Invalidate(filePath);
}
public void Dispose()
{
if (_disposed)
return;
foreach (var watcher in _watchers.Values)
{
try
{
watcher.Dispose();
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error disposing file watcher");
}
}
_watchers.Clear();
_cache?.Dispose();
_disposed = true;
}
}
// Static factory for easy access
public static class SyntaxTreeCacheFactory
{
private static readonly Lazy<ISyntaxTreeCache> _defaultInstance =
new(() => new SyntaxTreeCache());
public static ISyntaxTreeCache Default => _defaultInstance.Value;
public static ISyntaxTreeCache Create(IMemoryCache? cache = null, ILogger<SyntaxTreeCache>? logger = null)
{
return new SyntaxTreeCache(cache, logger);
}
}
}