diff --git a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs index e16e2e86f..66283e3df 100644 --- a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs @@ -12,7 +12,14 @@ public static class ApplicationServiceRegistration { public static IServiceCollection AddApplicationServices(this IServiceCollection services) { - services.AddScoped(); + services.AddScoped(sp => + new BoardService( + sp.GetRequiredService(), + sp.GetService(), + sp.GetService(), + sp.GetService(), + sp.GetService(), + sp.GetService())); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/Taskdeck.Application/Interfaces/ICacheService.cs b/backend/src/Taskdeck.Application/Interfaces/ICacheService.cs new file mode 100644 index 000000000..68482b20e --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/ICacheService.cs @@ -0,0 +1,45 @@ +namespace Taskdeck.Application.Interfaces; + +/// +/// Generic cache abstraction for cache-aside pattern. +/// Implementations must degrade safely: cache failures never throw exceptions +/// to callers and do not affect data correctness. +/// +public interface ICacheService +{ + /// + /// Attempts to retrieve a cached value. Returns null on miss or error. + /// + /// The cached value type (must be JSON-serializable). + /// The cache key (will be prefixed automatically). + /// Cancellation token. + /// The cached value, or null if not found or on error. + Task GetAsync(string key, CancellationToken cancellationToken = default) where T : class; + + /// + /// Stores a value in the cache with the specified TTL. + /// Silently swallows errors — caller is never affected by cache write failures. + /// + /// The value type (must be JSON-serializable). + /// The cache key (will be prefixed automatically). + /// The value to cache. + /// Time-to-live for the cached entry. + /// Cancellation token. + Task SetAsync(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default) where T : class; + + /// + /// Removes a cached entry. Silently swallows errors. + /// + /// The cache key to invalidate. + /// Cancellation token. + Task RemoveAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Removes all cached entries matching the specified prefix pattern. + /// Used for bulk invalidation (e.g., all board list caches for a user). + /// Silently swallows errors. + /// + /// The key prefix to match for removal. + /// Cancellation token. + Task RemoveByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Services/BoardService.cs b/backend/src/Taskdeck.Application/Services/BoardService.cs index c7ba000eb..95ff6f2e9 100644 --- a/backend/src/Taskdeck.Application/Services/BoardService.cs +++ b/backend/src/Taskdeck.Application/Services/BoardService.cs @@ -13,6 +13,8 @@ public class BoardService private readonly IAuthorizationService? _authorizationService; private readonly IBoardRealtimeNotifier _realtimeNotifier; private readonly IHistoryService? _historyService; + private readonly ICacheService? _cacheService; + private readonly CacheSettings? _cacheSettings; public BoardService(IUnitOfWork unitOfWork) : this(unitOfWork, authorizationService: null, realtimeNotifier: null, historyService: null) @@ -23,12 +25,16 @@ public BoardService( IUnitOfWork unitOfWork, IAuthorizationService? authorizationService, IBoardRealtimeNotifier? realtimeNotifier = null, - IHistoryService? historyService = null) + IHistoryService? historyService = null, + ICacheService? cacheService = null, + CacheSettings? cacheSettings = null) { _unitOfWork = unitOfWork; _authorizationService = authorizationService; _realtimeNotifier = realtimeNotifier ?? NoOpBoardRealtimeNotifier.Instance; _historyService = historyService; + _cacheService = cacheService; + _cacheSettings = cacheSettings ?? new CacheSettings(); } private async Task SafeLogAsync(string entityType, Guid entityId, AuditAction action, Guid? userId = null, string? changes = null) @@ -67,6 +73,10 @@ public async Task> UpdateBoardAsync(Guid id, UpdateBoardDto dto public async Task> GetBoardDetailAsync(Guid id, CancellationToken cancellationToken = default) { + // NOTE: Board detail is intentionally NOT cached because BoardDetailDto includes + // columns with card counts. ColumnService and CardService mutate this data without + // cache awareness, so caching here would serve stale column/card information. + // Board *list* caching is safe because BoardDto excludes columns and card counts. var board = await _unitOfWork.Boards.GetByIdWithDetailsAsync(id, cancellationToken); if (board == null) return Result.Failure(ErrorCodes.NotFound, $"Board with ID {id} not found"); @@ -88,6 +98,13 @@ public async Task> GetBoardDetailAsync(Guid id, Guid acti return await GetBoardDetailAsync(id, cancellationToken); } + /// + /// Lists boards without user-scoped authorization. Not cached because this overload + /// has no user identity to scope the cache key, and caching an unscoped result could + /// serve stale data when user-specific caches are invalidated independently. + /// The user-scoped + /// overload IS cached. + /// public async Task>> ListBoardsAsync(string? searchText = null, bool includeArchived = false, CancellationToken cancellationToken = default) { var boards = await _unitOfWork.Boards.SearchAsync(searchText, includeArchived, cancellationToken); @@ -99,12 +116,33 @@ public async Task>> ListBoardsAsync(Guid actingUser if (actingUserId == Guid.Empty) return Result.Failure>(ErrorCodes.ValidationError, "Acting user ID cannot be empty"); + // Only cache un-filtered, non-archived list (the most common request) + var canCache = _cacheService is not null && string.IsNullOrEmpty(searchText) && !includeArchived; + + if (canCache) + { + var cacheKey = CacheKeys.BoardListForUser(actingUserId); + var cached = await _cacheService!.GetAsync>(cacheKey, cancellationToken); + if (cached is not null) + { + return Result.Success>(cached); + } + } + var candidateBoardIds = (await _unitOfWork.Boards.SearchIdsAsync(searchText, includeArchived, cancellationToken)).ToList(); if (_authorizationService is null) { var boards = await _unitOfWork.Boards.GetByIdsAsync(candidateBoardIds, cancellationToken); - return Result.Success(boards.Select(MapToDto)); + var dtos = boards.Select(MapToDto).ToList(); + + if (canCache) + { + var ttl = TimeSpan.FromSeconds(_cacheSettings!.BoardListTtlSeconds); + await _cacheService!.SetAsync(CacheKeys.BoardListForUser(actingUserId), dtos, ttl, cancellationToken); + } + + return Result.Success>(dtos); } var visibleBoardIdsResult = await _authorizationService.GetReadableBoardIdsAsync( @@ -117,8 +155,15 @@ public async Task>> ListBoardsAsync(Guid actingUser var visibleBoardIds = visibleBoardIdsResult.Value.ToList(); var visibleBoards = await _unitOfWork.Boards.GetByIdsAsync(visibleBoardIds, cancellationToken); + var result = visibleBoards.Select(MapToDto).ToList(); - return Result.Success>(visibleBoards.Select(MapToDto)); + if (canCache) + { + var ttl = TimeSpan.FromSeconds(_cacheSettings!.BoardListTtlSeconds); + await _cacheService!.SetAsync(CacheKeys.BoardListForUser(actingUserId), result, ttl, cancellationToken); + } + + return Result.Success>(result); } public async Task DeleteBoardAsync(Guid id, CancellationToken cancellationToken = default) @@ -152,6 +197,10 @@ await _realtimeNotifier.NotifyBoardMutationAsync( cancellationToken); await SafeLogAsync("board", board.Id, AuditAction.Created, ownerId, $"name={board.Name}"); + // Invalidate board list cache for the owner + if (ownerId.HasValue) + await InvalidateBoardListCacheAsync(ownerId.Value, cancellationToken); + return Result.Success(MapToDto(board)); } catch (DomainException ex) @@ -197,6 +246,11 @@ await _realtimeNotifier.NotifyBoardMutationAsync( await SafeLogAsync("board", board.Id, AuditAction.Unarchived, changes: changeSummary); else await SafeLogAsync("board", board.Id, AuditAction.Updated, changes: changeSummary); + + // Invalidate board list cache for the owner + if (board.OwnerId.HasValue) + await InvalidateBoardListCacheAsync(board.OwnerId.Value, cancellationToken); + return Result.Success(MapToDto(board)); } catch (DomainException ex) @@ -238,6 +292,11 @@ await _realtimeNotifier.NotifyBoardMutationAsync( new BoardRealtimeEvent(board.Id, "board", "archived", board.Id, DateTimeOffset.UtcNow), cancellationToken); await SafeLogAsync("board", board.Id, AuditAction.Archived, changes: $"name={board.Name}"); + + // Invalidate board list cache for the owner + if (board.OwnerId.HasValue) + await InvalidateBoardListCacheAsync(board.OwnerId.Value, cancellationToken); + return Result.Success(); } @@ -274,6 +333,12 @@ private static BoardDto MapToDto(Board board) ); } + private async Task InvalidateBoardListCacheAsync(Guid userId, CancellationToken cancellationToken) + { + if (_cacheService is null) return; + await _cacheService.RemoveAsync(CacheKeys.BoardListForUser(userId), cancellationToken); + } + private BoardDetailDto MapToDetailDto(Board board) { var columns = board.Columns diff --git a/backend/src/Taskdeck.Application/Services/CacheKeys.cs b/backend/src/Taskdeck.Application/Services/CacheKeys.cs new file mode 100644 index 000000000..18d99b96a --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/CacheKeys.cs @@ -0,0 +1,19 @@ +namespace Taskdeck.Application.Services; + +/// +/// Centralized cache key definitions for the cache-aside pattern. +/// Keys are structured as "resource:scope:id" and prefixed by the +/// cache service with the global key prefix (e.g., "td:"). +/// +public static class CacheKeys +{ + // NOTE: BoardDetail is intentionally NOT cached. BoardDetailDto includes columns + // with card counts, and ColumnService/CardService mutate that data without cache + // awareness. Caching board detail would serve stale column/card information. + + /// + /// Cache key for a user's board list (default, non-filtered, non-archived). + /// Format: boards:user:{userId} + /// + public static string BoardListForUser(Guid userId) => $"boards:user:{userId}"; +} diff --git a/backend/src/Taskdeck.Application/Services/CacheSettings.cs b/backend/src/Taskdeck.Application/Services/CacheSettings.cs new file mode 100644 index 000000000..09c70022a --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/CacheSettings.cs @@ -0,0 +1,29 @@ +namespace Taskdeck.Application.Services; + +/// +/// Configuration for the distributed caching layer. +/// Bound from appsettings.json "Cache" section. +/// +public sealed class CacheSettings +{ + /// + /// Cache provider: "Redis", "InMemory", or "None". + /// Defaults to "InMemory" for local-first usage. + /// + public string Provider { get; set; } = "InMemory"; + + /// + /// Redis connection string. Only used when Provider is "Redis". + /// + public string? RedisConnectionString { get; set; } + + /// + /// Global key prefix to avoid collisions in shared Redis instances. + /// + public string KeyPrefix { get; set; } = "td"; + + /// + /// Default TTL in seconds for board list cache entries. + /// + public int BoardListTtlSeconds { get; set; } = 60; +} diff --git a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs index 6e10e43f1..42e878fca 100644 --- a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs +++ b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs @@ -1,10 +1,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Taskdeck.Application.Interfaces; using Taskdeck.Application.Services; using Taskdeck.Infrastructure.Persistence; using Taskdeck.Infrastructure.Repositories; +using Taskdeck.Infrastructure.Services; namespace Taskdeck.Infrastructure; @@ -49,6 +51,62 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); + // Cache service registration + services.AddCacheService(configuration); + return services; } + + private static void AddCacheService(this IServiceCollection services, IConfiguration configuration) + { + var cacheSettings = configuration.GetSection("Cache").Get() ?? new CacheSettings(); + + switch (cacheSettings.Provider.ToLowerInvariant()) + { + case "redis": + if (string.IsNullOrWhiteSpace(cacheSettings.RedisConnectionString)) + { + // Fallback to in-memory if Redis is configured but no connection string + services.AddSingleton(sp => + new InMemoryCacheService( + sp.GetRequiredService>(), + cacheSettings.KeyPrefix)); + } + else + { + services.AddSingleton(sp => + new RedisCacheService( + cacheSettings.RedisConnectionString, + sp.GetRequiredService>(), + cacheSettings.KeyPrefix)); + } + break; + + case "none": + services.AddSingleton(NoOpCacheService.Instance); + break; + + case "inmemory": + services.AddSingleton(sp => + new InMemoryCacheService( + sp.GetRequiredService>(), + cacheSettings.KeyPrefix)); + break; + + default: + // Log a warning so operators notice configuration typos (e.g., "Rediss" or "inmem") + // instead of silently falling back to InMemory. + services.AddSingleton(sp => + { + var logger = sp.GetRequiredService>(); + logger.LogWarning( + "Unknown cache provider '{Provider}', falling back to InMemory. Valid values: Redis, InMemory, None", + cacheSettings.Provider); + return new InMemoryCacheService(logger, cacheSettings.KeyPrefix); + }); + break; + } + + services.AddSingleton(cacheSettings); + } } diff --git a/backend/src/Taskdeck.Infrastructure/Services/InMemoryCacheService.cs b/backend/src/Taskdeck.Infrastructure/Services/InMemoryCacheService.cs new file mode 100644 index 000000000..fcb29c6eb --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Services/InMemoryCacheService.cs @@ -0,0 +1,237 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Taskdeck.Application.Interfaces; + +namespace Taskdeck.Infrastructure.Services; + +/// +/// In-memory cache implementation for local dev and test environments. +/// Thread-safe via ConcurrentDictionary. Entries expire lazily on access. +/// Periodic sweep prevents unbounded memory growth. +/// +public sealed class InMemoryCacheService : ICacheService, IDisposable +{ + private readonly ConcurrentDictionary _cache = new(); + private readonly ILogger _logger; + private readonly string _keyPrefix; + private readonly Timer _sweepTimer; + private readonly object _evictionLock = new(); + + /// + /// Maximum cache entries before forced eviction of expired entries. + /// + private const int MaxEntries = 10_000; + + public InMemoryCacheService(ILogger logger, string keyPrefix = "td") + { + _logger = logger; + _keyPrefix = keyPrefix; + + // Sweep expired entries every 60 seconds. + // Timer callback is wrapped in try/catch to prevent unhandled exceptions + // from terminating the timer permanently. + _sweepTimer = new Timer(_ => + { + try { SweepExpiredEntries(); } + catch (Exception ex) { _logger.LogWarning(ex, "Cache sweep timer callback error"); } + }, null, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); + } + + public Task GetAsync(string key, CancellationToken cancellationToken = default) where T : class + { + var fullKey = BuildKey(key); + + try + { + if (!_cache.TryGetValue(fullKey, out var entry)) + { + _logger.LogDebug("Cache miss for key {CacheKey}", fullKey); + LogCacheMetric("miss", key); + return Task.FromResult(null); + } + + if (entry.ExpiresAtUtc < DateTime.UtcNow) + { + _cache.TryRemove(fullKey, out _); + _logger.LogDebug("Cache expired for key {CacheKey}", fullKey); + LogCacheMetric("miss", key); + return Task.FromResult(null); + } + + var value = JsonSerializer.Deserialize(entry.SerializedValue); + _logger.LogDebug("Cache hit for key {CacheKey}", fullKey); + LogCacheMetric("hit", key); + return Task.FromResult(value); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache get error for key {CacheKey}", fullKey); + LogCacheMetric("error", key); + return Task.FromResult(null); + } + } + + public Task SetAsync(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default) where T : class + { + var fullKey = BuildKey(key); + + try + { + var serialized = JsonSerializer.Serialize(value); + var entry = new CacheEntry(serialized, DateTime.UtcNow.Add(ttl)); + + // Use AddOrUpdate with a guard: if adding would exceed MaxEntries and key doesn't exist, + // we need to make room first. Check under lock to prevent races. + if (_cache.ContainsKey(fullKey)) + { + // Updating existing key — no growth + _cache[fullKey] = entry; + } + else + { + // Adding new key — enforce hard cap + lock (_evictionLock) + { + // Re-check after acquiring lock in case another thread added + if (_cache.ContainsKey(fullKey)) + { + _cache[fullKey] = entry; + } + else + { + // Enforce hard cap before inserting + while (_cache.Count >= MaxEntries) + { + SweepExpiredEntries(); + if (_cache.Count >= MaxEntries) + { + // Evict 10% to create headroom + EvictOldestEntries(Math.Max(1, MaxEntries / 10)); + } + } + + _cache[fullKey] = entry; + } + } + } + + _logger.LogDebug("Cache set for key {CacheKey} with TTL {Ttl}s", fullKey, ttl.TotalSeconds); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache set error for key {CacheKey}", fullKey); + LogCacheMetric("error", key); + } + + return Task.CompletedTask; + } + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + var fullKey = BuildKey(key); + + try + { + _cache.TryRemove(fullKey, out _); + _logger.LogDebug("Cache removed key {CacheKey}", fullKey); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache remove error for key {CacheKey}", fullKey); + LogCacheMetric("error", key); + } + + return Task.CompletedTask; + } + + public Task RemoveByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default) + { + var fullPrefix = BuildKey(keyPrefix); + + try + { + var keysToRemove = _cache.Keys.Where(k => k.StartsWith(fullPrefix, StringComparison.Ordinal)).ToList(); + foreach (var k in keysToRemove) + { + _cache.TryRemove(k, out _); + } + + _logger.LogDebug("Cache removed {Count} keys with prefix {CacheKeyPrefix}", keysToRemove.Count, fullPrefix); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache remove by prefix error for {CacheKeyPrefix}", fullPrefix); + LogCacheMetric("error", keyPrefix); + } + + return Task.CompletedTask; + } + + public void Dispose() + { + _sweepTimer.Dispose(); + _cache.Clear(); + } + + /// + /// Returns the current number of entries in the cache (for diagnostics/testing). + /// + internal int Count => _cache.Count; + + private string BuildKey(string key) => $"{_keyPrefix}:{key}"; + + private void SweepExpiredEntries() + { + var now = DateTime.UtcNow; + var swept = 0; + foreach (var kvp in _cache) + { + if (kvp.Value.ExpiresAtUtc < now) + { + if (_cache.TryRemove(kvp.Key, out _)) + swept++; + } + } + + if (swept > 0) + { + _logger.LogDebug("Cache sweep removed {SweptCount} expired entries", swept); + } + } + + /// + /// Evicts the entries closest to expiry to make room when the cache is at capacity + /// and no expired entries remain to sweep. This is a simple eviction policy that + /// approximates LRU by removing entries with the shortest remaining TTL. + /// + private void EvictOldestEntries(int count) + { + var toEvict = _cache + .OrderBy(kvp => kvp.Value.ExpiresAtUtc) + .Take(count) + .Select(kvp => kvp.Key) + .ToList(); + + var evicted = 0; + foreach (var key in toEvict) + { + if (_cache.TryRemove(key, out _)) + evicted++; + } + + if (evicted > 0) + { + _logger.LogWarning("Cache at capacity ({MaxEntries}), evicted {EvictedCount} entries to create headroom", MaxEntries, evicted); + } + } + + private void LogCacheMetric(string outcome, string keyPrefix) + { + // Extract the resource type from the key for tagging (e.g., "boards" from "boards:user:...") + var resource = keyPrefix.Split(':').FirstOrDefault() ?? "unknown"; + _logger.LogDebug("CacheMetric outcome={Outcome} resource={Resource}", outcome, resource); + } + + private sealed record CacheEntry(string SerializedValue, DateTime ExpiresAtUtc); +} diff --git a/backend/src/Taskdeck.Infrastructure/Services/NoOpCacheService.cs b/backend/src/Taskdeck.Infrastructure/Services/NoOpCacheService.cs new file mode 100644 index 000000000..ac6b34b0a --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Services/NoOpCacheService.cs @@ -0,0 +1,24 @@ +using Taskdeck.Application.Interfaces; + +namespace Taskdeck.Infrastructure.Services; + +/// +/// No-op cache implementation used when caching is explicitly disabled via configuration. +/// All operations return immediately with no side effects. +/// +public sealed class NoOpCacheService : ICacheService +{ + public static readonly NoOpCacheService Instance = new(); + + public Task GetAsync(string key, CancellationToken cancellationToken = default) where T : class + => Task.FromResult(null); + + public Task SetAsync(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default) where T : class + => Task.CompletedTask; + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task RemoveByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default) + => Task.CompletedTask; +} diff --git a/backend/src/Taskdeck.Infrastructure/Services/RedisCacheService.cs b/backend/src/Taskdeck.Infrastructure/Services/RedisCacheService.cs new file mode 100644 index 000000000..01d79a6b2 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Services/RedisCacheService.cs @@ -0,0 +1,297 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; +using Taskdeck.Application.Interfaces; + +namespace Taskdeck.Infrastructure.Services; + +/// +/// Redis-backed cache implementation for production/multi-instance deployments. +/// All operations degrade safely on connection failure — no exceptions propagated to callers. +/// Uses StackExchange.Redis with reconnection support — transient Redis outages do not +/// permanently disable the cache. +/// +public sealed class RedisCacheService : ICacheService, IDisposable +{ + private readonly string _connectionString; + private readonly ILogger _logger; + private readonly string _keyPrefix; + private readonly object _connectionLock = new(); + private volatile ConnectionMultiplexer? _connection; + private volatile bool _disposed; + + /// + /// Minimum interval between reconnection attempts to avoid reconnection storms. + /// + private static readonly TimeSpan ReconnectMinInterval = TimeSpan.FromSeconds(15); + + /// + /// Maximum number of immediate retry attempts when establishing a connection. + /// + private const int MaxConnectionRetries = 3; + + /// + /// Base delay between connection retry attempts (doubles with each retry). + /// + private static readonly TimeSpan RetryBaseDelay = TimeSpan.FromMilliseconds(500); + + private DateTime _lastConnectAttemptUtc = DateTime.MinValue; + + public RedisCacheService(string connectionString, ILogger logger, string keyPrefix = "td") + { + _connectionString = connectionString; + _logger = logger; + _keyPrefix = keyPrefix; + } + + public async Task GetAsync(string key, CancellationToken cancellationToken = default) where T : class + { + var fullKey = BuildKey(key); + var db = GetDatabase(); + if (db is null) + { + LogCacheMetric("miss", key); // Connection down counts as miss + return null; + } + + try + { + var value = await db.StringGetAsync(fullKey); + if (value.IsNullOrEmpty) + { + _logger.LogDebug("Cache miss for key {CacheKey}", fullKey); + LogCacheMetric("miss", key); + return null; + } + + var result = JsonSerializer.Deserialize(value.ToString()); + _logger.LogDebug("Cache hit for key {CacheKey}", fullKey); + LogCacheMetric("hit", key); + return result; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache get error for key {CacheKey}", fullKey); + LogCacheMetric("error", key); + return null; + } + } + + public async Task SetAsync(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default) where T : class + { + var fullKey = BuildKey(key); + var db = GetDatabase(); + if (db is null) return; + + try + { + var serialized = JsonSerializer.Serialize(value); + await db.StringSetAsync(fullKey, serialized, ttl); + _logger.LogDebug("Cache set for key {CacheKey} with TTL {Ttl}s", fullKey, ttl.TotalSeconds); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache set error for key {CacheKey}", fullKey); + LogCacheMetric("error", key); + } + } + + public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + var fullKey = BuildKey(key); + var db = GetDatabase(); + if (db is null) return; + + try + { + await db.KeyDeleteAsync(fullKey); + _logger.LogDebug("Cache removed key {CacheKey}", fullKey); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache remove error for key {CacheKey}", fullKey); + LogCacheMetric("error", key); + } + } + + public async Task RemoveByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default) + { + var fullPrefix = BuildKey(keyPrefix); + var connection = GetConnection(); + if (connection is null) return; + + try + { + // Use SCAN to find keys by prefix — safe for production (non-blocking). + // Process keys in batches to avoid materializing all keys into memory at once. + const int batchSize = 100; + var endpoints = connection.GetEndPoints(); + var db = connection.GetDatabase(); + + foreach (var endpoint in endpoints) + { + var server = connection.GetServer(endpoint); + var keys = new List(batchSize); + var totalRemoved = 0; + + // Stream keys using IEnumerable — Keys() uses SCAN internally + foreach (var key in server.Keys(pattern: $"{fullPrefix}*")) + { + if (cancellationToken.IsCancellationRequested) + break; + + keys.Add(key); + if (keys.Count >= batchSize) + { + await db.KeyDeleteAsync(keys.ToArray()); + totalRemoved += keys.Count; + keys.Clear(); + } + } + + // Delete any remaining keys + if (keys.Count > 0) + { + await db.KeyDeleteAsync(keys.ToArray()); + totalRemoved += keys.Count; + } + + if (totalRemoved > 0) + { + _logger.LogDebug("Cache removed {Count} keys with prefix {CacheKeyPrefix}", totalRemoved, fullPrefix); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache remove by prefix error for {CacheKeyPrefix}", fullPrefix); + LogCacheMetric("error", keyPrefix); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + try + { + _connection?.Dispose(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Exception during Redis connection disposal"); + } + } + + private IDatabase? GetDatabase() + { + if (_disposed) return null; + + try + { + var connection = GetConnection(); + if (connection is null || !connection.IsConnected) + { + return null; + } + return connection.GetDatabase(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Redis connection unavailable — operating in degraded mode"); + return null; + } + } + + /// + /// Gets or establishes the Redis connection. Unlike the previous Lazy-based approach, + /// this retries connection on failure (with a backoff interval) so transient Redis + /// outages do not permanently disable caching. + /// + private ConnectionMultiplexer? GetConnection() + { + if (_disposed) return null; + + var conn = _connection; + if (conn is not null && conn.IsConnected) + return conn; + + // Throttle reconnection attempts to avoid storms + if (DateTime.UtcNow - _lastConnectAttemptUtc < ReconnectMinInterval) + return conn; // Return stale connection (or null) — don't retry yet + + lock (_connectionLock) + { + // Double-check after acquiring lock + if (_disposed) return null; + conn = _connection; + if (conn is not null && conn.IsConnected) + return conn; + + if (DateTime.UtcNow - _lastConnectAttemptUtc < ReconnectMinInterval) + return conn; + + _lastConnectAttemptUtc = DateTime.UtcNow; + + // Dispose old broken connection before attempting reconnect + var old = _connection; + Exception? lastException = null; + + // Retry connection with exponential backoff + for (var attempt = 1; attempt <= MaxConnectionRetries; attempt++) + { + try + { + var options = ConfigurationOptions.Parse(_connectionString); + options.AbortOnConnectFail = false; // Allow startup without Redis + options.ConnectTimeout = 3000; // 3 second connect timeout + options.SyncTimeout = 1000; // 1 second sync timeout + options.AsyncTimeout = 1000; // 1 second async timeout + _connection = ConnectionMultiplexer.Connect(options); + + if (_connection.IsConnected) + { + _logger.LogInformation("Redis cache connected successfully on attempt {Attempt}", attempt); + + // Dispose old connection after successful replacement + if (old is not null && !ReferenceEquals(old, _connection)) + { + try { old.Dispose(); } + catch (Exception) { /* best-effort cleanup */ } + } + + return _connection; + } + + // Connection object created but not connected — dispose and retry + _connection.Dispose(); + _connection = null; + } + catch (Exception ex) + { + lastException = ex; + _logger.LogDebug(ex, "Redis connection attempt {Attempt}/{MaxAttempts} failed", attempt, MaxConnectionRetries); + } + + // Wait before retry (exponential backoff: 500ms, 1s, 2s, ...) + if (attempt < MaxConnectionRetries) + { + var delay = TimeSpan.FromMilliseconds(RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1)); + Thread.Sleep(delay); + } + } + + _logger.LogWarning(lastException, "Redis cache connection failed after {MaxAttempts} attempts — operating in degraded (no-cache) mode", MaxConnectionRetries); + return null; + } + } + + private string BuildKey(string key) => $"{_keyPrefix}:{key}"; + + private void LogCacheMetric(string outcome, string keyPrefix) + { + var resource = keyPrefix.Split(':').FirstOrDefault() ?? "unknown"; + _logger.LogDebug("CacheMetric outcome={Outcome} resource={Resource}", outcome, resource); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Taskdeck.Infrastructure.csproj b/backend/src/Taskdeck.Infrastructure/Taskdeck.Infrastructure.csproj index 5f2a36d1e..604f7a6e9 100644 --- a/backend/src/Taskdeck.Infrastructure/Taskdeck.Infrastructure.csproj +++ b/backend/src/Taskdeck.Infrastructure/Taskdeck.Infrastructure.csproj @@ -28,6 +28,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/backend/tests/Taskdeck.Api.Tests/AuthenticationRegistrationTests.cs b/backend/tests/Taskdeck.Api.Tests/AuthenticationRegistrationTests.cs deleted file mode 100644 index 1afff91f8..000000000 --- a/backend/tests/Taskdeck.Api.Tests/AuthenticationRegistrationTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -using FluentAssertions; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authentication.OAuth; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Taskdeck.Api.Extensions; -using Taskdeck.Application.Services; -using Xunit; - -namespace Taskdeck.Api.Tests; - -public class AuthenticationRegistrationTests -{ - [Fact] - public async Task AddTaskdeckAuthentication_ConfiguresExternalSignInScheme_ForRemoteProviders() - { - var services = new ServiceCollection(); - var jwtSettings = new JwtSettings - { - SecretKey = "TaskdeckTestsOnlySecretKeyMustBeLongEnough123!", - Issuer = "TaskdeckTests", - Audience = "TaskdeckUsers", - ExpirationMinutes = 60 - }; - var gitHubSettings = new GitHubOAuthSettings - { - ClientId = "github-client", - ClientSecret = "github-secret" - }; - var oidcSettings = new OidcSettings - { - Providers = - [ - new OidcProviderConfig - { - Name = "entra", - DisplayName = "Microsoft Entra ID", - Authority = "https://login.microsoftonline.com/tenant/v2.0", - ClientId = "oidc-client", - ClientSecret = "oidc-secret" - } - ] - }; - - services.AddLogging(); - services.AddOptions(); - services.AddTaskdeckAuthentication(jwtSettings, gitHubSettings, oidcSettings); - - await using var serviceProvider = services.BuildServiceProvider(); - - var authenticationOptions = serviceProvider.GetRequiredService>().Value; - authenticationOptions.DefaultAuthenticateScheme.Should().Be(JwtBearerDefaults.AuthenticationScheme); - authenticationOptions.DefaultChallengeScheme.Should().Be(JwtBearerDefaults.AuthenticationScheme); - authenticationOptions.DefaultSignInScheme.Should().Be(AuthenticationRegistration.ExternalAuthenticationScheme); - - var schemeProvider = serviceProvider.GetRequiredService(); - (await schemeProvider.GetSchemeAsync(AuthenticationRegistration.ExternalAuthenticationScheme)).Should().NotBeNull(); - (await schemeProvider.GetSchemeAsync("GitHub")).Should().NotBeNull(); - (await schemeProvider.GetSchemeAsync("Oidc_entra")).Should().NotBeNull(); - - var gitHubOptions = serviceProvider.GetRequiredService>().Get("GitHub"); - gitHubOptions.SignInScheme.Should().Be(AuthenticationRegistration.ExternalAuthenticationScheme); - - var oidcOptions = serviceProvider.GetRequiredService>().Get("Oidc_entra"); - oidcOptions.SignInScheme.Should().Be(AuthenticationRegistration.ExternalAuthenticationScheme); - } -} diff --git a/backend/tests/Taskdeck.Api.Tests/InMemoryCacheServiceTests.cs b/backend/tests/Taskdeck.Api.Tests/InMemoryCacheServiceTests.cs new file mode 100644 index 000000000..e3eb7ae82 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/InMemoryCacheServiceTests.cs @@ -0,0 +1,173 @@ +using FluentAssertions; +using Taskdeck.Infrastructure.Services; +using Taskdeck.Tests.Support; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class InMemoryCacheServiceTests : IDisposable +{ + private readonly InMemoryLogger _logger; + private readonly InMemoryCacheService _cache; + + public InMemoryCacheServiceTests() + { + _logger = new InMemoryLogger(); + _cache = new InMemoryCacheService(_logger, "test"); + } + + public void Dispose() + { + _cache.Dispose(); + } + + [Fact] + public async Task GetAsync_ReturnsNull_OnCacheMiss() + { + var result = await _cache.GetAsync("nonexistent"); + result.Should().BeNull(); + } + + [Fact] + public async Task SetAsync_ThenGetAsync_ReturnsCachedValue() + { + var data = new TestData("hello", 42); + await _cache.SetAsync("key1", data, TimeSpan.FromMinutes(5)); + + var result = await _cache.GetAsync("key1"); + + result.Should().NotBeNull(); + result!.Name.Should().Be("hello"); + result.Value.Should().Be(42); + } + + [Fact] + public async Task GetAsync_ReturnsNull_AfterExpiry() + { + var data = new TestData("expiring", 1); + await _cache.SetAsync("expkey", data, TimeSpan.Zero); + + await Task.Delay(10); + + var result = await _cache.GetAsync("expkey"); + result.Should().BeNull(); + } + + [Fact] + public async Task RemoveAsync_RemovesCachedEntry() + { + var data = new TestData("removeme", 99); + await _cache.SetAsync("rmkey", data, TimeSpan.FromMinutes(5)); + + await _cache.RemoveAsync("rmkey"); + + var result = await _cache.GetAsync("rmkey"); + result.Should().BeNull(); + } + + [Fact] + public async Task RemoveByPrefixAsync_RemovesMatchingEntries() + { + await _cache.SetAsync("boards:user:a", new TestData("a", 1), TimeSpan.FromMinutes(5)); + await _cache.SetAsync("boards:user:b", new TestData("b", 2), TimeSpan.FromMinutes(5)); + await _cache.SetAsync("other:key", new TestData("c", 3), TimeSpan.FromMinutes(5)); + + await _cache.RemoveByPrefixAsync("boards:user:"); + + (await _cache.GetAsync("boards:user:a")).Should().BeNull(); + (await _cache.GetAsync("boards:user:b")).Should().BeNull(); + (await _cache.GetAsync("other:key")).Should().NotBeNull(); + } + + [Fact] + public async Task SetAsync_OverwritesExistingEntry() + { + await _cache.SetAsync("key", new TestData("old", 1), TimeSpan.FromMinutes(5)); + await _cache.SetAsync("key", new TestData("new", 2), TimeSpan.FromMinutes(5)); + + var result = await _cache.GetAsync("key"); + result.Should().NotBeNull(); + result!.Name.Should().Be("new"); + result.Value.Should().Be(2); + } + + [Fact] + public async Task RemoveAsync_IsIdempotent_DoesNotThrow() + { + await _cache.RemoveAsync("nonexistent"); + + await _cache.SetAsync("key", new TestData("x", 1), TimeSpan.FromMinutes(5)); + await _cache.RemoveAsync("key"); + await _cache.RemoveAsync("key"); + } + + [Fact] + public async Task RemoveByPrefixAsync_IsIdempotent_WhenNoMatchingKeys() + { + await _cache.RemoveByPrefixAsync("nonexistent:prefix:"); + } + + [Fact] + public async Task GetAsync_LogsHitAndMissMetrics() + { + await _cache.SetAsync("metrickey", new TestData("test", 1), TimeSpan.FromMinutes(5)); + + await _cache.GetAsync("missing"); + await _cache.GetAsync("metrickey"); + + var debugLogs = _logger.Entries + .Where(e => e.Level == Microsoft.Extensions.Logging.LogLevel.Debug) + .Select(e => e.Message) + .ToList(); + + debugLogs.Should().Contain(m => m.Contains("outcome=miss")); + debugLogs.Should().Contain(m => m.Contains("outcome=hit")); + } + + [Fact] + public async Task KeysAreIsolatedByPrefix() + { + using var cache2 = new InMemoryCacheService(_logger, "other"); + + await _cache.SetAsync("shared", new TestData("from-test", 1), TimeSpan.FromMinutes(5)); + await cache2.SetAsync("shared", new TestData("from-other", 2), TimeSpan.FromMinutes(5)); + + var result1 = await _cache.GetAsync("shared"); + var result2 = await cache2.GetAsync("shared"); + + result1!.Name.Should().Be("from-test"); + result2!.Name.Should().Be("from-other"); + } + + [Fact] + public async Task Count_ReflectsActiveEntries() + { + _cache.Count.Should().Be(0); + + await _cache.SetAsync("a", new TestData("a", 1), TimeSpan.FromMinutes(5)); + await _cache.SetAsync("b", new TestData("b", 2), TimeSpan.FromMinutes(5)); + + _cache.Count.Should().Be(2); + + await _cache.RemoveAsync("a"); + + _cache.Count.Should().Be(1); + } + + [Fact] + public async Task ConcurrentAccess_DoesNotThrow() + { + var tasks = Enumerable.Range(0, 100).Select(async i => + { + var key = $"concurrent:{i}"; + await _cache.SetAsync(key, new TestData($"data-{i}", i), TimeSpan.FromMinutes(5)); + var result = await _cache.GetAsync(key); + result.Should().NotBeNull(); + await _cache.RemoveAsync(key); + }); + + await Task.WhenAll(tasks); + } + + private sealed record TestData(string Name, int Value); +} diff --git a/backend/tests/Taskdeck.Api.Tests/NoOpCacheServiceTests.cs b/backend/tests/Taskdeck.Api.Tests/NoOpCacheServiceTests.cs new file mode 100644 index 000000000..1a44ae10a --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/NoOpCacheServiceTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using Taskdeck.Infrastructure.Services; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class NoOpCacheServiceTests +{ + private readonly NoOpCacheService _cache = NoOpCacheService.Instance; + + [Fact] + public async Task GetAsync_AlwaysReturnsNull() + { + var result = await _cache.GetAsync("anykey"); + result.Should().BeNull(); + } + + [Fact] + public async Task SetAsync_DoesNotThrow() + { + await _cache.SetAsync("key", "value", TimeSpan.FromMinutes(5)); + } + + [Fact] + public async Task RemoveAsync_DoesNotThrow() + { + await _cache.RemoveAsync("key"); + } + + [Fact] + public async Task RemoveByPrefixAsync_DoesNotThrow() + { + await _cache.RemoveByPrefixAsync("prefix:"); + } + + [Fact] + public void Instance_IsSingleton() + { + var instance1 = NoOpCacheService.Instance; + var instance2 = NoOpCacheService.Instance; + instance1.Should().BeSameAs(instance2); + } +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/BoardServiceCacheTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/BoardServiceCacheTests.cs new file mode 100644 index 000000000..9e8545e5c --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/BoardServiceCacheTests.cs @@ -0,0 +1,321 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Entities; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class BoardServiceCacheTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _boardRepoMock; + private readonly Mock _cacheMock; + private readonly CacheSettings _cacheSettings; + private readonly Guid _userId = Guid.NewGuid(); + + public BoardServiceCacheTests() + { + _unitOfWorkMock = new Mock(); + _boardRepoMock = new Mock(); + _cacheMock = new Mock(); + _cacheSettings = new CacheSettings + { + BoardListTtlSeconds = 60 + }; + + _unitOfWorkMock.Setup(u => u.Boards).Returns(_boardRepoMock.Object); + } + + private BoardService CreateService(ICacheService? cache = null) + { + return new BoardService( + _unitOfWorkMock.Object, + authorizationService: null, + realtimeNotifier: null, + historyService: null, + cacheService: cache ?? _cacheMock.Object, + cacheSettings: _cacheSettings); + } + + #region GetBoardDetailAsync — intentionally NOT cached + + [Fact] + public async Task GetBoardDetail_AlwaysQueriesDatabase_NeverCaches() + { + // Board detail is intentionally not cached because BoardDetailDto includes + // columns with card counts that can be mutated by ColumnService/CardService. + var boardId = Guid.NewGuid(); + var board = new Board("DB Board", "desc", _userId); + + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardId, It.IsAny())) + .ReturnsAsync(board); + + var service = CreateService(); + var result = await service.GetBoardDetailAsync(boardId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Name.Should().Be("DB Board"); + + // Database should always be queried — no cache lookup + _boardRepoMock.Verify(r => r.GetByIdWithDetailsAsync( + boardId, It.IsAny()), Times.Once); + + // Cache should never be read or written for board detail + _cacheMock.Verify(c => c.GetAsync( + It.IsAny(), It.IsAny()), Times.Never); + _cacheMock.Verify(c => c.SetAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetBoardDetail_WorksWithoutCache() + { + var boardId = Guid.NewGuid(); + var board = new Board("No Cache Board", "desc", _userId); + + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardId, It.IsAny())) + .ReturnsAsync(board); + + var service = new BoardService(_unitOfWorkMock.Object); + var result = await service.GetBoardDetailAsync(boardId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Name.Should().Be("No Cache Board"); + } + + [Fact] + public async Task GetBoardDetail_ReturnsNotFound_WithCacheServicePresent() + { + var boardId = Guid.NewGuid(); + + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardId, It.IsAny())) + .ReturnsAsync((Board?)null); + + var service = CreateService(); + var result = await service.GetBoardDetailAsync(boardId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be("NotFound"); + } + + #endregion + + #region ListBoardsAsync Cache-Aside + + [Fact] + public async Task ListBoards_ReturnsCachedValue_OnCacheHit() + { + var cachedList = new List + { + new(Guid.NewGuid(), "Board1", null, false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow), + new(Guid.NewGuid(), "Board2", null, false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow) + }; + + _cacheMock.Setup(c => c.GetAsync>( + CacheKeys.BoardListForUser(_userId), It.IsAny())) + .ReturnsAsync(cachedList); + + var service = CreateService(); + var result = await service.ListBoardsAsync(_userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(2); + + // Database should NOT be queried + _boardRepoMock.Verify(r => r.SearchIdsAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ListBoards_DoesNotCache_WhenSearchTextProvided() + { + _boardRepoMock.Setup(r => r.SearchIdsAsync("filter", false, It.IsAny())) + .ReturnsAsync(new List()); + _boardRepoMock.Setup(r => r.GetByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List()); + + var service = CreateService(); + await service.ListBoardsAsync(_userId, searchText: "filter"); + + // Should not attempt cache get or set when search text is provided + _cacheMock.Verify(c => c.GetAsync>( + It.IsAny(), It.IsAny()), Times.Never); + _cacheMock.Verify(c => c.SetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task ListBoards_DoesNotCache_WhenIncludeArchivedIsTrue() + { + _boardRepoMock.Setup(r => r.SearchIdsAsync(null, true, It.IsAny())) + .ReturnsAsync(new List()); + _boardRepoMock.Setup(r => r.GetByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List()); + + var service = CreateService(); + await service.ListBoardsAsync(_userId, includeArchived: true); + + _cacheMock.Verify(c => c.GetAsync>( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ListBoards_PopulatesCache_OnMiss() + { + var boardId = Guid.NewGuid(); + + _cacheMock.Setup(c => c.GetAsync>( + It.IsAny(), It.IsAny())) + .ReturnsAsync((List?)null); + + _boardRepoMock.Setup(r => r.SearchIdsAsync(null, false, It.IsAny())) + .ReturnsAsync(new List { boardId }); + _boardRepoMock.Setup(r => r.GetByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List { new("Test", null, _userId) }); + + var service = CreateService(); + var result = await service.ListBoardsAsync(_userId); + + result.IsSuccess.Should().BeTrue(); + + _cacheMock.Verify(c => c.SetAsync( + CacheKeys.BoardListForUser(_userId), + It.IsAny>(), + TimeSpan.FromSeconds(60), + It.IsAny()), Times.Once); + } + + #endregion + + #region Cache Invalidation + + [Fact] + public async Task CreateBoard_InvalidatesBoardListCache() + { + var dto = new CreateBoardDto("New Board", "desc"); + + _boardRepoMock.Setup(r => r.AddAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Board b, CancellationToken _) => b); + + var service = CreateService(); + await service.CreateBoardAsync(dto, _userId); + + _cacheMock.Verify(c => c.RemoveAsync( + CacheKeys.BoardListForUser(_userId), It.IsAny()), Times.Once); + } + + [Fact] + public async Task UpdateBoard_InvalidatesBoardListCache() + { + var boardId = Guid.NewGuid(); + var board = new Board("Old Name", "desc", _userId); + + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, It.IsAny())) + .ReturnsAsync(board); + + var service = CreateService(); + await service.UpdateBoardAsync(boardId, new UpdateBoardDto("New Name", null, null)); + + // Board list cache should be invalidated for the owner + _cacheMock.Verify(c => c.RemoveAsync( + CacheKeys.BoardListForUser(_userId), It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteBoard_InvalidatesBoardListCache() + { + var boardId = Guid.NewGuid(); + var board = new Board("Board to Delete", "desc", _userId); + + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, It.IsAny())) + .ReturnsAsync(board); + + var service = CreateService(); + await service.DeleteBoardAsync(boardId); + + _cacheMock.Verify(c => c.RemoveAsync( + CacheKeys.BoardListForUser(_userId), It.IsAny()), Times.Once); + } + + #endregion + + #region Cache Degradation + + [Fact] + public async Task GetBoardDetail_WorksWithoutCacheService() + { + var boardId = Guid.NewGuid(); + var board = new Board("Fallback Board", "desc", _userId); + + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardId, It.IsAny())) + .ReturnsAsync(board); + + var serviceWithoutCache = new BoardService(_unitOfWorkMock.Object); + var result = await serviceWithoutCache.GetBoardDetailAsync(boardId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Name.Should().Be("Fallback Board"); + } + + [Fact] + public async Task ListBoards_FallsBackToDatabase_WhenNoCacheService() + { + _boardRepoMock.Setup(r => r.SearchIdsAsync(null, false, It.IsAny())) + .ReturnsAsync(new List()); + _boardRepoMock.Setup(r => r.GetByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List()); + + var serviceWithoutCache = new BoardService(_unitOfWorkMock.Object); + var result = await serviceWithoutCache.ListBoardsAsync(_userId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task ListBoards_FallsBackToDatabase_WhenCacheGetThrows() + { + // Even though the ICacheService contract says "never throw", + // verify BoardService is resilient if a faulty implementation violates the contract. + var throwingCacheMock = new Mock(); + throwingCacheMock.Setup(c => c.GetAsync>( + It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Redis down")); + + _boardRepoMock.Setup(r => r.SearchIdsAsync(null, false, It.IsAny())) + .ReturnsAsync(new List()); + _boardRepoMock.Setup(r => r.GetByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List { new("DB Board", null, _userId) }); + + // NOTE: This test documents a known limitation. If a cache implementation + // throws (violating the contract), BoardService will propagate the exception. + // Defense-in-depth would add try/catch at the call site, but the current + // design relies on the contract. This test verifies the contract matters. + var service = CreateService(throwingCacheMock.Object); + var act = () => service.ListBoardsAsync(_userId); + + // Currently throws — documenting this as known behavior + await act.Should().ThrowAsync(); + } + + #endregion + + #region CacheKeys + + [Fact] + public void CacheKeys_BoardListForUser_FormatsCorrectly() + { + var userId = Guid.Parse("abcdef01-2345-6789-abcd-ef0123456789"); + CacheKeys.BoardListForUser(userId).Should().Be("boards:user:abcdef01-2345-6789-abcd-ef0123456789"); + } + + #endregion +} diff --git a/backend/tests/Taskdeck.Domain.Tests/Entities/MfaCredentialTests.cs b/backend/tests/Taskdeck.Domain.Tests/Entities/MfaCredentialTests.cs index 59a286de6..61cb31647 100644 --- a/backend/tests/Taskdeck.Domain.Tests/Entities/MfaCredentialTests.cs +++ b/backend/tests/Taskdeck.Domain.Tests/Entities/MfaCredentialTests.cs @@ -80,6 +80,18 @@ public void SetRecoveryCodes_ShouldClearCodes_WhenEmpty() credential.RecoveryCodes.Should().BeNull(); } + [Fact] + public void SetRecoveryCodes_ShouldClearCodes_WhenNull() + { + var credential = new MfaCredential(Guid.NewGuid(), "JBSWY3DPEHPK3PXP"); + credential.SetRecoveryCodes("hash1,hash2"); + + credential.SetRecoveryCodes(null); + + credential.RecoveryCodes.Should().BeNull( + "null should clear recovery codes"); + } + [Fact] public void Revoke_ShouldSetIsConfirmedToFalse() { diff --git a/docs/decisions/ADR-0023-distributed-caching-cache-aside.md b/docs/decisions/ADR-0023-distributed-caching-cache-aside.md new file mode 100644 index 000000000..729a81f0e --- /dev/null +++ b/docs/decisions/ADR-0023-distributed-caching-cache-aside.md @@ -0,0 +1,85 @@ +# ADR-0023: Distributed Caching — Cache-Aside Pattern with Redis + +- **Status**: Proposed +- **Date**: 2026-04-09 +- **Deciders**: Project maintainers + +## Context + +Issue #85 (PLAT-02) requires a distributed caching strategy with well-defined cache-invalidation semantics. Taskdeck's board listing endpoint is a high-read, low-write path that benefits from caching. The system is local-first with SQLite persistence, so the caching layer must degrade gracefully when no external cache is available. + +Key requirements: +- Cache hot read paths (board listing) to reduce database load +- Define explicit TTL, key strategy, and invalidation triggers +- Cache failures must never break correctness — safe degradation to no-cache mode +- Observability: hit/miss/error metrics for cache effectiveness analysis +- Support both distributed (Redis) and local (in-memory) cache backends + +## Decision + +Adopt the **cache-aside** (lazy-loading) pattern with two interchangeable implementations: + +1. **Redis-backed** (`RedisCacheService`) for production/multi-instance deployments +2. **In-memory** (`InMemoryCacheService`) using `ConcurrentDictionary` with periodic sweep for local dev and test + +The abstraction lives in `Taskdeck.Application` as `ICacheService`. Implementations live in `Taskdeck.Infrastructure`. + +### Cache-Aside Flow + +``` +Read: Check cache → hit? return cached → miss? load from DB → store in cache → return +Write: Mutate DB → invalidate cache key(s) +``` + +### Key Strategy + +- Board list: `boards:user:{userId}` (user-scoped because board visibility depends on authorization) +- Keys are prefixed with `td:` namespace to avoid collisions in shared Redis instances +- Board detail is intentionally NOT cached: `BoardDetailDto` includes columns with card counts, and `ColumnService`/`CardService` mutate that data without cache awareness. Caching board detail would serve stale column/card information after sibling-service mutations. + +### TTL Policy + +- Board list: 60 seconds (short TTL — list changes frequently with board creation/archival) +- All TTLs are configurable via `appsettings.json` + +### Invalidation Triggers + +- Board create/update/delete/archive/unarchive: invalidate `boards:user:{ownerId}` cache for the board owner +- For simplicity in the initial implementation, board list cache is invalidated per-owner (invalidate the acting user's list cache on mutation) + +### Safe Degradation + +- All cache operations are wrapped in try/catch +- On cache error: log warning, proceed without cache (transparent to caller) +- No exceptions propagated from cache failures +- Cache unavailability does not affect data correctness + +### Metrics + +- Cache hit/miss/error counters emitted via `ILogger` structured logging +- Metric names: `cache.hit`, `cache.miss`, `cache.error` +- Tagged with `cache_key_prefix` for per-resource analysis + +## Alternatives Considered + +- **Write-through**: Updates cache on every write. Adds latency to write paths and complexity for multi-key invalidation. Rejected because Taskdeck's write patterns are relatively simple and cache-aside is simpler to reason about for invalidation correctness. + +- **Read-through**: Cache itself is responsible for loading on miss. Requires tighter coupling between cache and data access layers, violating the clean architecture boundary (cache would need repository references). Rejected. + +- **No caching**: Simplest option. Adequate for single-user local-first usage but would not scale for multi-user or hosted deployments (PLAT expansion strategy). Rejected for forward-compatibility reasons, though the fallback mode effectively provides this. + +- **EF Core second-level cache**: Third-party packages like `EFCoreSecondLevelCacheInterceptor` exist but couple caching decisions to the ORM layer rather than the application layer. Rejected for lack of explicit invalidation control and observability. + +## Consequences + +- Board listing endpoint gains cache-aside behavior with measurable hit rates (board detail intentionally excluded — see Key Strategy) +- New `ICacheService` abstraction available for future hot paths (cards, columns, proposals) +- Redis becomes an optional infrastructure dependency (not required for local dev) +- Cache invalidation correctness must be maintained as new board mutation paths are added +- TTL values may need tuning based on observed usage patterns + +## References + +- Issue: #85 (PLAT-02: Distributed caching strategy and cache-invalidation semantics) +- Related: `BoardService`, `BoardsController`, `InMemoryActiveUserCache` (existing per-request cache pattern) +- Platform expansion: ADR-0014, #531