Skip to content

Implement P6: cache system with FusionCache integration#4

Open
jhosm wants to merge 1 commit intomainfrom
feature/p6-cache-system
Open

Implement P6: cache system with FusionCache integration#4
jhosm wants to merge 1 commit intomainfrom
feature/p6-cache-system

Conversation

@jhosm
Copy link
Owner

@jhosm jhosm commented Feb 9, 2026

Summary

  • Add CacheHandler DelegatingHandler with FusionCache-backed caching for GET/HEAD requests
  • Implement ETag/If-None-Match revalidation with 304 Not Modified support
  • Add SWR (stale-while-revalidate) via FactorySoftTimeout and fail-safe via FailSafeMaxDuration
  • Support shared, user-isolated, and no-cache key strategies via CacheKeyBuilder
  • Add AcdcCacheManager for programmatic cache clearing by URL or user
  • Mutation methods (POST/PUT/DELETE/PATCH) automatically invalidate related GET cache entries
  • Add per-request cache duration override via AcdcRequestOptions.CacheMaxAge

Files added

Source (6 files):

  • src/CSharpAcdc/Cache/CacheKeyBuilder.cs - Cache key strategies (Shared, UserIsolated, NoCache)
  • src/CSharpAcdc/Cache/CachedResponse.cs - Serializable record for cached HTTP responses
  • src/CSharpAcdc/Cache/IAcdcCacheManager.cs - Interface for cache management
  • src/CSharpAcdc/Cache/AcdcCacheManager.cs - Cache clearing by URL, user, or all entries
  • src/CSharpAcdc/Configuration/AcdcCacheOptions.cs - Configuration record (duration, fail-safe, SWR, ETag)
  • src/CSharpAcdc/Handlers/CacheHandler.cs - Core DelegatingHandler with FusionCache integration

Tests (5 files, 42 tests):

  • tests/CSharpAcdc.Tests/Cache/CacheKeyBuilderTests.cs - Key format for all strategies
  • tests/CSharpAcdc.Tests/Cache/AcdcCacheManagerTests.cs - Clear all, by URL, by user
  • tests/CSharpAcdc.Tests/Handlers/CacheHandlerTests.cs - Cache hit/miss, headers, skip, user isolation, invalidation
  • tests/CSharpAcdc.Tests/Handlers/CacheHandlerETagTests.cs - ETag storage, If-None-Match, 304 resolution
  • tests/CSharpAcdc.Tests/Handlers/CacheHandlerSwrTests.cs - SWR timeout, stale-if-error, fail-safe

Test plan

  • dotnet build passes with zero warnings (TreatWarningsAsErrors enabled)
  • All 102 tests pass (42 new cache tests + 60 existing tests)
  • ETag revalidation works across cache expiration boundaries
  • SWR returns stale data when factory exceeds soft timeout
  • Fail-safe returns stale data on downstream errors
  • Mutation requests invalidate related GET cache entries

🤖 Generated with Claude Code

Add CacheHandler DelegatingHandler with FusionCache-backed caching for
GET/HEAD requests. Includes ETag/If-None-Match revalidation, per-request
cache duration overrides, user-isolated cache keys, mutation invalidation,
SWR via FactorySoftTimeout, and fail-safe for stale-if-error scenarios.

Source files:
- Cache/CacheKeyBuilder: shared, user-isolated, and no-cache key strategies
- Cache/CachedResponse: serializable record for cached HTTP responses
- Cache/IAcdcCacheManager + AcdcCacheManager: cache clearing by URL/user
- Configuration/AcdcCacheOptions: duration, fail-safe, SWR, ETag settings
- Handlers/CacheHandler: DelegatingHandler with FusionCache GetOrSetAsync

Test files (42 tests):
- CacheKeyBuilderTests: key format for all strategies
- CacheHandlerTests: hit/miss, metadata headers, skip-cache, user isolation
- CacheHandlerETagTests: ETag storage, If-None-Match, 304 resolution
- CacheHandlerSwrTests: SWR timeout, stale-if-error, fail-safe

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 9, 2026 00:28
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a FusionCache-backed HTTP caching layer to CSharpAcdc, including cache key strategies, ETag revalidation, stale-while-revalidate / fail-safe behavior, and programmatic invalidation support.

Changes:

  • Introduce CacheHandler delegating handler to cache GET/HEAD responses and support ETag-based 304 revalidation.
  • Add cache key strategy helper + cached response model, plus AcdcCacheManager/IAcdcCacheManager for cache clearing/invalidation.
  • Add a comprehensive new test suite covering cache hits/misses, key strategies, invalidation, ETag behavior, and SWR/fail-safe.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 46 comments.

Show a summary per file
File Description
src/CSharpAcdc/Handlers/CacheHandler.cs Core caching handler with FusionCache integration, ETag revalidation, and mutation invalidation logic.
src/CSharpAcdc/Configuration/AcdcCacheOptions.cs Options record controlling TTL, SWR soft timeout, fail-safe window, key strategy, and ETag enablement.
src/CSharpAcdc/Cache/CacheKeyBuilder.cs Cache key building logic supporting shared/user-isolated/no-cache strategies.
src/CSharpAcdc/Cache/CachedResponse.cs Serializable response shape for storing cached HTTP responses.
src/CSharpAcdc/Cache/IAcdcCacheManager.cs Public cache manager interface for clearing cache by scope.
src/CSharpAcdc/Cache/AcdcCacheManager.cs Tracks cache keys per base URL and removes cache entries by URL/user/all.
tests/CSharpAcdc.Tests/Handlers/CacheHandlerTests.cs Tests for basic caching, headers, bypass flags, user isolation, invalidation, and HEAD caching.
tests/CSharpAcdc.Tests/Handlers/CacheHandlerETagTests.cs Tests for ETag storage, If-None-Match, and 304 cached resolution.
tests/CSharpAcdc.Tests/Handlers/CacheHandlerSwrTests.cs Tests for SWR soft timeout behavior and fail-safe stale responses.
tests/CSharpAcdc.Tests/Cache/CacheKeyBuilderTests.cs Tests for cache key formats across strategies and methods.
tests/CSharpAcdc.Tests/Cache/AcdcCacheManagerTests.cs Tests for clearing/invalidation behavior by URL/user/all.
tests/CSharpAcdc.Tests/CSharpAcdc.Tests.csproj Adds required test dependencies (FusionCache, logging abstractions, options).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +79 to +84
catch (Exception ex) when (ex is not AcdcCacheException)
{
_logger.LogWarning(ex, "Cache operation failed for key {CacheKey}, falling through to downstream", cacheKey);
var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
return response;
}
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

The catch (Exception ex) when (ex is not AcdcCacheException) fallback calls base.SendAsync again. If the exception originated from the downstream call inside the FusionCache factory (e.g., HttpRequestException), this results in an unintended implicit retry / duplicate request. Consider only falling back when the failure is cache-layer related, and rethrow downstream exceptions instead of re-sending the request.

Copilot uses AI. Check for mistakes.
Comment on lines +122 to +128
// Add If-None-Match header if we have a stored ETag
if (_options.ETagEnabled && storedETag is not null)
{
request.Headers.IfNoneMatch.Clear();
request.Headers.IfNoneMatch.Add(
new System.Net.Http.Headers.EntityTagHeaderValue($"\"{storedETag}\""));
}
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

The FusionCache factory captures and mutates the caller’s HttpRequestMessage (clearing/setting If-None-Match). With AllowTimedOutFactoryBackgroundCompletion enabled, the factory may continue after the original call returns, so reusing the same request instance can race with caller disposal/mutation and can also leak headers into subsequent uses. Consider cloning the request message for the downstream send (and avoid overwriting caller-specified If-None-Match).

Copilot uses AI. Check for mistakes.
Comment on lines +172 to +189
private async Task InvalidateRelatedCacheEntriesAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (_cacheManager is null)
return;

var baseUrl = GetBaseUrl(request.RequestUri);

try
{
await _cacheManager.InvalidateForBaseUrlAsync(baseUrl, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to invalidate cache entries for base URL {BaseUrl}", baseUrl);
}
}
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

Cache invalidation only removes FusionCache entries via _cacheManager, but _etagStore is never cleared. After a mutation invalidates a URL, a subsequent GET can still send an old ETag and, on a 304, return lastKnownResponse even though the cache was invalidated. Clear the corresponding _etagStore entry when invalidating (or store ETag/lastKnownResponse in the cache with the same invalidation path).

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +32
private readonly Func<HttpRequestMessage, string?>? _userIdProvider;
private readonly AcdcCacheManager? _cacheManager;

// ETags and last responses survive cache expiration for 304 revalidation
private readonly ConcurrentDictionary<string, (string ETag, CachedResponse Response)> _etagStore = new();

private static readonly HttpRequestOptionsKey<string> CacheSourceKey = new("acdc_source");

public CacheHandler(
IFusionCache cache,
IOptions<AcdcCacheOptions> options,
ILogger<CacheHandler> logger,
Func<HttpRequestMessage, string?>? userIdProvider = null,
AcdcCacheManager? cacheManager = null)
{
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

CacheHandler depends on the concrete AcdcCacheManager type even though an IAcdcCacheManager interface exists. This makes it harder to mock/extend and also hides InvalidateForBaseUrlAsync (not on the interface). Consider updating IAcdcCacheManager to include the invalidation method and accept the interface here instead of the concrete class.

Copilot uses AI. Check for mistakes.
{
Task ClearCacheAsync(CancellationToken ct = default);
Task ClearCacheForUrlAsync(string url, CancellationToken ct = default);
Task ClearCacheForUserAsync(string userId, CancellationToken ct = default);
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

IAcdcCacheManager is missing the InvalidateForBaseUrlAsync method that CacheHandler relies on, forcing the handler to take a concrete AcdcCacheManager. Add InvalidateForBaseUrlAsync (or rename/align methods) so the handler can depend on the interface.

Suggested change
Task ClearCacheForUserAsync(string userId, CancellationToken ct = default);
Task ClearCacheForUserAsync(string userId, CancellationToken ct = default);
Task InvalidateForBaseUrlAsync(string baseUrl, CancellationToken ct = default);

Copilot uses AI. Check for mistakes.
Comment on lines +219 to +222
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent($"data-for-call-{callCount}"),
});
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

Disposable 'HttpResponseMessage' is created but not disposed.

Copilot uses AI. Check for mistakes.
Comment on lines +243 to +246
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("user-data"),
});
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

Disposable 'HttpResponseMessage' is created but not disposed.

Copilot uses AI. Check for mistakes.
Comment on lines +264 to +267
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent($"response-{callCount}"),
});
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

Disposable 'HttpResponseMessage' is created but not disposed.

Copilot uses AI. Check for mistakes.
Comment on lines +290 to +293
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("data"),
});
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

Disposable 'HttpResponseMessage' is created but not disposed.

Copilot uses AI. Check for mistakes.
var (_, client) = CreatePipeline((_, _) =>
{
Interlocked.Increment(ref callCount);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

Disposable 'HttpResponseMessage' is created but not disposed.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant