complete refactor

This commit is contained in:
2025-12-15 09:26:27 +01:00
parent 41c7ec292c
commit fbf3b6826c
45 changed files with 3001 additions and 1474 deletions

View File

@@ -0,0 +1,208 @@
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace Blueberry.Redmine
{
class RedmineCache<T>
{
private List<T> Items { get; set; } = [];
public DateTime LastUpdated { get; set; } = DateTime.MinValue;
private readonly ILogger _logger;
private readonly TimeSpan _cacheDuration;
private readonly object _lock = new();
private int _maxCapacity = int.MaxValue;
private bool _useSlidingExpiration = false;
private DateTime _lastAccessed = DateTime.MinValue;
private string? _cacheFilePath;
private Func<Task<List<T>>>? _refreshCallback;
private class CacheData
{
public List<T> Items { get; set; } = [];
public DateTime LastUpdated { get; set; }
public DateTime LastAccessed { get; set; }
}
/// <summary>
/// Initializes a new instance of the RedmineCache class.
/// </summary>
/// <param name="cacheDuration">The time span for which the cache remains valid. After this duration, the cache is considered expired.</param>
/// <param name="logger">The logger instance for logging cache operations.</param>
/// <param name="maxCapacity">The maximum number of items to store in the cache. Defaults to int.MaxValue (unlimited).</param>
/// <param name="useSlidingExpiration">If true, the cache expiration resets on each access (sliding expiration). If false, uses absolute expiration from the last update. Defaults to false.</param>
/// <param name="cacheFilePath">Optional file path for persisting the cache to disk. If provided, the cache loads from and saves to this file. Defaults to null (no persistence).</param>
/// <param name="refreshCallback">Optional asynchronous callback function to refresh the cache when it expires. Called automatically in GetItemsAsync if the cache is invalid. Defaults to null.</param>
public RedmineCache(TimeSpan cacheDuration, ILogger<RedmineCache<T>> logger, int maxCapacity = int.MaxValue, bool useSlidingExpiration = false,
string? cacheFilePath = null, Func<Task<List<T>>>? refreshCallback = null)
{
if (logger == null) throw new ArgumentNullException(nameof(logger));
if (cacheDuration <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(cacheDuration));
_logger = logger;
_cacheDuration = cacheDuration;
_maxCapacity = maxCapacity;
_useSlidingExpiration = useSlidingExpiration;
_cacheFilePath = cacheFilePath;
_refreshCallback = refreshCallback;
LoadFromFile();
}
/// <summary>
/// Initializes a new instance of the RedmineCache class with cache duration in seconds.
/// </summary>
/// <param name="cacheDurationSec">The cache duration in seconds. Converted to a TimeSpan internally.</param>
/// <param name="logger">The logger instance for logging cache operations.</param>
/// <param name="maxCapacity">The maximum number of items to store in the cache. Defaults to int.MaxValue (unlimited).</param>
/// <param name="useSlidingExpiration">If true, the cache expiration resets on each access (sliding expiration). If false, uses absolute expiration from the last update. Defaults to false.</param>
/// <param name="cacheFilePath">Optional file path for persisting the cache to disk. If provided, the cache loads from and saves to this file. Defaults to null (no persistence).</param>
/// <param name="refreshCallback">Optional asynchronous callback function to refresh the cache when it expires. Called automatically in GetItemsAsync if the cache is invalid. Defaults to null.</param>
public RedmineCache(int cacheDurationSec, ILogger<RedmineCache<T>> logger, int maxCapacity = int.MaxValue, bool useSlidingExpiration = false,
string? cacheFilePath = null, Func<Task<List<T>>>? refreshCallback = null)
: this(new TimeSpan(0, 0, cacheDurationSec), logger, maxCapacity, useSlidingExpiration, cacheFilePath, refreshCallback) { }
private void LoadFromFile()
{
if (_cacheFilePath == null || !File.Exists(_cacheFilePath)) return;
try
{
var json = File.ReadAllText(_cacheFilePath);
var data = JsonSerializer.Deserialize<CacheData>(json);
if (data != null)
{
Items = data.Items ?? [];
LastUpdated = data.LastUpdated;
_lastAccessed = data.LastAccessed;
}
_logger.LogDebug("Loaded cache from {path}", _cacheFilePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load cache from {path}", _cacheFilePath);
}
}
private void SaveToFile()
{
if (_cacheFilePath == null) return;
try
{
var data = new CacheData { Items = Items, LastUpdated = LastUpdated, LastAccessed = _lastAccessed };
var json = JsonSerializer.Serialize(data);
var dir = Path.GetDirectoryName(_cacheFilePath) ?? throw new NullReferenceException();
Directory.CreateDirectory(dir);
File.WriteAllText(_cacheFilePath, json);
_logger.LogDebug("Saved cache to {path}", _cacheFilePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save cache to {path}", _cacheFilePath);
}
}
private void RefreshCache(List<T> newItems)
{
_logger.LogDebug("Refreshing cache with {count} items", newItems.Count);
lock (_lock)
{
Items = newItems.Count > _maxCapacity ? newItems.Take(_maxCapacity).ToList() : newItems;
LastUpdated = DateTime.UtcNow;
_lastAccessed = DateTime.UtcNow;
SaveToFile();
_logger.LogDebug("Cache refreshed");
}
}
public void InvalidateCache()
{
_logger.LogDebug("Invalidating cache");
lock (_lock)
{
LastUpdated = DateTime.MinValue;
_lastAccessed = DateTime.MinValue;
Items.Clear();
SaveToFile();
_logger.LogDebug("Cache invalidated");
}
}
public bool IsCacheValid()
{
lock (_lock)
{
bool valid = !_useSlidingExpiration ? DateTime.UtcNow - LastUpdated <= _cacheDuration : DateTime.UtcNow - _lastAccessed <= _cacheDuration;
_logger.LogDebug("Cache valid: {valid}", valid);
return valid;
}
}
private IReadOnlyList<T> GetItems()
{
lock (_lock)
{
_lastAccessed = DateTime.UtcNow;
_logger.LogDebug("Returning {count} cached items", Items.Count);
return Items.AsReadOnly();
}
}
public Task RefreshCacheAsync(List<T> newItems) => Task.Run(() => RefreshCache(newItems));
public async Task<IReadOnlyList<T>> GetItemsAsync()
{
bool needsRefresh = false;
lock (_lock)
{
_lastAccessed = DateTime.UtcNow;
if (!IsCacheValid() && _refreshCallback != null)
{
needsRefresh = true;
}
}
if (needsRefresh)
{
await TryRefreshAsync();
}
lock (_lock)
{
_logger.LogDebug("Returning {count} cached items", Items.Count);
return Items.AsReadOnly();
}
}
public bool IsEmpty()
{
lock (_lock)
{
return Items.Count == 0;
}
}
public int GetCount()
{
lock (_lock)
{
return Items.Count;
}
}
public async Task TryRefreshAsync()
{
if (_refreshCallback != null)
{
try
{
var newItems = await _refreshCallback();
RefreshCache(newItems);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh cache via callback");
}
}
}
}
}