209 lines
8.5 KiB
C#
209 lines
8.5 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|