From 10353cd2172ed1665fd049dca790f8b43fe17982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ruiz=20Ad=C3=A1n?= Date: Wed, 26 Aug 2026 19:59:02 +0200 Subject: [PATCH] feat(caching): tag-based invalidation, full Vary/credential eviction, cache status header - IStampedeHttpCache.EvictByTagAsync: responses labeled via CacheOptions.TagHeaderNames (Cache-Tag, Surrogate-Key, xkey conventions; comma- and space-separated) or CacheRequestPolicy.Tags are indexed per tag in the client's own ICacheStore, so one call evicts every tagged entry, Vary variants included. 304 refreshes re-extend the index deadline so long-revalidated entries stay reachable by tag. - EvictAsync now sweeps Vary variants: variant keys are tracked on the primary marker (CacheEntry.TrackedKeys) at store time and removed on explicit eviction, closing the documented unreachable-but-alive trade-off. RFC 9111 4.4 invalidation unchanged. - EvictAsync(uri, authorization): credential-scoped eviction for entries stored under AuthorizationCaching, resolving the same key the authenticated GET stored under. New interface members ship as default interface methods for pre-2.6 implementations. - X-Stampede-Cache response header (StampedeCacheStatus): HIT / MISS / STALE / REVALIDATED on every response the caching layer handles, COALESCED stamped by the coalescer on waiters; never persisted into stored entries. Co-Authored-By: Claude Fable 5 --- README.md | 69 ++++- .../Caching/AuthorizedEvictionTests.cs | 144 ++++++++++ .../Caching/CacheEntryJsonConverterTests.cs | 6 +- .../Caching/CacheStatusHeaderTests.cs | 263 ++++++++++++++++++ .../Caching/TagInvalidationTests.cs | 256 +++++++++++++++++ .../Caching/VaryVariantEvictionTests.cs | 156 +++++++++++ Stampede.Http/Caching/CacheEntry.cs | 22 ++ .../Caching/CacheEntryJsonConverter.cs | 20 +- Stampede.Http/Caching/CacheIndexing.cs | 77 +++++ Stampede.Http/Caching/CacheKeyHelpers.cs | 12 + Stampede.Http/Caching/CacheOptions.cs | 25 ++ Stampede.Http/Caching/CacheRequestPolicy.cs | 9 + Stampede.Http/Caching/CachingMiddleware.cs | 175 ++++++++++-- Stampede.Http/Caching/IStampedeHttpCache.cs | 66 ++++- Stampede.Http/Caching/MemoryCacheStore.cs | 10 + Stampede.Http/Caching/StampedeHttpCache.cs | 33 ++- Stampede.Http/Coalescing/RequestCoalescer.cs | 9 +- Stampede.Http/StampedeCacheStatus.cs | 78 ++++++ 18 files changed, 1392 insertions(+), 38 deletions(-) create mode 100644 Stampede.Http.Tests/Caching/AuthorizedEvictionTests.cs create mode 100644 Stampede.Http.Tests/Caching/CacheStatusHeaderTests.cs create mode 100644 Stampede.Http.Tests/Caching/TagInvalidationTests.cs create mode 100644 Stampede.Http.Tests/Caching/VaryVariantEvictionTests.cs create mode 100644 Stampede.Http/Caching/CacheIndexing.cs create mode 100644 Stampede.Http/StampedeCacheStatus.cs diff --git a/README.md b/README.md index 7e13312..1b17a5e 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Stampede.Http occupies a specific niche: **origin-controlled caching semantics i | `HeuristicFreshnessFraction` | `0.1` | Fraction of the `Last-Modified` age used as the heuristic TTL, when enabled | | `MaxHeuristicFreshness` | `24h` | Upper bound on the heuristic TTL, when enabled | | `AuthorizationCaching` | `Never` | Whether requests carrying an `Authorization` header are cacheable — `Never`, `WhenPermittedByResponse` (RFC 9111 §3.5: requires `public`/`must-revalidate`/`s-maxage`), or `Always`. See [Caching authorized requests](#caching-authorized-requests) | +| `TagHeaderNames` | `[]` | Response header names scanned for cache tags (e.g. `Cache-Tag`, `Surrogate-Key`, `xkey`), enabling group invalidation via `EvictByTagAsync`. See [Tag-based invalidation](#tag-based-invalidation) | | `EnableEarlyRevalidation` | `false` | XFetch: probabilistically refresh a fresh entry in the background ahead of its expiry. See [Early revalidation (XFetch)](#early-revalidation-xfetch) | | `EarlyRevalidationBeta` | `1.0` | Tuning parameter (β) scaling how far ahead of expiry early revalidation starts, in units of the entry's measured origin fetch duration | @@ -143,7 +144,43 @@ await cache.EvictAsync(new Uri("https://api.example.com/products/42")); Or via constructor injection when there's a single registered client — the non-keyed `IStampedeHttpCache` falls back to the first-registered client, same as `ICacheStore`. -Eviction targets one exact URI (the same key a GET to it would resolve to) — there's no prefix or pattern eviction, since `IDistributedCache` has no portable way to enumerate keys. It's unconditional and idempotent: evicting a URI with nothing cached is a no-op. If the evicted entry carried a `Vary` header, only the primary marker is removed; the secondary-key variants it pointed to become unreachable and expire on their own rather than being actively swept. +URI eviction targets one exact URI (the same key a GET to it would resolve to) — there's no prefix or pattern eviction, since `IDistributedCache` has no portable way to enumerate keys; to invalidate a group of URIs in one call, use [tags](#tag-based-invalidation). Eviction is unconditional and idempotent: evicting a URI with nothing cached is a no-op. If the evicted entry carries a `Vary` header, its secondary-key variants are swept too — each variant's key is tracked on the primary marker as it's stored, so eviction follows the marker and removes all of them (best-effort: a variant that fell out of the tracked list just expires on its own instead). + +When [`AuthorizationCaching`](#caching-authorized-requests) is enabled, authenticated responses live under credential-scoped keys the URI overload can't reach. Pass the same `Authorization` value the cached request carried: + +```csharp +await cache.EvictAsync(new Uri("https://api.example.com/products/42"), + new AuthenticationHeaderValue("Bearer", token)); +``` + +--- + +## Tag-based invalidation + +Evicting one URI at a time doesn't scale to "product 42 changed — drop its detail view, every list it appears in, and the search results mentioning it". Tags solve this the way CDNs do (Cloudflare's `Cache-Tag`, Fastly's `Surrogate-Key`, Varnish's `xkey`): the origin labels each response, and the client invalidates by label. + +Opt in by naming the response headers to scan: + +```csharp +services.AddHttpClient("catalog") + .AddStampedeHttp(cache => cache.TagHeaderNames = ["Cache-Tag"]); +``` + +Any response stored with `Cache-Tag: products, product-42` (comma- or space-separated — both CDN conventions work) is indexed under each tag. One call then invalidates every entry carrying the tag, including their `Vary` variants: + +```csharp +IStampedeHttpCache cache = serviceProvider.GetRequiredKeyedService("catalog"); +await cache.EvictByTagAsync("product-42"); +``` + +When the origin doesn't emit tag headers, attach tags from the caller instead — `CacheRequestPolicy.Tags` works with `TagHeaderNames` unset: + +```csharp +var request = new HttpRequestMessage(HttpMethod.Get, "/api/products/42"); +request.Options.Set(CacheRequestPolicy.Tags, ["product-42"]); +``` + +Tags are compared ordinally (case-sensitive). The index lives in the same `ICacheStore` as the entries — memory or distributed — with a retention that covers the longest-retained entry it tracks. Like `Vary` variant tracking, it's best-effort by design: the index is read-merge-write with no compare-and-swap, and capped at 1024 keys per tag, so under extreme concurrency or cardinality an entry can fall out of the index — it's then simply not swept by `EvictByTagAsync` and expires via its own freshness/validator rules; it is never served incorrectly. --- @@ -164,7 +201,7 @@ services.AddHttpClient("catalog") Stampede.Http is a *private* cache (scoped to one process/`HttpClient`, never shared through a common proxy across principals), so returning a caller's own prior response to that same caller isn't the cross-user leak §3.5 guards against. What still has to hold is that *different* credentials are never mixed: whenever this isn't `Never`, both the cache key and the coalescing key fold in a hash of the `Authorization` value (never the raw value — it never appears in a key, a log line, or a distributed store's key listing), so two callers presenting different — or absent — credentials for the same URL always get independent entries and are never coalesced into one shared origin call. This protection in the coalescer is unconditional, independent of `AuthorizationCaching`: it also applies with `AddCoalescingOnly()`, with no caching in the pipeline at all. -Two known limitations, both a direct consequence of `HEAD` and §4.4 invalidation resolving the plain, unauthenticated key for a URI (they have no credential of their own to scope by): an authenticated `HEAD` request won't hit its own `GET` entry's cache, and a successful `POST`/`PUT`/`DELETE` only invalidates the unauthenticated entry (if any) for that URL, not any per-credential ones — those expire on their own via normal freshness/validator rules, or can be evicted explicitly via [`IStampedeHttpCache`](#programmatic-eviction) if that's not enough. +Two known limitations, both a direct consequence of `HEAD` and §4.4 invalidation resolving the plain, unauthenticated key for a URI (they have no credential of their own to scope by): an authenticated `HEAD` request won't hit its own `GET` entry's cache, and a successful `POST`/`PUT`/`DELETE` only invalidates the unauthenticated entry (if any) for that URL, not any per-credential ones — those expire on their own via normal freshness/validator rules, or can be evicted explicitly with the credential-scoped `EvictAsync(uri, authorization)` overload (see [Programmatic eviction](#programmatic-eviction)). --- @@ -238,6 +275,7 @@ request.Options.Set(CacheRequestPolicy.BypassCache, true); | `BypassCache` | Skips all cache interaction — lookup, storage, and unsafe-method invalidation | | `ForceRevalidate` | Forces conditional revalidation even if the entry is fresh | | `NoStore` | Prevents the response from being stored; reads and revalidation still work | +| `Tags` | Cache tags to index the stored response under, for group invalidation via `EvictByTagAsync` — honored even with `TagHeaderNames` unset. See [Tag-based invalidation](#tag-based-invalidation) | **Coalescing policy** (`CoalescingRequestPolicy`): @@ -247,6 +285,27 @@ request.Options.Set(CacheRequestPolicy.BypassCache, true); --- +## Cache status header + +Every response the caching layer handles carries a synthetic `X-Stampede-Cache` header reporting how it was obtained — the client-side equivalent of a CDN's `X-Cache`. Constants and a typed accessor live on `StampedeCacheStatus`: + +```csharp +HttpResponseMessage response = await client.GetAsync("/api/products/42"); +string? status = StampedeCacheStatus.GetStatus(response); // "HIT", "MISS", ... +``` + +| Value | Meaning | +|---|---| +| `HIT` | Served from a fresh cache entry, no origin contact (includes locally answered conditional requests returning `304`) | +| `STALE` | Served from an expired entry — `stale-while-revalidate`, `stale-if-error`, or the request's own `max-stale` | +| `REVALIDATED` | Served from cache after the origin confirmed it unchanged with `304 Not Modified` | +| `COALESCED` | Shared another concurrent caller's in-flight origin call instead of issuing its own | +| `MISS` | Fetched from the origin — no usable cache entry | + +The header is absent when the caching layer didn't participate (unsafe methods, non-cacheable requests, `BypassCache`) — except `COALESCED`, which the coalescer sets on its own, so it also appears with `AddCoalescingOnly()`. It's set on the response handed back to the caller and never persisted: stored entries strip it, so a replayed hit always reports its own status. Handy in integration tests — assert `GetStatus(response) == StampedeCacheStatus.Hit` instead of instrumenting metrics or counting stub calls. + +--- + ## Metrics All instruments live under the **`Stampede.Http`** meter. @@ -335,6 +394,12 @@ MIT — see [LICENSE](LICENSE). ## Changelog +### v2.6.0 +- **Tag-based invalidation (`IStampedeHttpCache.EvictByTagAsync`)** — responses can be labeled with cache tags, collected from the response headers named in `CacheOptions.TagHeaderNames` (`Cache-Tag`, `Surrogate-Key`, `xkey`… — comma- and space-separated values both work) or attached per request via `CacheRequestPolicy.Tags`; one call then evicts every entry carrying a tag, `Vary` variants included. The index lives in the client's own `ICacheStore` (memory or distributed) and is best-effort by design. Off by default: with `TagHeaderNames` unset and no request tags, nothing is indexed. See [Tag-based invalidation](#tag-based-invalidation). +- **Explicit eviction sweeps `Vary` variants.** `EvictAsync` previously removed only the primary-key marker of a varying resource, leaving its secondary-key variants unreachable-but-alive until their own retention elapsed. Variant keys are now tracked on the marker (`CacheEntry.TrackedKeys`) as they're stored, and eviction removes them too. §4.4 invalidation is deliberately unchanged (its marker removal already made variants unreachable; the extra read per unsafe request wasn't worth it). +- **Credential-scoped eviction (`EvictAsync(uri, authorization)`)** — closes the 2.4 limitation that per-credential entries stored under `AuthorizationCaching` couldn't be evicted programmatically: the new overload resolves the same credential-scoped key the authenticated GET stored under. The new `IStampedeHttpCache` members ship as default interface methods (throwing `NotSupportedException`), so custom pre-2.6 implementations keep compiling. +- **Cache status header (`X-Stampede-Cache`)** — every response the caching layer handles now reports how it was obtained: `HIT`, `MISS`, `STALE`, `REVALIDATED`, or `COALESCED` (set by the coalescer for waiters that shared another caller's in-flight origin call). Constants and a typed accessor on `StampedeCacheStatus`; never persisted into stored entries. See [Cache status header](#cache-status-header). + ### v2.5.0 - **Coalescing non-GET requests (`CoalescerOptions.ShouldCoalesce`)** — opt specific `POST` (or other non-`GET`/`HEAD`) requests into coalescing, keyed on method + URL + a hash of the request body so two different bodies to the same URL are never merged. Buffering the body to hash it also makes it replayable for retry/hedging layers. Default remains unset — no method beyond `GET`/`HEAD` is ever coalesced unless explicitly matched. See [Coalescing non-GET requests](#coalescing-non-get-requests). - **Early revalidation (`CacheOptions.EnableEarlyRevalidation`)** — XFetch probabilistic early expiration: a fresh cache hit can trigger a background refresh ahead of its expiry, with the trigger probability scaled by how expensive the entry was to fetch (`CacheEntry.OriginFetchDurationMs`, newly tracked on every origin call) and `EarlyRevalidationBeta`. Spreads out — rather than synchronizes — when concurrent callers/instances refetch a resource near its expiry. Default remains `false`; enabling it changes no other behavior. See [Early revalidation (XFetch)](#early-revalidation-xfetch). diff --git a/Stampede.Http.Tests/Caching/AuthorizedEvictionTests.cs b/Stampede.Http.Tests/Caching/AuthorizedEvictionTests.cs new file mode 100644 index 0000000..4d3080d --- /dev/null +++ b/Stampede.Http.Tests/Caching/AuthorizedEvictionTests.cs @@ -0,0 +1,144 @@ +using Stampede.Http.Caching; +using Stampede.Http.Extensions; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Net.Http.Headers; + +namespace Stampede.Http.Tests.Caching; + +/// +/// Verifies credential-scoped eviction: +/// resolves +/// the same credential-scoped key an authenticated GET stores under when +/// is enabled — a key the plain URI overload cannot reach. +/// +public sealed class AuthorizedEvictionTests +{ + private const string Url = "https://api.test/authorized/evict"; + + private static readonly AuthenticationHeaderValue TokenA = new("Bearer", "token-a"); + private static readonly AuthenticationHeaderValue TokenB = new("Bearer", "token-b"); + + private static (ServiceProvider Provider, Func BackendCalls) BuildClient() + { + ServiceCollection services = new(); + int backendCalls = 0; + + services.AddHttpClient("catalog") + .AddStampedeHttp(o => + { + o.DefaultTtl = TimeSpan.FromMinutes(5); + o.AuthorizationCaching = AuthorizationCachingMode.Always; + }) + .ConfigurePrimaryHttpMessageHandler(() => new TestHandler(() => + { + Interlocked.Increment(ref backendCalls); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("body") }); + })); + + return (services.BuildServiceProvider(), () => backendCalls); + } + + private static async Task GetWithAuthAsync(HttpClient client, AuthenticationHeaderValue auth) + { + HttpRequestMessage request = new(HttpMethod.Get, Url); + request.Headers.Authorization = auth; + return await client.SendAsync(request, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task PlainUriEviction_CannotReachCredentialScopedEntry() + { + (ServiceProvider sp, Func backendCalls) = BuildClient(); + HttpClient client = sp.GetRequiredService().CreateClient("catalog"); + + _ = await GetWithAuthAsync(client, TokenA); + _ = await GetWithAuthAsync(client, TokenA); + backendCalls().Should().Be(1, "the authorized response is cached under its credential-scoped key"); + + await sp.GetRequiredKeyedService("catalog") + .EvictAsync(new Uri(Url), TestContext.Current.CancellationToken); + + _ = await GetWithAuthAsync(client, TokenA); + backendCalls().Should().Be(1, "the URI overload resolves the unauthenticated key and must not touch per-credential entries"); + } + + [Fact] + public async Task CredentialScopedEviction_ForcesNextAuthorizedRequestBackToOrigin() + { + (ServiceProvider sp, Func backendCalls) = BuildClient(); + HttpClient client = sp.GetRequiredService().CreateClient("catalog"); + + _ = await GetWithAuthAsync(client, TokenA); + _ = await GetWithAuthAsync(client, TokenA); + backendCalls().Should().Be(1); + + await sp.GetRequiredKeyedService("catalog") + .EvictAsync(new Uri(Url), TokenA, TestContext.Current.CancellationToken); + + _ = await GetWithAuthAsync(client, TokenA); + backendCalls().Should().Be(2, "evicting with the request's own Authorization value must reach its credential-scoped entry"); + } + + [Fact] + public async Task CredentialScopedEviction_LeavesOtherCredentialsEntriesIntact() + { + (ServiceProvider sp, Func backendCalls) = BuildClient(); + HttpClient client = sp.GetRequiredService().CreateClient("catalog"); + + _ = await GetWithAuthAsync(client, TokenA); + _ = await GetWithAuthAsync(client, TokenB); + backendCalls().Should().Be(2, "each credential gets its own independent entry"); + + await sp.GetRequiredKeyedService("catalog") + .EvictAsync(new Uri(Url), TokenA, TestContext.Current.CancellationToken); + + _ = await GetWithAuthAsync(client, TokenB); + backendCalls().Should().Be(2, "evicting credential A must not disturb credential B's entry"); + + _ = await GetWithAuthAsync(client, TokenA); + backendCalls().Should().Be(3, "credential A's entry was evicted"); + } + + [Fact] + public async Task EvictAsync_NullArguments_Throw() + { + StampedeHttpCache cache = new( + new MemoryCacheStore(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())), + new DefaultCacheKeyBuilder()); + + Func nullUri = async () => await cache.EvictAsync(null!, TokenA, TestContext.Current.CancellationToken); + Func nullAuth = async () => await cache.EvictAsync(new Uri(Url), null!, TestContext.Current.CancellationToken); + + await nullUri.Should().ThrowAsync(); + await nullAuth.Should().ThrowAsync(); + } + + [Fact] + public async Task DefaultInterfaceImplementation_ThrowsNotSupported_ForPre26Implementations() + { + // A custom IStampedeHttpCache written before 2.6 implements only the URI overload; the new + // members must keep it compiling and fail loudly (not silently no-op) when called. + IStampedeHttpCache legacy = new LegacyCache(); + + Func credentialScoped = async () => await legacy.EvictAsync(new Uri(Url), TokenA, TestContext.Current.CancellationToken); + Func byTag = async () => await legacy.EvictByTagAsync("products", TestContext.Current.CancellationToken); + + await credentialScoped.Should().ThrowAsync(); + await byTag.Should().ThrowAsync(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private sealed class LegacyCache : IStampedeHttpCache + { + public ValueTask EvictAsync(Uri uri, CancellationToken ct = default) => default; + } + + private sealed class TestHandler(Func> responseFactory) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => responseFactory(); + } +} diff --git a/Stampede.Http.Tests/Caching/CacheEntryJsonConverterTests.cs b/Stampede.Http.Tests/Caching/CacheEntryJsonConverterTests.cs index 714c9ec..048c4e9 100644 --- a/Stampede.Http.Tests/Caching/CacheEntryJsonConverterTests.cs +++ b/Stampede.Http.Tests/Caching/CacheEntryJsonConverterTests.cs @@ -41,7 +41,9 @@ public void RoundTrip_AllFields_PreservesValues() StaleWhileRevalidateSeconds = 60, MustRevalidate = true, IsVaryMarker = true, - OriginFetchDurationMs = 245 + OriginFetchDurationMs = 245, + TrackedKeys = ["GET:https://api.test/a", "GET:https://api.test/b"], + Tags = ["products", "product-42"] }; string json = JsonSerializer.Serialize(original, CacheEntryJsonContext.Default.CacheEntry); @@ -62,6 +64,8 @@ public void RoundTrip_AllFields_PreservesValues() restored.MustRevalidate.Should().Be(original.MustRevalidate); restored.IsVaryMarker.Should().Be(original.IsVaryMarker); restored.OriginFetchDurationMs.Should().Be(original.OriginFetchDurationMs); + restored.TrackedKeys.Should().Equal(original.TrackedKeys); + restored.Tags.Should().Equal(original.Tags); } // ── Optional fields default to null/default when absent ────────────────── diff --git a/Stampede.Http.Tests/Caching/CacheStatusHeaderTests.cs b/Stampede.Http.Tests/Caching/CacheStatusHeaderTests.cs new file mode 100644 index 0000000..df5d7eb --- /dev/null +++ b/Stampede.Http.Tests/Caching/CacheStatusHeaderTests.cs @@ -0,0 +1,263 @@ +using Stampede.Http.Caching; +using Stampede.Http.Coalescing; +using Stampede.Http.Extensions; +using Stampede.Http.Handlers; +using Stampede.Http.Options; +using Stampede.Http.Tests.Helpers; +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Net.Http.Headers; + +namespace Stampede.Http.Tests.Caching; + +/// +/// Verifies the synthetic X-Stampede-Cache response header (): +/// every response the caching layer handles reports how it was obtained — HIT, MISS, STALE, REVALIDATED — +/// and coalesced waiters report COALESCED, while untouched requests carry no header at all. +/// +public sealed class CacheStatusHeaderTests +{ + private static CachingMiddleware BuildPipeline( + Func handler, + CacheOptions? options = null, + TimeProvider? timeProvider = null) + { + return new CachingMiddleware( + new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions())), + new DefaultCacheKeyBuilder(), + options ?? new CacheOptions { DefaultTtl = TimeSpan.FromMinutes(5) }, + timeProvider: timeProvider) + { + InnerHandler = new StubTransport(handler) + }; + } + + [Fact] + public async Task MissThenHit_ReportsEachStatus() + { + HttpMessageInvoker invoker = new(BuildPipeline( + _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("body") })); + + HttpResponseMessage first = await invoker.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "https://api.test/status/basic"), TestContext.Current.CancellationToken); + HttpResponseMessage second = await invoker.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "https://api.test/status/basic"), TestContext.Current.CancellationToken); + + StampedeCacheStatus.GetStatus(first).Should().Be(StampedeCacheStatus.Miss); + StampedeCacheStatus.GetStatus(second).Should().Be(StampedeCacheStatus.Hit); + } + + [Fact] + public async Task Revalidation304_ReportsRevalidated() + { + FakeTimeProvider clock = new(); + int calls = 0; + + HttpMessageInvoker invoker = new(BuildPipeline(req => + { + calls++; + + if (req.Headers.IfNoneMatch.Count > 0) + { + return new HttpResponseMessage(HttpStatusCode.NotModified); + } + + HttpResponseMessage r = new(HttpStatusCode.OK) { Content = new StringContent("body") }; + r.Headers.ETag = new EntityTagHeaderValue("\"v1\""); + r.Headers.CacheControl = new CacheControlHeaderValue { MaxAge = TimeSpan.FromSeconds(30) }; + return r; + }, timeProvider: clock)); + + _ = await invoker.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "https://api.test/status/reval"), TestContext.Current.CancellationToken); + + clock.Advance(TimeSpan.FromSeconds(60)); // past max-age, within revalidation grace + + HttpResponseMessage revalidated = await invoker.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "https://api.test/status/reval"), TestContext.Current.CancellationToken); + + calls.Should().Be(2, "the second request must be a conditional revalidation"); + StampedeCacheStatus.GetStatus(revalidated).Should().Be(StampedeCacheStatus.Revalidated); + } + + [Fact] + public async Task StaleWhileRevalidate_ReportsStale() + { + FakeTimeProvider clock = new(); + + HttpMessageInvoker invoker = new(BuildPipeline(_ => + { + HttpResponseMessage r = new(HttpStatusCode.OK) { Content = new StringContent("body") }; + r.Headers.TryAddWithoutValidation("Cache-Control", "max-age=10, stale-while-revalidate=120"); + return r; + }, timeProvider: clock)); + + _ = await invoker.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "https://api.test/status/swr"), TestContext.Current.CancellationToken); + + clock.Advance(TimeSpan.FromSeconds(30)); // expired, inside the stale-while-revalidate window + + HttpResponseMessage stale = await invoker.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "https://api.test/status/swr"), TestContext.Current.CancellationToken); + + StampedeCacheStatus.GetStatus(stale).Should().Be(StampedeCacheStatus.Stale); + } + + [Fact] + public async Task StaleIfError_ReportsStale() + { + FakeTimeProvider clock = new(); + bool failNow = false; + + HttpMessageInvoker invoker = new(BuildPipeline(_ => + { + if (failNow) + { + return new HttpResponseMessage(HttpStatusCode.InternalServerError); + } + + HttpResponseMessage r = new(HttpStatusCode.OK) { Content = new StringContent("body") }; + r.Headers.TryAddWithoutValidation("Cache-Control", "max-age=10, stale-if-error=120"); + return r; + }, timeProvider: clock)); + + _ = await invoker.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "https://api.test/status/sie"), TestContext.Current.CancellationToken); + + clock.Advance(TimeSpan.FromSeconds(30)); + failNow = true; + + HttpResponseMessage stale = await invoker.SendAsync( + new HttpRequestMessage(HttpMethod.Get, "https://api.test/status/sie"), TestContext.Current.CancellationToken); + + stale.StatusCode.Should().Be(HttpStatusCode.OK, "the stale entry is served instead of the 500"); + StampedeCacheStatus.GetStatus(stale).Should().Be(StampedeCacheStatus.Stale); + } + + [Fact] + public async Task NonCacheableMethod_CarriesNoStatusHeader() + { + HttpMessageInvoker invoker = new(BuildPipeline( + _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("done") })); + + HttpResponseMessage post = await invoker.SendAsync( + new HttpRequestMessage(HttpMethod.Post, "https://api.test/status/post"), TestContext.Current.CancellationToken); + + StampedeCacheStatus.GetStatus(post).Should().BeNull("the caching layer does not handle unsafe methods"); + } + + [Fact] + public async Task BypassCache_CarriesNoStatusHeader() + { + HttpMessageInvoker invoker = new(BuildPipeline( + _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("body") })); + + HttpRequestMessage request = new(HttpMethod.Get, "https://api.test/status/bypass"); + request.Options.Set(CacheRequestPolicy.BypassCache, true); + + HttpResponseMessage response = await invoker.SendAsync(request, TestContext.Current.CancellationToken); + + StampedeCacheStatus.GetStatus(response).Should().BeNull("BypassCache skips the caching layer entirely"); + } + + [Fact] + public async Task CoalescedWaiter_ReportsCoalesced_WinnerDoesNot() + { + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + int originCalls = 0; + + CoalescingHandler handler = new(new RequestCoalescer(new CoalescerOptions())) + { + InnerHandler = new AsyncStubTransport(async () => + { + Interlocked.Increment(ref originCalls); + await release.Task; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("body") }; + }) + }; + + HttpMessageInvoker invoker = new(handler); + const string url = "https://api.test/status/coalesced"; + + Task first = invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, url), TestContext.Current.CancellationToken); + Task second = invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, url), TestContext.Current.CancellationToken); + + // Wait until one caller owns the in-flight slot, then let the origin answer. + while (Volatile.Read(ref originCalls) == 0) + { + await Task.Yield(); + } + + release.SetResult(); + HttpResponseMessage[] responses = await Task.WhenAll(first, second); + + originCalls.Should().Be(1, "both callers share one origin call"); + responses.Count(r => StampedeCacheStatus.GetStatus(r) == StampedeCacheStatus.Coalesced) + .Should().Be(1, "exactly the waiter reports COALESCED — the winner performed the origin call itself"); + } + + [Fact] + public async Task FullPipeline_CoalescedIsNotOverwrittenByMiss_AndLaterHitsReportHit() + { + ServiceCollection services = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + int originCalls = 0; + + services.AddHttpClient("catalog") + .AddStampedeHttp(o => o.DefaultTtl = TimeSpan.FromMinutes(5)) + .ConfigurePrimaryHttpMessageHandler(() => new AsyncStubTransport(async () => + { + Interlocked.Increment(ref originCalls); + await release.Task; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("body") }; + })); + + ServiceProvider sp = services.BuildServiceProvider(); + HttpClient client = sp.GetRequiredService().CreateClient("catalog"); + const string url = "https://api.test/status/pipeline"; + + Task first = client.GetAsync(url, TestContext.Current.CancellationToken); + Task second = client.GetAsync(url, TestContext.Current.CancellationToken); + + while (Volatile.Read(ref originCalls) == 0) + { + await Task.Yield(); + } + + release.SetResult(); + HttpResponseMessage[] responses = await Task.WhenAll(first, second); + + originCalls.Should().Be(1); + string?[] statuses = [.. responses.Select(StampedeCacheStatus.GetStatus)]; + statuses.Should().Contain(StampedeCacheStatus.Miss, "the winner's response is an origin fetch"); + statuses.Should().Contain(StampedeCacheStatus.Coalesced, "the waiter's COALESCED must survive the caching layer's miss marking"); + + HttpResponseMessage third = await client.GetAsync(url, TestContext.Current.CancellationToken); + StampedeCacheStatus.GetStatus(third).Should().Be(StampedeCacheStatus.Hit, + "a stored entry must report its own status when replayed, never the COALESCED/MISS of the response that populated it"); + } + + [Fact] + public void GetStatus_AbsentHeader_ReturnsNull() + { + using HttpResponseMessage response = new(HttpStatusCode.OK); + + StampedeCacheStatus.GetStatus(response).Should().BeNull(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private sealed class StubTransport(Func handler) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => Task.FromResult(handler(request)); + } + + private sealed class AsyncStubTransport(Func> handler) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => handler(); + } +} diff --git a/Stampede.Http.Tests/Caching/TagInvalidationTests.cs b/Stampede.Http.Tests/Caching/TagInvalidationTests.cs new file mode 100644 index 0000000..df6e9a0 --- /dev/null +++ b/Stampede.Http.Tests/Caching/TagInvalidationTests.cs @@ -0,0 +1,256 @@ +using Stampede.Http.Caching; +using FluentAssertions; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using System.Net; + +namespace Stampede.Http.Tests.Caching; + +/// +/// Verifies tag-based invalidation: tags collected from the response headers named in +/// (or attached per request via +/// ) index stored entries so +/// can invalidate a whole group of URIs in one call. +/// +public sealed class TagInvalidationTests +{ + private static CachingMiddleware BuildPipeline( + ICacheStore store, + Func handler, + CacheOptions options) + { + return new CachingMiddleware(store, new DefaultCacheKeyBuilder(), options) + { + InnerHandler = new StubTransport(handler) + }; + } + + private static CacheOptions OptionsWithTagHeader(params string[] headerNames) => new() + { + DefaultTtl = TimeSpan.FromMinutes(5), + TagHeaderNames = headerNames + }; + + private static HttpResponseMessage TaggedResponse(string body, string headerName, string headerValue) + { + HttpResponseMessage r = new(HttpStatusCode.OK) { Content = new StringContent(body) }; + r.Headers.TryAddWithoutValidation(headerName, headerValue); + return r; + } + + [Fact] + public async Task EvictByTag_EvictsEveryTaggedUri_AndLeavesOthersCached() + { + int callCount = 0; + MemoryCacheStore store = new(new MemoryCache(new MemoryCacheOptions())); + HttpMessageInvoker invoker = new(BuildPipeline(store, req => + { + callCount++; + string tag = req.RequestUri!.AbsolutePath.Contains("other") ? "misc" : "products"; + return TaggedResponse(req.RequestUri.AbsolutePath, "Cache-Tag", tag); + }, OptionsWithTagHeader("Cache-Tag"))); + + Uri productA = new("https://api.test/products/1"); + Uri productB = new("https://api.test/products/2"); + Uri other = new("https://api.test/other"); + + foreach (Uri uri in new[] { productA, productB, other, productA, productB, other }) + { + _ = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), TestContext.Current.CancellationToken); + } + + callCount.Should().Be(3, "the second round is served entirely from cache"); + + await new StampedeHttpCache(store, new DefaultCacheKeyBuilder()) + .EvictByTagAsync("products", TestContext.Current.CancellationToken); + + foreach (Uri uri in new[] { productA, productB, other }) + { + _ = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), TestContext.Current.CancellationToken); + } + + callCount.Should().Be(5, "both 'products' entries must be refetched while the 'misc'-tagged entry stays cached"); + } + + [Fact] + public async Task EvictByTag_SpaceSeparatedSurrogateKeys_EachTagIsIndexed() + { + int callCount = 0; + MemoryCacheStore store = new(new MemoryCache(new MemoryCacheOptions())); + HttpMessageInvoker invoker = new(BuildPipeline(store, + _ => { callCount++; return TaggedResponse("body", "Surrogate-Key", "products featured"); }, + OptionsWithTagHeader("Surrogate-Key"))); + + Uri uri = new("https://api.test/surrogate"); + _ = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), TestContext.Current.CancellationToken); + callCount.Should().Be(1); + + // Fastly-style Surrogate-Key values are space-separated: each token is its own tag. + await new StampedeHttpCache(store, new DefaultCacheKeyBuilder()) + .EvictByTagAsync("featured", TestContext.Current.CancellationToken); + + _ = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), TestContext.Current.CancellationToken); + callCount.Should().Be(2, "evicting either space-separated tag must reach the entry"); + } + + [Fact] + public async Task RequestPolicyTags_IndexWithoutAnyTagHeaderConfigured() + { + int callCount = 0; + MemoryCacheStore store = new(new MemoryCache(new MemoryCacheOptions())); + HttpMessageInvoker invoker = new(BuildPipeline(store, + _ => { callCount++; return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("body") }; }, + new CacheOptions { DefaultTtl = TimeSpan.FromMinutes(5) })); + + Uri uri = new("https://api.test/request-tags"); + + HttpRequestMessage first = new(HttpMethod.Get, uri); + first.Options.Set(CacheRequestPolicy.Tags, ["client-group"]); + _ = await invoker.SendAsync(first, TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), TestContext.Current.CancellationToken); + callCount.Should().Be(1); + + await new StampedeHttpCache(store, new DefaultCacheKeyBuilder()) + .EvictByTagAsync("client-group", TestContext.Current.CancellationToken); + + _ = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), TestContext.Current.CancellationToken); + callCount.Should().Be(2, "per-request tags must be honored even with TagHeaderNames unset"); + } + + [Fact] + public async Task EvictByTag_UnknownTag_IsANoOp() + { + StampedeHttpCache cache = new( + new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions())), + new DefaultCacheKeyBuilder()); + + Func act = async () => await cache.EvictByTagAsync("never-used", TestContext.Current.CancellationToken); + + await act.Should().NotThrowAsync("evicting a tag nothing carries is a no-op, not an error"); + } + + [Fact] + public async Task EvictByTag_NullOrWhitespaceTag_Throws() + { + StampedeHttpCache cache = new( + new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions())), + new DefaultCacheKeyBuilder()); + + Func nullTag = async () => await cache.EvictByTagAsync(null!, TestContext.Current.CancellationToken); + Func blankTag = async () => await cache.EvictByTagAsync(" ", TestContext.Current.CancellationToken); + + await nullTag.Should().ThrowAsync(); + await blankTag.Should().ThrowAsync(); + } + + [Fact] + public async Task EvictByTag_VaryingEntry_SweepsAllVariants() + { + int callCount = 0; + MemoryCacheStore store = new(new MemoryCache(new MemoryCacheOptions())); + HttpMessageInvoker invoker = new(BuildPipeline(store, req => + { + callCount++; + string lang = req.Headers.TryGetValues("Accept-Language", out IEnumerable? v) ? string.Join(",", v) : "none"; + HttpResponseMessage r = TaggedResponse($"lang={lang}", "Cache-Tag", "products"); + r.Headers.Vary.Add("Accept-Language"); + return r; + }, OptionsWithTagHeader("Cache-Tag"))); + + Uri uri = new("https://api.test/tagged-vary"); + + HttpRequestMessage Localized(string lang) + { + HttpRequestMessage req = new(HttpMethod.Get, uri); + req.Headers.TryAddWithoutValidation("Accept-Language", lang); + return req; + } + + _ = await invoker.SendAsync(Localized("en"), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(Localized("es"), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(Localized("en"), TestContext.Current.CancellationToken); + callCount.Should().Be(2, "both language variants are cached"); + + await new StampedeHttpCache(store, new DefaultCacheKeyBuilder()) + .EvictByTagAsync("products", TestContext.Current.CancellationToken); + + _ = await invoker.SendAsync(Localized("en"), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(Localized("es"), TestContext.Current.CancellationToken); + callCount.Should().Be(4, "tag eviction must sweep every variant of the tagged URI, not just its marker"); + } + + [Fact] + public async Task EvictByTag_WorksAcrossDistributedStoreSerialization() + { + int callCount = 0; + DistributedCacheStore store = new( + new MemoryDistributedCache(Microsoft.Extensions.Options.Options.Create(new MemoryDistributedCacheOptions())), + new CacheOptions()); + + HttpMessageInvoker invoker = new(BuildPipeline(store, + _ => { callCount++; return TaggedResponse("body", "Cache-Tag", "products"); }, + OptionsWithTagHeader("Cache-Tag"))); + + Uri uri = new("https://api.test/distributed-tags"); + _ = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), TestContext.Current.CancellationToken); + callCount.Should().Be(1); + + await new StampedeHttpCache(store, new DefaultCacheKeyBuilder()) + .EvictByTagAsync("products", TestContext.Current.CancellationToken); + + _ = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), TestContext.Current.CancellationToken); + callCount.Should().Be(2, "the tag index (CacheEntry.TrackedKeys) must survive the JSON round-trip of the distributed store"); + } + + [Fact] + public async Task Revalidation304_ExtendsTheTagIndexDeadline_AlongWithTheEntry() + { + Helpers.FakeTimeProvider clock = new(); + MemoryCacheStore store = new(new MemoryCache(new MemoryCacheOptions())); + + CachingMiddleware middleware = new(store, new DefaultCacheKeyBuilder(), + OptionsWithTagHeader("Cache-Tag"), timeProvider: clock) + { + InnerHandler = new StubTransport(req => + { + if (req.Headers.IfNoneMatch.Count > 0) + { + return new HttpResponseMessage(HttpStatusCode.NotModified); + } + + HttpResponseMessage r = TaggedResponse("body", "Cache-Tag", "products"); + r.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"v1\""); + r.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { MaxAge = TimeSpan.FromSeconds(30) }; + return r; + }) + }; + + HttpMessageInvoker invoker = new(middleware); + Uri uri = new("https://api.test/reval-extends-index"); + + _ = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), TestContext.Current.CancellationToken); + + store.TryGetValue(CacheIndexing.BuildTagKey("products"), out CacheEntry? initialIndex).Should().BeTrue(); + DateTimeOffset initialDeadline = initialIndex!.ExpiresAt; + + // A 304 refresh restarts the entry's retention from the revalidation time; the tag index must + // follow, or an entry that keeps revalidating would eventually outlive its index and become + // unreachable by EvictByTagAsync. + clock.Advance(TimeSpan.FromSeconds(120)); + _ = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), TestContext.Current.CancellationToken); + + store.TryGetValue(CacheIndexing.BuildTagKey("products"), out CacheEntry? refreshedIndex).Should().BeTrue(); + refreshedIndex!.ExpiresAt.Should().BeAfter(initialDeadline, + "the 304 refresh must push the tag index deadline out to the entry's new retention deadline"); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private sealed class StubTransport(Func handler) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => Task.FromResult(handler(request)); + } +} diff --git a/Stampede.Http.Tests/Caching/VaryVariantEvictionTests.cs b/Stampede.Http.Tests/Caching/VaryVariantEvictionTests.cs new file mode 100644 index 0000000..1ee52d9 --- /dev/null +++ b/Stampede.Http.Tests/Caching/VaryVariantEvictionTests.cs @@ -0,0 +1,156 @@ +using Stampede.Http.Caching; +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using System.Net; + +namespace Stampede.Http.Tests.Caching; + +/// +/// Verifies that explicit eviction of a varying resource sweeps the secondary-key variants tracked by its +/// Vary marker (), instead of only removing the marker and leaving the +/// variants unreachable-but-alive until their own retention elapses — the pre-2.6 trade-off. +/// +public sealed class VaryVariantEvictionTests +{ + private const string Url = "https://api.test/vary/evict"; + + private static CachingMiddleware BuildPipeline( + ICacheStore store, + Func handler, + CacheOptions? options = null) + { + return new CachingMiddleware(store, new DefaultCacheKeyBuilder(), + options ?? new CacheOptions { DefaultTtl = TimeSpan.FromMinutes(5) }) + { + InnerHandler = new StubTransport(handler) + }; + } + + private static HttpRequestMessage Req(string url, string acceptLanguage) + { + HttpRequestMessage req = new(HttpMethod.Get, url); + req.Headers.TryAddWithoutValidation("Accept-Language", acceptLanguage); + return req; + } + + private static HttpResponseMessage VaryingByLanguage(HttpRequestMessage request) + { + string lang = request.Headers.TryGetValues("Accept-Language", out IEnumerable? v) + ? string.Join(",", v) + : "none"; + + HttpResponseMessage r = new(HttpStatusCode.OK) { Content = new StringContent($"lang={lang}") }; + r.Headers.Vary.Add("Accept-Language"); + return r; + } + + [Fact] + public async Task Marker_TracksEveryStoredVariantKey() + { + RecordingCacheStore store = new(new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions()))); + HttpMessageInvoker invoker = new(BuildPipeline(store, VaryingByLanguage)); + + _ = await invoker.SendAsync(Req(Url, "en"), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(Req(Url, "es"), TestContext.Current.CancellationToken); + + string primaryKey = new DefaultCacheKeyBuilder().Build(new HttpRequestMessage(HttpMethod.Get, Url)); + store.TryGetValue(primaryKey, out CacheEntry? marker).Should().BeTrue(); + + marker!.IsVaryMarker.Should().BeTrue(); + marker.TrackedKeys.Should().HaveCount(2, "the marker must track one secondary key per stored variant"); + marker.TrackedKeys.Should().OnlyHaveUniqueItems(); + } + + [Fact] + public async Task Marker_ReStoringSameVariant_DoesNotDuplicateItsKey() + { + RecordingCacheStore store = new(new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions()))); + + // max-age=0 marks entries immediately stale, so every request re-stores the same variant. + HttpMessageInvoker invoker = new(BuildPipeline(store, req => + { + HttpResponseMessage r = VaryingByLanguage(req); + r.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { MaxAge = TimeSpan.Zero }; + r.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"v1\""); + return r; + })); + + _ = await invoker.SendAsync(Req(Url, "en"), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(Req(Url, "en"), TestContext.Current.CancellationToken); + + string primaryKey = new DefaultCacheKeyBuilder().Build(new HttpRequestMessage(HttpMethod.Get, Url)); + store.TryGetValue(primaryKey, out CacheEntry? marker).Should().BeTrue(); + + marker!.TrackedKeys.Should().HaveCount(1, "re-storing an existing variant must merge, not append"); + } + + [Fact] + public async Task EvictAsync_RemovesMarkerAndEveryTrackedVariant_FromTheStore() + { + RecordingCacheStore store = new(new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions()))); + DefaultCacheKeyBuilder keyBuilder = new(); + HttpMessageInvoker invoker = new(BuildPipeline(store, VaryingByLanguage)); + + _ = await invoker.SendAsync(Req(Url, "en"), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(Req(Url, "es"), TestContext.Current.CancellationToken); + store.LiveKeys.Should().HaveCount(3, "two variants plus the primary-key marker are stored"); + + await new StampedeHttpCache(store, keyBuilder).EvictAsync(new Uri(Url), TestContext.Current.CancellationToken); + + store.LiveKeys.Should().BeEmpty( + "eviction must sweep the tracked variants too, not just the marker — a bare marker removal leaves them unreachable-but-alive"); + } + + [Fact] + public async Task EvictAsync_VaryingEntry_NextRequestsRefetchEveryVariant() + { + int callCount = 0; + MemoryCacheStore store = new(new MemoryCache(new MemoryCacheOptions())); + HttpMessageInvoker invoker = new(BuildPipeline(store, req => { callCount++; return VaryingByLanguage(req); })); + + _ = await invoker.SendAsync(Req(Url, "en"), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(Req(Url, "es"), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(Req(Url, "en"), TestContext.Current.CancellationToken); + callCount.Should().Be(2, "both variants are cached before eviction"); + + await new StampedeHttpCache(store, new DefaultCacheKeyBuilder()).EvictAsync(new Uri(Url), TestContext.Current.CancellationToken); + + _ = await invoker.SendAsync(Req(Url, "en"), TestContext.Current.CancellationToken); + _ = await invoker.SendAsync(Req(Url, "es"), TestContext.Current.CancellationToken); + callCount.Should().Be(4, "after eviction both variants must be fetched from the origin again"); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// + /// Wraps a real store and tracks which keys currently hold an entry, so tests can assert that an + /// eviction physically removed every stored key — something the middleware's behavior alone cannot + /// distinguish from variants merely becoming unreachable. + /// + private sealed class RecordingCacheStore(ICacheStore inner) : ICacheStore + { + private readonly HashSet _liveKeys = []; + + public IReadOnlyCollection LiveKeys => _liveKeys; + + public bool TryGetValue(string key, out CacheEntry? entry) => inner.TryGetValue(key, out entry); + + public void Set(string key, CacheEntry entry) + { + inner.Set(key, entry); + _ = _liveKeys.Add(key); + } + + public void Remove(string key) + { + inner.Remove(key); + _ = _liveKeys.Remove(key); + } + } + + private sealed class StubTransport(Func handler) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => Task.FromResult(handler(request)); + } +} diff --git a/Stampede.Http/Caching/CacheEntry.cs b/Stampede.Http/Caching/CacheEntry.cs index aa7373f..11182bb 100644 --- a/Stampede.Http/Caching/CacheEntry.cs +++ b/Stampede.Http/Caching/CacheEntry.cs @@ -71,6 +71,28 @@ public sealed record CacheEntry /// public bool IsVaryMarker { get; init; } + /// + /// Cache keys this entry tracks when it acts as an index rather than (only) a stored response: + /// on a Vary marker (), the secondary-key variants stored under this + /// primary key, so explicit eviction can sweep them; on a tag index entry, the primary keys of the + /// responses carrying that tag. Empty on ordinary representations, and on entries written before 2.6. + /// + /// + /// Best-effort by design: concurrent writers merging this list can lose a key, and the list is capped + /// (see CacheIndexing.MaxTrackedKeys) — an untracked key is never served incorrectly, it just + /// expires on its own retention schedule instead of being actively removed on eviction. + /// + public string[] TrackedKeys { get; init; } = []; + + /// + /// Cache tags this response was stored under, collected from the response headers named in + /// and from . + /// Carried on the entry so every 304 refresh can re-extend the per-tag indexes to the entry's + /// new retention deadline — without this, a repeatedly revalidated entry would outlive its index and + /// silently stop being reachable by tag eviction. Empty when untagged, and on pre-2.6 entries. + /// + public string[] Tags { get; init; } = []; + /// /// Determines whether the cache entry has expired based on its expiration time. /// diff --git a/Stampede.Http/Caching/CacheEntryJsonConverter.cs b/Stampede.Http/Caching/CacheEntryJsonConverter.cs index d1a28e8..8f3c09b 100644 --- a/Stampede.Http/Caching/CacheEntryJsonConverter.cs +++ b/Stampede.Http/Caching/CacheEntryJsonConverter.cs @@ -28,6 +28,8 @@ public override CacheEntry Read(ref Utf8JsonReader reader, Type typeToConvert, J bool immutable = false; bool isVaryMarker = false; long originFetchDurationMs = 0; + string[] trackedKeys = []; + string[] tags = []; if (reader.TokenType != JsonTokenType.StartObject) { @@ -96,6 +98,14 @@ public override CacheEntry Read(ref Utf8JsonReader reader, Type typeToConvert, J case nameof(CacheEntry.OriginFetchDurationMs): originFetchDurationMs = reader.GetInt64(); break; + case nameof(CacheEntry.TrackedKeys): + trackedKeys = JsonSerializer.Deserialize(ref reader, + CacheEntryJsonContext.Default.StringArray) ?? []; + break; + case nameof(CacheEntry.Tags): + tags = JsonSerializer.Deserialize(ref reader, + CacheEntryJsonContext.Default.StringArray) ?? []; + break; default: reader.Skip(); break; @@ -120,7 +130,9 @@ public override CacheEntry Read(ref Utf8JsonReader reader, Type typeToConvert, J MustRevalidate = mustRevalidate, Immutable = immutable, IsVaryMarker = isVaryMarker, - OriginFetchDurationMs = originFetchDurationMs + OriginFetchDurationMs = originFetchDurationMs, + TrackedKeys = trackedKeys, + Tags = tags }; } @@ -166,6 +178,12 @@ public override void Write(Utf8JsonWriter writer, CacheEntry value, JsonSerializ writer.WriteBoolean(nameof(CacheEntry.Immutable), value.Immutable); writer.WriteBoolean(nameof(CacheEntry.IsVaryMarker), value.IsVaryMarker); writer.WriteNumber(nameof(CacheEntry.OriginFetchDurationMs), value.OriginFetchDurationMs); + writer.WritePropertyName(nameof(CacheEntry.TrackedKeys)); + JsonSerializer.Serialize(writer, value.TrackedKeys, + CacheEntryJsonContext.Default.StringArray); + writer.WritePropertyName(nameof(CacheEntry.Tags)); + JsonSerializer.Serialize(writer, value.Tags, + CacheEntryJsonContext.Default.StringArray); writer.WriteEndObject(); } diff --git a/Stampede.Http/Caching/CacheIndexing.cs b/Stampede.Http/Caching/CacheIndexing.cs new file mode 100644 index 0000000..caf6140 --- /dev/null +++ b/Stampede.Http/Caching/CacheIndexing.cs @@ -0,0 +1,77 @@ +namespace Stampede.Http.Caching; + +/// +/// Shared helpers for the two key indexes the cache maintains inside its own : +/// the variant keys tracked on a Vary marker (), and the per-tag +/// index entries that map a cache tag to the primary keys carrying it. Kept in one place so the write +/// side () and the eviction side () stay +/// in sync on key format, capacity, and sweep semantics. +/// +/// +/// Both indexes are best-effort by design: they are read-merge-write over a store with no compare-and-swap, +/// so two concurrent writers can each miss the other's addition. A key that falls out of an index is never +/// served incorrectly — it just expires on its own retention schedule instead of being actively removed +/// when the index is swept. +/// +internal static class CacheIndexing +{ + /// + /// Upper bound on . Beyond it, new keys are simply not tracked + /// (they still expire on their own), keeping a high-cardinality Vary header or a very broad tag from + /// growing an index entry without limit — every store of a tracked key rewrites the whole list. + /// + internal const int MaxTrackedKeys = 1024; + + /// + /// U+001F (unit separator) frames tag index keys. Request-derived cache keys always start with an + /// HTTP method name, and this control character cannot appear in a URI or header value, so a key + /// starting with it can never collide with one built by . + /// + private const char TagKeySeparator = (char)0x1f; + + /// Builds the store key of the index entry for (compared ordinally, case-sensitive). + public static string BuildTagKey(string tag) => $"{TagKeySeparator}tag{TagKeySeparator}{tag}"; + + /// + /// Returns with appended, or + /// itself when the key is already present or the list is at . + /// + public static string[] MergeTrackedKey(string[] existing, string key) + { + if (existing.Length >= MaxTrackedKeys || Array.IndexOf(existing, key) >= 0) + { + return existing; + } + + string[] merged = new string[existing.Length + 1]; + existing.CopyTo(merged, 0); + merged[^1] = key; + return merged; + } + + /// + /// Removes the entry at and, when it is a Vary marker, every + /// secondary-key variant it tracks — so an explicit eviction sweeps the whole representation set + /// instead of leaving variants unreachable-but-alive until their own retention elapses. + /// + /// + /// Costs one read to discover whether the key holds a marker. RFC 9111 §4.4 invalidation deliberately + /// does not take this path: it runs on every successful unsafe request, where the extra + /// round-trip against a distributed store isn't worth reclaiming storage that a removed marker has + /// already made unreachable. Explicit eviction is rare and user-initiated, so it takes the full sweep. + /// + public static async ValueTask EvictWithVariantsAsync(ICacheStore cache, string primaryKey, CancellationToken ct) + { + CacheEntry? entry = await cache.GetAsync(primaryKey, ct).ConfigureAwait(false); + + if (entry is { IsVaryMarker: true }) + { + foreach (string variantKey in entry.TrackedKeys) + { + await cache.RemoveAsync(variantKey, ct).ConfigureAwait(false); + } + } + + await cache.RemoveAsync(primaryKey, ct).ConfigureAwait(false); + } +} diff --git a/Stampede.Http/Caching/CacheKeyHelpers.cs b/Stampede.Http/Caching/CacheKeyHelpers.cs index 44a3c48..35cf306 100644 --- a/Stampede.Http/Caching/CacheKeyHelpers.cs +++ b/Stampede.Http/Caching/CacheKeyHelpers.cs @@ -21,4 +21,16 @@ public static string BuildGetKey(ICacheKeyBuilder keyBuilder, Uri? uri) using HttpRequestMessage synthetic = new(HttpMethod.Get, uri); return keyBuilder.Build(synthetic); } + + /// + /// Builds the cache key for a synthetic GET request to carrying + /// — the credential-scoped key an authenticated GET resolves to when + /// is enabled. + /// + public static string BuildGetKey(ICacheKeyBuilder keyBuilder, Uri? uri, System.Net.Http.Headers.AuthenticationHeaderValue authorization) + { + using HttpRequestMessage synthetic = new(HttpMethod.Get, uri); + synthetic.Headers.Authorization = authorization; + return keyBuilder.Build(synthetic); + } } diff --git a/Stampede.Http/Caching/CacheOptions.cs b/Stampede.Http/Caching/CacheOptions.cs index dd89143..5486487 100644 --- a/Stampede.Http/Caching/CacheOptions.cs +++ b/Stampede.Http/Caching/CacheOptions.cs @@ -234,6 +234,31 @@ public TimeSpan MaxHeuristicFreshness /// public bool EnableEarlyRevalidation { get; set; } + /// + /// Gets or sets the response header names scanned for cache tags — e.g. Cache-Tag (Cloudflare), + /// Surrogate-Key (Fastly), xkey (Varnish). Header values are split on commas and + /// whitespace, so both delimiter conventions work. Default is empty — no response-header tags are + /// collected. + /// + /// + /// Each tag found on a stored response is indexed against that response's cache key, so + /// can later invalidate every tagged entry in one + /// call. Tags are compared ordinally (case-sensitive). Independent of this option, per-request tags + /// can always be attached via . + /// + /// Thrown when the value is . + public IReadOnlyList TagHeaderNames + { + get => _tagHeaderNames; + set + { + ArgumentNullException.ThrowIfNull(value); + _tagHeaderNames = value; + } + } + + private IReadOnlyList _tagHeaderNames = []; + private double _earlyRevalidationBeta = 1.0; /// diff --git a/Stampede.Http/Caching/CacheRequestPolicy.cs b/Stampede.Http/Caching/CacheRequestPolicy.cs index daf6794..dbdad31 100644 --- a/Stampede.Http/Caching/CacheRequestPolicy.cs +++ b/Stampede.Http/Caching/CacheRequestPolicy.cs @@ -34,4 +34,13 @@ public static class CacheRequestPolicy /// Cache reads, conditional revalidation, and 304 TTL refreshes still function normally. /// public static readonly HttpRequestOptionsKey NoStore = new("Stampede.Http.NoStore"); + + /// + /// Cache tags to index the stored response under, in addition to any collected from the response + /// headers named in . Tagged entries can later be + /// invalidated as a group via . Tags are compared + /// ordinally (case-sensitive); entries that are not stored (non-cacheable responses, ) + /// are never indexed. + /// + public static readonly HttpRequestOptionsKey Tags = new("Stampede.Http.Tags"); } diff --git a/Stampede.Http/Caching/CachingMiddleware.cs b/Stampede.Http/Caching/CachingMiddleware.cs index bc4fa6b..edd2816 100644 --- a/Stampede.Http/Caching/CachingMiddleware.cs +++ b/Stampede.Http/Caching/CachingMiddleware.cs @@ -362,10 +362,118 @@ private async Task StoreAsync(string key, HttpRequestMessage request, HttpRespon StaleWhileRevalidateSeconds = staleWhileRevalidate, MustRevalidate = cc?.MustRevalidate == true || cc?.ProxyRevalidate == true, Immutable = IsImmutableEntry(cc), - OriginFetchDurationMs = Math.Max(0L, (long)fetchDuration.TotalMilliseconds) + OriginFetchDurationMs = Math.Max(0L, (long)fetchDuration.TotalMilliseconds), + Tags = CollectTags(request, response) }; + await StoreRepresentationAsync(key, entry, ct).ConfigureAwait(false); + } + + /// + /// Writes a representation and, when it carries tags, refreshes its per-tag indexes. Used for both + /// initial stores and 304 refreshes: a refresh extends the entry's retention, so the indexes + /// must be re-extended along with it or a repeatedly revalidated entry would outlive them and stop + /// being reachable by . + /// + private async ValueTask StoreRepresentationAsync(string key, CacheEntry entry, CancellationToken ct) + { await WriteEntryAsync(key, entry, ct).ConfigureAwait(false); + + if (entry.Tags.Length > 0) + { + await UpdateTagIndexesAsync(entry.Tags, key, entry, ct).ConfigureAwait(false); + } + } + + /// Delimiters accepted inside a tag header value: commas (Cloudflare's Cache-Tag) and whitespace (Fastly's Surrogate-Key). + private static readonly char[] _tagDelimiters = [',', ' ', '\t']; + + /// + /// Collects the cache tags for a response being stored: per-request tags from + /// plus tags parsed from the response headers named in + /// . Deduplicated, ordinal, order-preserving. + /// + private string[] CollectTags(HttpRequestMessage request, HttpResponseMessage response) + { + List? tags = null; + + if (request.Options.TryGetValue(CacheRequestPolicy.Tags, out string[]? requestTags) && requestTags is not null) + { + foreach (string tag in requestTags) + { + AddTag(ref tags, tag); + } + } + + IReadOnlyList headerNames = Options.TagHeaderNames; + + for (int i = 0; i < headerNames.Count; i++) + { + if (!response.Headers.TryGetValues(headerNames[i], out IEnumerable? values)) + { + continue; + } + + foreach (string value in values) + { + foreach (string tag in value.Split(_tagDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + AddTag(ref tags, tag); + } + } + } + + return tags is null ? [] : [.. tags]; + } + + private static void AddTag(ref List? tags, string tag) + { + if (string.IsNullOrWhiteSpace(tag)) + { + return; + } + + tags ??= []; + + if (!tags.Contains(tag)) + { + tags.Add(tag); + } + } + + /// + /// Indexes the stored response's primary key under each of its tags, so + /// can later invalidate every tagged entry in one + /// call. Each index entry's expiry is pushed out to cover the longest-retained entry it tracks, and + /// the merge is best-effort read-merge-write (see ). + /// + private async ValueTask UpdateTagIndexesAsync(string[] tags, string primaryKey, CacheEntry entry, CancellationToken ct) + { + // The index must outlive the entries it tracks, including their stale windows and revalidation + // grace — the same retention deadline the backing store itself applies to the entry. + DateTimeOffset deadline = entry.StoredAt + MemoryCacheStore.ComputeRetention(entry, Options.RevalidationGraceSeconds); + + foreach (string tag in tags) + { + string tagKey = CacheIndexing.BuildTagKey(tag); + CacheEntry? existing = await cache.GetAsync(tagKey, ct).ConfigureAwait(false); + + string[] trackedKeys = CacheIndexing.MergeTrackedKey(existing?.TrackedKeys ?? [], primaryKey); + DateTimeOffset expiresAt = existing is not null && existing.ExpiresAt > deadline ? existing.ExpiresAt : deadline; + + CacheEntry index = new() + { + StatusCode = 0, + Body = [], + Headers = new Dictionary(), + StoredAt = entry.StoredAt, + ExpiresAt = expiresAt, + TrackedKeys = trackedKeys + }; + + await cache.SetAsync(tagKey, index, ct).ConfigureAwait(false); + LogTagIndexed(primaryKey, tag); + } } /// @@ -398,7 +506,15 @@ private async ValueTask WriteEntryAsync(string primaryKey, CacheEntry entry, Can string variantKey = BuildVariantKey(primaryKey, entry.VaryFields, entry.VaryValues); await cache.SetAsync(variantKey, entry, ct).ConfigureAwait(false); - await cache.SetAsync(primaryKey, CreateVaryMarker(entry), ct).ConfigureAwait(false); + + // Track the variant key on the marker so explicit eviction can sweep every variant, not just make + // them unreachable. Read-merge-write with no compare-and-swap: best-effort (see CacheIndexing). + CacheEntry? existingMarker = await cache.GetAsync(primaryKey, ct).ConfigureAwait(false); + string[] trackedKeys = CacheIndexing.MergeTrackedKey( + existingMarker is { IsVaryMarker: true } ? existingMarker.TrackedKeys : [], + variantKey); + + await cache.SetAsync(primaryKey, CreateVaryMarker(entry) with { TrackedKeys = trackedKeys }, ct).ConfigureAwait(false); } /// @@ -750,10 +866,10 @@ protected override async Task SendAsync(HttpRequestMessage HttpResponseMessage? notModified = TryCreateNotModified(request, entry); if (notModified is not null) { - return notModified; + return WithStatus(notModified, StampedeCacheStatus.Hit); } - return CreateResponse(entry); + return WithStatus(CreateResponse(entry), StampedeCacheStatus.Hit); } // RFC 5861 §3 — stale-while-revalidate: serve stale immediately, revalidate in background @@ -762,7 +878,7 @@ protected override async Task SendAsync(HttpRequestMessage metrics?.RecordStaleWhileRevalidateServed(clientName); LogStaleWhileRevalidate(key); ScheduleBackgroundRevalidation(key, entry, request); - return CreateResponse(entry); + return WithStatus(CreateResponse(entry), StampedeCacheStatus.Stale); } // §5.2.1.2 — max-stale: the client accepts an expired entry directly, no origin contact. @@ -770,7 +886,7 @@ protected override async Task SendAsync(HttpRequestMessage { metrics?.RecordCacheHit(clientName: clientName); LogMaxStaleServed(key); - return CreateResponse(entry); + return WithStatus(CreateResponse(entry), StampedeCacheStatus.Stale); } // Stale entry (or no-cache demand, or an unmet request freshness directive) with a validator @@ -808,7 +924,7 @@ protected override async Task SendAsync(HttpRequestMessage { metrics?.RecordStaleErrorServed(clientName); LogStaleIfErrorServed(key); - return CreateResponse(entry!); + return WithStatus(CreateResponse(entry!), StampedeCacheStatus.Stale); } TimeSpan fetchDuration = _timeProvider.GetUtcNow() - fetchStart; @@ -819,7 +935,7 @@ protected override async Task SendAsync(HttpRequestMessage response.Dispose(); metrics?.RecordStaleErrorServed(clientName); LogStaleIfErrorServed(key); - return CreateResponse(entry); + return WithStatus(CreateResponse(entry), StampedeCacheStatus.Stale); } bool noStore = request.Options.TryGetValue(CacheRequestPolicy.NoStore, out bool ns) && ns; @@ -830,6 +946,14 @@ protected override async Task SendAsync(HttpRequestMessage await StoreAsync(key, request, response, fetchDuration, ct).ConfigureAwait(false); } + StampedeCacheStatus.MarkMiss(response); + return response; + } + + /// Stamps the X-Stampede-Cache status header on a response and returns it, for use in return expressions. + private static HttpResponseMessage WithStatus(HttpResponseMessage response, string status) + { + StampedeCacheStatus.Set(response, status); return response; } @@ -876,7 +1000,7 @@ private async Task HandleHeadAsync(HttpRequestMessage reque { metrics?.RecordCacheHit(HttpMethod.Head, clientName); LogCacheHit(getKey); - return CreateResponse(entry, includeBody: false); + return WithStatus(CreateResponse(entry, includeBody: false), StampedeCacheStatus.Hit); } // §5.2.1.2 — max-stale: the client accepts an expired GET entry directly, no origin contact. @@ -884,7 +1008,7 @@ private async Task HandleHeadAsync(HttpRequestMessage reque { metrics?.RecordCacheHit(HttpMethod.Head, clientName); LogMaxStaleServed(getKey); - return CreateResponse(entry, includeBody: false); + return WithStatus(CreateResponse(entry, includeBody: false), StampedeCacheStatus.Stale); } // Stale entry with a validator — conditional HEAD revalidation @@ -910,16 +1034,19 @@ private async Task HandleHeadAsync(HttpRequestMessage reque if (revalResponse.StatusCode == HttpStatusCode.NotModified) { CacheEntry refreshed = RefreshFromNotModified(entry, revalResponse, headFetchDuration); - await WriteEntryAsync(getKey, refreshed, ct).ConfigureAwait(false); + await StoreRepresentationAsync(getKey, refreshed, ct).ConfigureAwait(false); metrics?.RecordCacheHit(HttpMethod.Head, clientName); - return CreateResponse(refreshed, includeBody: false); + return WithStatus(CreateResponse(refreshed, includeBody: false), StampedeCacheStatus.Revalidated); } + StampedeCacheStatus.MarkMiss(revalResponse); return revalResponse; } // Miss or stale without validator — forward HEAD to origin - return await base.SendAsync(request, ct).ConfigureAwait(false); + HttpResponseMessage headResponse = await base.SendAsync(request, ct).ConfigureAwait(false); + StampedeCacheStatus.MarkMiss(headResponse); + return headResponse; } /// @@ -1001,7 +1128,7 @@ private async Task RevalidateAsync(string key, CacheEntry e catch when (CanServeStaleOnError(entry)) { metrics?.RecordStaleErrorServed(clientName); - return CreateResponse(entry); + return WithStatus(CreateResponse(entry), StampedeCacheStatus.Stale); } TimeSpan fetchDuration = _timeProvider.GetUtcNow() - fetchStart; @@ -1011,15 +1138,15 @@ private async Task RevalidateAsync(string key, CacheEntry e { response.Dispose(); metrics?.RecordStaleErrorServed(clientName); - return CreateResponse(entry); + return WithStatus(CreateResponse(entry), StampedeCacheStatus.Stale); } if (response.StatusCode == HttpStatusCode.NotModified) { CacheEntry refreshed = RefreshFromNotModified(entry, response, fetchDuration); - await WriteEntryAsync(key, refreshed, ct).ConfigureAwait(false); + await StoreRepresentationAsync(key, refreshed, ct).ConfigureAwait(false); metrics?.RecordCacheHit(clientName: clientName); - return CreateResponse(refreshed); + return WithStatus(CreateResponse(refreshed), StampedeCacheStatus.Revalidated); } // Per-request NoStore: allow 304 TTL refresh (above) but block storing a new response @@ -1030,6 +1157,7 @@ private async Task RevalidateAsync(string key, CacheEntry e await StoreAsync(key, request, response, fetchDuration, ct).ConfigureAwait(false); } + StampedeCacheStatus.MarkMiss(response); return response; } @@ -1162,7 +1290,7 @@ private void ScheduleBackgroundRevalidation(string key, CacheEntry entry, HttpRe if (response.StatusCode == HttpStatusCode.NotModified) { CacheEntry refreshed = RefreshFromNotModified(entry, response, fetchDuration); - await WriteEntryAsync(key, refreshed, CancellationToken.None).ConfigureAwait(false); + await StoreRepresentationAsync(key, refreshed, CancellationToken.None).ConfigureAwait(false); } else if (IsResponseCacheable(response, bgRequest)) { @@ -1261,6 +1389,14 @@ private static Dictionary ExtractHeaders(HttpResponseMessage r foreach (KeyValuePair> header in response.Headers) { + // Never persist the synthetic cache-status header (e.g. COALESCED stamped by the coalescer): + // a stored entry must report its own status when replayed, not the one of the response that + // populated it. + if (string.Equals(header.Key, StampedeCacheStatus.HeaderName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + headers[header.Key] = [.. header.Value]; } @@ -1307,4 +1443,7 @@ private static Dictionary ExtractHeaders(HttpResponseMessage r [LoggerMessage(Level = LogLevel.Debug, Message = "Cache: invalidating {CacheKey} after successful {HttpMethod} request (RFC 9111 §4.4)")] private partial void LogCacheInvalidation(string cacheKey, string httpMethod); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Cache: indexed {CacheKey} under tag {Tag}")] + private partial void LogTagIndexed(string cacheKey, string tag); } diff --git a/Stampede.Http/Caching/IStampedeHttpCache.cs b/Stampede.Http/Caching/IStampedeHttpCache.cs index 9352eb7..7e22c73 100644 --- a/Stampede.Http/Caching/IStampedeHttpCache.cs +++ b/Stampede.Http/Caching/IStampedeHttpCache.cs @@ -1,3 +1,5 @@ +using System.Net.Http.Headers; + namespace Stampede.Http.Caching; /// @@ -16,38 +18,74 @@ namespace Stampede.Http.Caching; /// await cache.EvictAsync(new Uri("https://api.example.com/products/42")); /// /// -/// Scope: eviction targets a single, exact URI — the same key an ordinary GET to that URI would +/// Scope: URI eviction targets a single, exact URI — the same key an ordinary GET to that URI would /// resolve to. There is no prefix or pattern eviction: (in particular /// , backed by ) -/// has no portable way to enumerate or pattern-match keys. +/// has no portable way to enumerate or pattern-match keys. To invalidate a group of URIs in one call, tag +/// them (via or ) and use +/// . /// /// -/// Vary interaction: when the evicted entry carried a Vary header (RFC 9111 §4.1), eviction -/// removes only the primary key's marker. The secondary-key variants it pointed to (one per distinct -/// combination of Vary field values) become unreachable but are not actively removed — they expire on -/// their own schedule. This is the same trade-off Vary storage already makes internally; a future release -/// may enumerate and remove them explicitly if this proves to matter in practice. +/// Vary interaction: when the evicted entry carries a Vary header (RFC 9111 §4.1), eviction +/// follows the primary key's marker and also removes the secondary-key variants it tracks, at the cost of +/// one extra store read per evicted key. Variant tracking is best-effort (see +/// ): a variant that fell out of the tracked list becomes unreachable +/// on marker removal and expires on its own schedule rather than being actively swept. /// /// /// Authorization interaction: when is enabled, /// authenticated responses are stored under a credential-scoped key (see -/// ) that — which always resolves the plain, -/// unauthenticated key for a URI — cannot target. Per-credential entries are unaffected by eviction and -/// rely on their own freshness/validator lifecycle instead. +/// ). always +/// resolves the plain, unauthenticated key, so per-credential entries need +/// with the same +/// Authorization value the cached request carried. /// /// public interface IStampedeHttpCache { /// - /// Evicts the cached GET representation for , if one exists. + /// Evicts the cached GET representation for , if one exists — including, when it + /// varies (RFC 9111 §4.1), the secondary-key variants tracked by its Vary marker. /// /// /// Removal is unconditional and idempotent — calling this for a URI with nothing cached is a no-op, - /// not an error, and there is no read-before-remove to report whether an entry actually existed - /// (avoiding a redundant round trip against a distributed store, the same reasoning already applied to - /// §4.4 invalidation). + /// not an error, and nothing is reported about whether an entry actually existed. /// /// The URI whose cached GET response should be evicted. /// A cancellation token to observe while removing the entry. ValueTask EvictAsync(Uri uri, CancellationToken ct = default); + + /// + /// Evicts the cached GET representation stored for under the credential-scoped + /// key derived from — the entry an authenticated GET carrying that + /// same Authorization value would hit when is + /// enabled. Like the URI overload, this also sweeps tracked Vary variants and is idempotent. + /// + /// + /// The default implementation throws so that custom + /// implementations written before 2.6 keep compiling; the built-in + /// implementation registered by AddStampedeHttp()/AddCachingOnly() always supports it. + /// + /// The URI whose cached authenticated GET response should be evicted. + /// The Authorization header value the cached request carried. + /// A cancellation token to observe while removing the entry. + ValueTask EvictAsync(Uri uri, AuthenticationHeaderValue authorization, CancellationToken ct = default) + => throw new NotSupportedException($"This {nameof(IStampedeHttpCache)} implementation does not support credential-scoped eviction."); + + /// + /// Evicts every cached GET representation tagged with — collected from the + /// response headers named in and from + /// — then drops the tag's index entry itself. Tags are compared + /// ordinally (case-sensitive). Evicting a tag nothing carries is a no-op, not an error. + /// + /// + /// The tag index is best-effort (see ): an entry that fell out of + /// it is not swept here and expires on its own freshness/validator schedule instead. The default + /// implementation throws so that custom implementations written + /// before 2.6 keep compiling; the built-in implementation always supports it. + /// + /// The tag whose entries should be evicted. + /// A cancellation token to observe while removing the entries. + ValueTask EvictByTagAsync(string tag, CancellationToken ct = default) + => throw new NotSupportedException($"This {nameof(IStampedeHttpCache)} implementation does not support tag-based eviction."); } diff --git a/Stampede.Http/Caching/MemoryCacheStore.cs b/Stampede.Http/Caching/MemoryCacheStore.cs index f1a6c73..3d25090 100644 --- a/Stampede.Http/Caching/MemoryCacheStore.cs +++ b/Stampede.Http/Caching/MemoryCacheStore.cs @@ -117,6 +117,16 @@ internal static long ComputeSize(CacheEntry entry) } } + foreach (string trackedKey in entry.TrackedKeys) + { + size += trackedKey.Length; + } + + foreach (string tag in entry.Tags) + { + size += tag.Length; + } + size += entry.ETag?.Length ?? 0; return size; diff --git a/Stampede.Http/Caching/StampedeHttpCache.cs b/Stampede.Http/Caching/StampedeHttpCache.cs index 4b4155d..2175ed0 100644 --- a/Stampede.Http/Caching/StampedeHttpCache.cs +++ b/Stampede.Http/Caching/StampedeHttpCache.cs @@ -1,3 +1,5 @@ +using System.Net.Http.Headers; + namespace Stampede.Http.Caching; /// @@ -13,6 +15,35 @@ public ValueTask EvictAsync(Uri uri, CancellationToken ct = default) ArgumentNullException.ThrowIfNull(uri); string key = CacheKeyHelpers.BuildGetKey(keyBuilder, uri); - return cache.RemoveAsync(key, ct); + return CacheIndexing.EvictWithVariantsAsync(cache, key, ct); + } + + /// + public ValueTask EvictAsync(Uri uri, AuthenticationHeaderValue authorization, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(authorization); + + string key = CacheKeyHelpers.BuildGetKey(keyBuilder, uri, authorization); + return CacheIndexing.EvictWithVariantsAsync(cache, key, ct); + } + + /// + public async ValueTask EvictByTagAsync(string tag, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tag); + + string tagKey = CacheIndexing.BuildTagKey(tag); + CacheEntry? index = await cache.GetAsync(tagKey, ct).ConfigureAwait(false); + + if (index is not null) + { + foreach (string primaryKey in index.TrackedKeys) + { + await CacheIndexing.EvictWithVariantsAsync(cache, primaryKey, ct).ConfigureAwait(false); + } + } + + await cache.RemoveAsync(tagKey, ct).ConfigureAwait(false); } } diff --git a/Stampede.Http/Coalescing/RequestCoalescer.cs b/Stampede.Http/Coalescing/RequestCoalescer.cs index e595876..462a647 100644 --- a/Stampede.Http/Coalescing/RequestCoalescer.cs +++ b/Stampede.Http/Coalescing/RequestCoalescer.cs @@ -56,7 +56,14 @@ public async Task ExecuteAsync( .ConfigureAwait(false); LogCoalescedWaiterCompleted(key); - return cachedResponse.ToHttpResponseMessage(); + + HttpResponseMessage waiterResponse = cachedResponse.ToHttpResponseMessage(); + + // This caller shared the winner's origin call instead of issuing its own — report it via + // the synthetic status header. Only waiters are marked: the winner did hit the origin. + StampedeCacheStatus.Set(waiterResponse, StampedeCacheStatus.Coalesced); + + return waiterResponse; } catch (TimeoutException) { diff --git a/Stampede.Http/StampedeCacheStatus.cs b/Stampede.Http/StampedeCacheStatus.cs new file mode 100644 index 0000000..244fe99 --- /dev/null +++ b/Stampede.Http/StampedeCacheStatus.cs @@ -0,0 +1,78 @@ +namespace Stampede.Http; + +/// +/// The synthetic X-Stampede-Cache response header Stampede.Http sets on every response its caching +/// layer handled, reporting how the response was obtained — from cache, from the origin, or from another +/// caller's shared in-flight origin call. Useful for debugging and for integration tests asserting cache +/// behavior without instrumenting metrics. +/// +/// +/// The header carries exactly one of the constant values below: +/// +/// Served from a fresh cache entry, no origin contact (includes locally answered conditional requests returning 304). +/// Served from an expired entry — stale-while-revalidate, stale-if-error, or the request's own max-stale. +/// Served from cache after the origin confirmed it unchanged with 304 Not Modified. +/// Shared another concurrent caller's in-flight origin call instead of issuing its own. +/// Fetched from the origin — no usable cache entry, or the entry needed a full refetch. +/// +/// +/// The header is absent when the caching layer didn't participate in serving the response: requests it +/// doesn't handle (unsafe methods, non-cacheable requests, ), +/// or pipelines without the caching handler — with the exception of , which the +/// coalescer sets on its own, caching layer or not. It is set on the response handed back to the caller +/// and never persisted: stored cache entries strip it, so a replayed hit always reports its own status. +/// +/// +public static class StampedeCacheStatus +{ + /// Name of the synthetic response header: X-Stampede-Cache. + public const string HeaderName = "X-Stampede-Cache"; + + /// Served from a fresh cache entry without contacting the origin. + public const string Hit = "HIT"; + + /// Fetched from the origin — no usable cache entry. + public const string Miss = "MISS"; + + /// Served from an expired entry under stale-while-revalidate, stale-if-error, or the request's max-stale. + public const string Stale = "STALE"; + + /// Served from cache after a conditional revalidation the origin answered with 304 Not Modified. + public const string Revalidated = "REVALIDATED"; + + /// Shared a concurrent caller's in-flight origin call instead of issuing an independent one. + public const string Coalesced = "COALESCED"; + + /// + /// Returns the X-Stampede-Cache value carried by , or + /// when the header is absent. + /// + public static string? GetStatus(HttpResponseMessage response) + { + ArgumentNullException.ThrowIfNull(response); + + return response.Headers.NonValidated.TryGetValues(HeaderName, out System.Net.Http.Headers.HeaderStringValues values) + ? values.ToString() + : null; + } + + /// Sets the header to , replacing any existing value. + internal static void Set(HttpResponseMessage response, string status) + { + _ = response.Headers.Remove(HeaderName); + _ = response.Headers.TryAddWithoutValidation(HeaderName, status); + } + + /// + /// Marks the response as an origin fetch (), unless the coalescer already marked it + /// — the more specific of the two. Any other pre-existing value (an origin + /// echoing the header name back) is replaced, so the header always reflects this pipeline's outcome. + /// + internal static void MarkMiss(HttpResponseMessage response) + { + if (!string.Equals(GetStatus(response), Coalesced, StringComparison.Ordinal)) + { + Set(response, Miss); + } + } +}