Revert persistent file-based cache implementation, restore IMemoryCache usage

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-08-25 06:24:50 +00:00
parent b5f052f4fb
commit 545d43f1f5
6 changed files with 14 additions and 228 deletions

3
.gitignore vendored
View file

@ -59,6 +59,3 @@ nunit-*.xml
# mac files # mac files
.DS_Store .DS_Store
# Cache directories
cache/

View file

@ -10,7 +10,7 @@ var transportConfig = builder.Configuration.GetSection("Transport").Get<Transpor
// Register services // Register services
builder.Services.AddSingleton(shifts); builder.Services.AddSingleton(shifts);
builder.Services.AddSingleton(transportConfig); builder.Services.AddSingleton(transportConfig);
builder.Services.AddSingleton<IPersistentCache, FilePersistentCache>(); builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<TransportService>(); builder.Services.AddHttpClient<TransportService>();
builder.Services.AddSingleton<ShiftService>(); builder.Services.AddSingleton<ShiftService>();
builder.Services.AddSingleton<IcsExportService>(); builder.Services.AddSingleton<IcsExportService>();

View file

@ -1,8 +1,9 @@
using Microsoft.Extensions.Caching.Memory;
using ShiftScheduler.Shared; using ShiftScheduler.Shared;
namespace ShiftScheduler.Services namespace ShiftScheduler.Services
{ {
public class CachedTransportService(ITransportApiService transportService, TransportConfiguration config, IPersistentCache cache) : ITransportService public class CachedTransportService(ITransportApiService transportService, TransportConfiguration config, IMemoryCache cache) : ITransportService
{ {
public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime) public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime)
{ {
@ -13,8 +14,7 @@ namespace ShiftScheduler.Services
var cacheKey = GenerateCacheKey(searchDate, searchTime); var cacheKey = GenerateCacheKey(searchDate, searchTime);
// Try to get from cache first // Try to get from cache first
var cachedConnection = await cache.GetAsync<TransportConnection>(cacheKey); if (cache.TryGetValue(cacheKey, out TransportConnection? cachedConnection) && cachedConnection != null)
if (cachedConnection != null)
{ {
return cachedConnection; return cachedConnection;
} }
@ -25,7 +25,11 @@ namespace ShiftScheduler.Services
// Cache the connection result if valid // Cache the connection result if valid
if (connection != null) if (connection != null)
{ {
await cache.SetAsync(cacheKey, connection, TimeSpan.FromDays(config.CacheDurationDays)); var cacheOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(config.CacheDurationDays)
};
cache.Set(cacheKey, connection, cacheOptions);
} }
return connection; return connection;

View file

@ -1,179 +0,0 @@
using System.Text.Json;
namespace ShiftScheduler.Services
{
public class FilePersistentCache : IPersistentCache
{
private readonly string _cacheDirectory;
private readonly string _indexFilePath;
private readonly object _lockObject = new object();
public FilePersistentCache(string cacheDirectory = "cache")
{
_cacheDirectory = Path.Combine(Directory.GetCurrentDirectory(), cacheDirectory);
_indexFilePath = Path.Combine(_cacheDirectory, "cache_index.json");
EnsureCacheDirectoryExists();
}
public async Task<T?> GetAsync<T>(string key) where T : class
{
try
{
var cacheFilePath = GetCacheFilePath(key);
if (!File.Exists(cacheFilePath))
return null;
var cacheEntryJson = await File.ReadAllTextAsync(cacheFilePath);
var cacheEntry = JsonSerializer.Deserialize<CacheEntry>(cacheEntryJson);
if (cacheEntry == null || cacheEntry.ExpiresAt < DateTime.UtcNow)
{
// Cache entry expired, remove it
await RemoveAsync(key);
return null;
}
return JsonSerializer.Deserialize<T>(cacheEntry.Value);
}
catch
{
// If there's any error reading the cache, return null
return null;
}
}
public async Task SetAsync<T>(string key, T value, TimeSpan expiration) where T : class
{
try
{
var cacheEntry = new CacheEntry
{
Key = key,
Value = JsonSerializer.Serialize(value),
ExpiresAt = DateTime.UtcNow.Add(expiration),
CreatedAt = DateTime.UtcNow
};
var cacheFilePath = GetCacheFilePath(key);
var cacheEntryJson = JsonSerializer.Serialize(cacheEntry);
lock (_lockObject)
{
EnsureCacheDirectoryExists();
File.WriteAllText(cacheFilePath, cacheEntryJson);
}
await UpdateIndexAsync(key, cacheEntry.ExpiresAt);
}
catch
{
// Fail silently if cache write fails
}
}
public async Task RemoveAsync(string key)
{
try
{
var cacheFilePath = GetCacheFilePath(key);
if (File.Exists(cacheFilePath))
{
File.Delete(cacheFilePath);
}
await RemoveFromIndexAsync(key);
}
catch
{
// Fail silently if removal fails
}
}
public async Task ClearExpiredAsync()
{
try
{
var index = await LoadIndexAsync();
var expiredKeys = index.Where(kvp => kvp.Value < DateTime.UtcNow).Select(kvp => kvp.Key).ToList();
foreach (var key in expiredKeys)
{
await RemoveAsync(key);
}
}
catch
{
// Fail silently if cleanup fails
}
}
private string GetCacheFilePath(string key)
{
var safeKey = key.Replace(':', '_').Replace('/', '_').Replace('\\', '_');
return Path.Combine(_cacheDirectory, $"{safeKey}.json");
}
private void EnsureCacheDirectoryExists()
{
if (!Directory.Exists(_cacheDirectory))
{
Directory.CreateDirectory(_cacheDirectory);
}
}
private async Task UpdateIndexAsync(string key, DateTime expiresAt)
{
try
{
var index = await LoadIndexAsync();
index[key] = expiresAt;
var indexJson = JsonSerializer.Serialize(index);
await File.WriteAllTextAsync(_indexFilePath, indexJson);
}
catch
{
// Fail silently if index update fails
}
}
private async Task RemoveFromIndexAsync(string key)
{
try
{
var index = await LoadIndexAsync();
index.Remove(key);
var indexJson = JsonSerializer.Serialize(index);
await File.WriteAllTextAsync(_indexFilePath, indexJson);
}
catch
{
// Fail silently if index update fails
}
}
private async Task<Dictionary<string, DateTime>> LoadIndexAsync()
{
try
{
if (!File.Exists(_indexFilePath))
return new Dictionary<string, DateTime>();
var indexJson = await File.ReadAllTextAsync(_indexFilePath);
return JsonSerializer.Deserialize<Dictionary<string, DateTime>>(indexJson) ?? new Dictionary<string, DateTime>();
}
catch
{
return new Dictionary<string, DateTime>();
}
}
private class CacheEntry
{
public string Key { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
}
}
}

View file

@ -1,10 +0,0 @@
namespace ShiftScheduler.Services
{
public interface IPersistentCache
{
Task<T?> GetAsync<T>(string key) where T : class;
Task SetAsync<T>(string key, T value, TimeSpan expiration) where T : class;
Task RemoveAsync(string key);
Task ClearExpiredAsync();
}
}

View file

@ -1,3 +1,4 @@
using Microsoft.Extensions.Caching.Memory;
using Moq; using Moq;
using ShiftScheduler.Services; using ShiftScheduler.Services;
using ShiftScheduler.Shared; using ShiftScheduler.Shared;
@ -8,14 +9,14 @@ namespace ShiftScheduler.Services.Tests;
public class CachedTransportServiceTests public class CachedTransportServiceTests
{ {
private readonly Mock<ITransportApiService> _transportServiceMock; private readonly Mock<ITransportApiService> _transportServiceMock;
private readonly Mock<IPersistentCache> _cacheMock; private readonly IMemoryCache _memoryCache;
private readonly CachedTransportService _cachedTransportService; private readonly CachedTransportService _cachedTransportService;
private readonly TransportConfiguration _config; private readonly TransportConfiguration _config;
public CachedTransportServiceTests() public CachedTransportServiceTests()
{ {
_transportServiceMock = new Mock<ITransportApiService>(); _transportServiceMock = new Mock<ITransportApiService>();
_cacheMock = new Mock<IPersistentCache>(); _memoryCache = new MemoryCache(new MemoryCacheOptions());
_config = new TransportConfiguration _config = new TransportConfiguration
{ {
@ -29,7 +30,7 @@ public class CachedTransportServiceTests
CacheDurationDays = 1 CacheDurationDays = 1
}; };
_cachedTransportService = new CachedTransportService(_transportServiceMock.Object, _config, _cacheMock.Object); _cachedTransportService = new CachedTransportService(_transportServiceMock.Object, _config, _memoryCache);
} }
[Fact] [Fact]
@ -45,10 +46,6 @@ public class CachedTransportServiceTests
Platform = "5" Platform = "5"
}; };
_cacheMock
.Setup(x => x.GetAsync<TransportConnection>(It.IsAny<string>()))
.ReturnsAsync((TransportConnection?)null); // First call returns null (not cached)
_transportServiceMock _transportServiceMock
.Setup(x => x.GetConnectionAsync(shiftStartTime)) .Setup(x => x.GetConnectionAsync(shiftStartTime))
.ReturnsAsync(connection); .ReturnsAsync(connection);
@ -56,11 +53,6 @@ public class CachedTransportServiceTests
// Act - First call should hit the transport service // Act - First call should hit the transport service
var result1 = await _cachedTransportService.GetConnectionAsync(shiftStartTime); var result1 = await _cachedTransportService.GetConnectionAsync(shiftStartTime);
// Setup cache to return the connection for second call
_cacheMock
.Setup(x => x.GetAsync<TransportConnection>(It.IsAny<string>()))
.ReturnsAsync(connection);
// Act - Second call should use cache // Act - Second call should use cache
var result2 = await _cachedTransportService.GetConnectionAsync(shiftStartTime); var result2 = await _cachedTransportService.GetConnectionAsync(shiftStartTime);
@ -70,12 +62,8 @@ public class CachedTransportServiceTests
result1.ArrivalTime.ShouldBe(result2.ArrivalTime); result1.ArrivalTime.ShouldBe(result2.ArrivalTime);
result1.DepartureTime.ShouldBe(result2.DepartureTime); result1.DepartureTime.ShouldBe(result2.DepartureTime);
// Verify that transport service was called only once for the first call // Verify that transport service was called only once
_transportServiceMock.Verify(x => x.GetConnectionAsync(shiftStartTime), Times.Once); _transportServiceMock.Verify(x => x.GetConnectionAsync(shiftStartTime), Times.Once);
// Verify that cache was accessed and set was called
_cacheMock.Verify(x => x.GetAsync<TransportConnection>(It.IsAny<string>()), Times.AtLeastOnce);
_cacheMock.Verify(x => x.SetAsync(It.IsAny<string>(), It.IsAny<TransportConnection>(), It.IsAny<TimeSpan>()), Times.Once);
} }
[Fact] [Fact]
@ -84,10 +72,6 @@ public class CachedTransportServiceTests
// Arrange // Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
_cacheMock
.Setup(x => x.GetAsync<TransportConnection>(It.IsAny<string>()))
.ReturnsAsync((TransportConnection?)null);
_transportServiceMock _transportServiceMock
.Setup(x => x.GetConnectionAsync(shiftStartTime)) .Setup(x => x.GetConnectionAsync(shiftStartTime))
.ReturnsAsync((TransportConnection?)null); .ReturnsAsync((TransportConnection?)null);
@ -104,9 +88,6 @@ public class CachedTransportServiceTests
// Verify that transport service was called twice (no caching for null) // Verify that transport service was called twice (no caching for null)
_transportServiceMock.Verify(x => x.GetConnectionAsync(shiftStartTime), Times.Exactly(2)); _transportServiceMock.Verify(x => x.GetConnectionAsync(shiftStartTime), Times.Exactly(2));
// Verify that cache set was never called for null values
_cacheMock.Verify(x => x.SetAsync(It.IsAny<string>(), It.IsAny<TransportConnection>(), It.IsAny<TimeSpan>()), Times.Never);
} }
[Fact] [Fact]
@ -119,10 +100,6 @@ public class CachedTransportServiceTests
var connection1 = new TransportConnection { ArrivalTime = "2023-12-15T07:30:00" }; var connection1 = new TransportConnection { ArrivalTime = "2023-12-15T07:30:00" };
var connection2 = new TransportConnection { ArrivalTime = "2023-12-16T07:30:00" }; var connection2 = new TransportConnection { ArrivalTime = "2023-12-16T07:30:00" };
_cacheMock
.Setup(x => x.GetAsync<TransportConnection>(It.IsAny<string>()))
.ReturnsAsync((TransportConnection?)null); // Cache misses for both calls
_transportServiceMock _transportServiceMock
.Setup(x => x.GetConnectionAsync(shiftStartTime1)) .Setup(x => x.GetConnectionAsync(shiftStartTime1))
.ReturnsAsync(connection1); .ReturnsAsync(connection1);
@ -143,8 +120,5 @@ public class CachedTransportServiceTests
// Verify that transport service was called twice (different cache keys) // Verify that transport service was called twice (different cache keys)
_transportServiceMock.Verify(x => x.GetConnectionAsync(It.IsAny<DateTime>()), Times.Exactly(2)); _transportServiceMock.Verify(x => x.GetConnectionAsync(It.IsAny<DateTime>()), Times.Exactly(2));
// Verify that cache set was called twice (for different cache keys)
_cacheMock.Verify(x => x.SetAsync(It.IsAny<string>(), It.IsAny<TransportConnection>(), It.IsAny<TimeSpan>()), Times.Exactly(2));
} }
} }