Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ab10cb3
Add ADR-0023: distributed caching strategy with cache-aside pattern
Chris0Jeky Apr 9, 2026
c631674
Add ICacheService interface in Application layer
Chris0Jeky Apr 9, 2026
e5afd4c
Add cache service implementations with DI wiring
Chris0Jeky Apr 9, 2026
1f817f3
Apply cache-aside pattern to BoardService hot read paths
Chris0Jeky Apr 9, 2026
33a1d2c
Add cache service and cache-aside behavior tests
Chris0Jeky Apr 9, 2026
bee66f0
Update STATUS.md and IMPLEMENTATION_MASTERPLAN.md for cache delivery
Chris0Jeky Apr 9, 2026
9473f35
Fix self-review findings: log level, dispose, unused field
Chris0Jeky Apr 9, 2026
8d3bfc1
Remove board detail caching to prevent stale data from sibling servic…
Chris0Jeky Apr 9, 2026
91c76c6
Replace Lazy<ConnectionMultiplexer> with reconnection-capable pattern
Chris0Jeky Apr 9, 2026
7ae17ce
Add capacity-capped eviction and defensive timer callback to InMemory…
Chris0Jeky Apr 9, 2026
e98c2b6
Remove unused Microsoft.Extensions.Caching.Memory package reference
Chris0Jeky Apr 9, 2026
bddb47b
Log warning for unknown cache provider configuration values
Chris0Jeky Apr 9, 2026
88b4a27
Update BoardServiceCacheTests for board-detail caching removal and fi…
Chris0Jeky Apr 9, 2026
2e73840
Update ADR-0023 and docs to reflect review-driven design changes
Chris0Jeky Apr 9, 2026
bebb2c0
Merge remote-tracking branch 'origin/main' into feature/distributed-c…
claude Apr 9, 2026
c4b1f83
Guard proposal decisions with EF concurrency
Chris0Jeky Apr 9, 2026
705e411
Fix cache service review comments from PR #805
Chris0Jeky Apr 12, 2026
60835d3
Merge main to resolve conflicts
Chris0Jeky Apr 12, 2026
9f6c7e7
Merge branch 'feature/sso-oidc-mfa-policy' of https://github.com/Chri…
Chris0Jeky Apr 12, 2026
5940167
Resolve merge conflicts with main for distributed cache PR
Chris0Jeky Apr 12, 2026
208c657
Fix MfaCredentialTests and resolve merge conflicts with main
Chris0Jeky Apr 12, 2026
573f512
Resolve merge conflicts with main after SSO/OIDC/MFA merge
Chris0Jeky Apr 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ public static class ApplicationServiceRegistration
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
services.AddScoped<BoardService>();
services.AddScoped<BoardService>(sp =>
new BoardService(
sp.GetRequiredService<IUnitOfWork>(),
sp.GetService<IAuthorizationService>(),
sp.GetService<IBoardRealtimeNotifier>(),
sp.GetService<IHistoryService>(),
sp.GetService<ICacheService>(),
sp.GetService<CacheSettings>()));
services.AddScoped<ColumnService>();
services.AddScoped<CardService>();
services.AddScoped<CardCommentService>();
Expand Down
45 changes: 45 additions & 0 deletions backend/src/Taskdeck.Application/Interfaces/ICacheService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
namespace Taskdeck.Application.Interfaces;

/// <summary>
/// Generic cache abstraction for cache-aside pattern.
/// Implementations must degrade safely: cache failures never throw exceptions
/// to callers and do not affect data correctness.
/// </summary>
public interface ICacheService
{
/// <summary>
/// Attempts to retrieve a cached value. Returns null on miss or error.
/// </summary>
/// <typeparam name="T">The cached value type (must be JSON-serializable).</typeparam>
/// <param name="key">The cache key (will be prefixed automatically).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cached value, or null if not found or on error.</returns>
Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default) where T : class;

/// <summary>
/// Stores a value in the cache with the specified TTL.
/// Silently swallows errors — caller is never affected by cache write failures.
/// </summary>
/// <typeparam name="T">The value type (must be JSON-serializable).</typeparam>
/// <param name="key">The cache key (will be prefixed automatically).</param>
/// <param name="value">The value to cache.</param>
/// <param name="ttl">Time-to-live for the cached entry.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task SetAsync<T>(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default) where T : class;

/// <summary>
/// Removes a cached entry. Silently swallows errors.
/// </summary>
/// <param name="key">The cache key to invalidate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task RemoveAsync(string key, CancellationToken cancellationToken = default);

/// <summary>
/// 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.
/// </summary>
/// <param name="keyPrefix">The key prefix to match for removal.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task RemoveByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default);
}
71 changes: 68 additions & 3 deletions backend/src/Taskdeck.Application/Services/BoardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -67,6 +73,10 @@ public async Task<Result<BoardDto>> UpdateBoardAsync(Guid id, UpdateBoardDto dto

public async Task<Result<BoardDetailDto>> 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<BoardDetailDto>(ErrorCodes.NotFound, $"Board with ID {id} not found");
Expand All @@ -88,6 +98,13 @@ public async Task<Result<BoardDetailDto>> GetBoardDetailAsync(Guid id, Guid acti
return await GetBoardDetailAsync(id, cancellationToken);
}

/// <summary>
/// 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 <see cref="ListBoardsAsync(Guid, string?, bool, CancellationToken)"/>
/// overload IS cached.
/// </summary>
public async Task<Result<IEnumerable<BoardDto>>> ListBoardsAsync(string? searchText = null, bool includeArchived = false, CancellationToken cancellationToken = default)
{
var boards = await _unitOfWork.Boards.SearchAsync(searchText, includeArchived, cancellationToken);
Expand All @@ -99,12 +116,33 @@ public async Task<Result<IEnumerable<BoardDto>>> ListBoardsAsync(Guid actingUser
if (actingUserId == Guid.Empty)
return Result.Failure<IEnumerable<BoardDto>>(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<List<BoardDto>>(cacheKey, cancellationToken);
if (cached is not null)
{
return Result.Success<IEnumerable<BoardDto>>(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<IEnumerable<BoardDto>>(dtos);
}

var visibleBoardIdsResult = await _authorizationService.GetReadableBoardIdsAsync(
Expand All @@ -117,8 +155,15 @@ public async Task<Result<IEnumerable<BoardDto>>> 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<IEnumerable<BoardDto>>(visibleBoards.Select(MapToDto));
if (canCache)
{
var ttl = TimeSpan.FromSeconds(_cacheSettings!.BoardListTtlSeconds);
await _cacheService!.SetAsync(CacheKeys.BoardListForUser(actingUserId), result, ttl, cancellationToken);
}

return Result.Success<IEnumerable<BoardDto>>(result);
}

public async Task<Result> DeleteBoardAsync(Guid id, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Comment on lines +251 to +252
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The invalidation logic only clears the board list cache for the owner. If a board is shared with other users, their cached lists will remain stale until the TTL expires. This discrepancy with ADR-0023 (which specifies invalidating all accessible users' caches) could lead to data inconsistency in multi-user scenarios. Consider using RemoveByPrefixAsync to invalidate all user board lists or updating the design document to reflect this limitation.


return Result.Success(MapToDto(board));
}
catch (DomainException ex)
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions backend/src/Taskdeck.Application/Services/CacheKeys.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Taskdeck.Application.Services;

/// <summary>
/// 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:").
/// </summary>
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.

/// <summary>
/// Cache key for a user's board list (default, non-filtered, non-archived).
/// Format: boards:user:{userId}
/// </summary>
public static string BoardListForUser(Guid userId) => $"boards:user:{userId}";
}
29 changes: 29 additions & 0 deletions backend/src/Taskdeck.Application/Services/CacheSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace Taskdeck.Application.Services;

/// <summary>
/// Configuration for the distributed caching layer.
/// Bound from appsettings.json "Cache" section.
/// </summary>
public sealed class CacheSettings
{
/// <summary>
/// Cache provider: "Redis", "InMemory", or "None".
/// Defaults to "InMemory" for local-first usage.
/// </summary>
public string Provider { get; set; } = "InMemory";

/// <summary>
/// Redis connection string. Only used when Provider is "Redis".
/// </summary>
public string? RedisConnectionString { get; set; }

/// <summary>
/// Global key prefix to avoid collisions in shared Redis instances.
/// </summary>
public string KeyPrefix { get; set; } = "td";

/// <summary>
/// Default TTL in seconds for board list cache entries.
/// </summary>
public int BoardListTtlSeconds { get; set; } = 60;
}
58 changes: 58 additions & 0 deletions backend/src/Taskdeck.Infrastructure/DependencyInjection.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -49,6 +51,62 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
services.AddScoped<IKnowledgeSearchService, Taskdeck.Infrastructure.Services.KnowledgeFtsSearchService>();
services.AddScoped<IUnitOfWork, UnitOfWork>();

// Cache service registration
services.AddCacheService(configuration);

return services;
}

private static void AddCacheService(this IServiceCollection services, IConfiguration configuration)
{
var cacheSettings = configuration.GetSection("Cache").Get<CacheSettings>() ?? 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<ICacheService>(sp =>
new InMemoryCacheService(
sp.GetRequiredService<ILogger<InMemoryCacheService>>(),
cacheSettings.KeyPrefix));
}
else
{
services.AddSingleton<ICacheService>(sp =>
new RedisCacheService(
cacheSettings.RedisConnectionString,
sp.GetRequiredService<ILogger<RedisCacheService>>(),
cacheSettings.KeyPrefix));
}
break;

case "none":
services.AddSingleton<ICacheService>(NoOpCacheService.Instance);
break;

case "inmemory":
services.AddSingleton<ICacheService>(sp =>
new InMemoryCacheService(
sp.GetRequiredService<ILogger<InMemoryCacheService>>(),
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<ICacheService>(sp =>
{
var logger = sp.GetRequiredService<ILogger<InMemoryCacheService>>();
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);
}
}
Loading
Loading