feat: heuristic freshness, request directives, authorized caching, POST coalescing, XFetch - #11
Merged
Merged
Conversation
… (RFC 9111 §4.2.2) Adds CacheOptions.EnableHeuristicFreshness (default false): when a response carries Last-Modified but no s-maxage/max-age/Expires, the freshness lifetime is estimated as HeuristicFreshnessFraction (default 10%) of the time elapsed since Last-Modified, capped at MaxHeuristicFreshness (default 24h) — the classic heuristic recommended by §4.2.2. Disabled by default; falls back to DefaultTtl exactly as before when the response has no Last-Modified either. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a stampede_http.client_name tag to every StampedeHttpMetrics instrument, carrying the name of the HttpClient a measurement came from (IHttpClientFactory.CreateClient), so cache-hit/miss/coalescing rates can be broken down per named client instead of only in aggregate. Built with TagList for zero extra allocation on the hot path. The default/unnamed client and the parameterless test constructors emit no tag, so this is purely additive: existing single-client totals are unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…1 §5.2.1) Adds max-age, min-fresh, and max-stale on the request side, on top of the no-cache/no-store/only-if-cached already handled: - max-age / min-fresh can tighten what counts as a fresh hit even when the entry itself is still within its server-set freshness lifetime, falling through to conditional revalidation (or a full request) when unmet. Honored even for Immutable (RFC 8246) entries — immutability only exempts a response from the origin's own no-cache semantics, not a client's recency requirement. - max-stale (with or without a value) widens acceptance to serve an already-expired entry directly, without contacting the origin, unless the entry carries must-revalidate/proxy-revalidate (§5.2.2.2). Only ever matters for entries the store already retains for another reason (a validator or an origin-configured stale window) — MemoryCacheStore drops anything else immediately, documented on IsWithinRequestMaxStale. Applies to both GET and HEAD. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ss, client_name tag) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds IStampedeHttpCache.EvictAsync(Uri) for evicting a URI's cached GET response on demand — for when a resource changes through a channel this HttpClient didn't observe (another service mutated it, a webhook fired, an out-of-band admin action). Registered as a per-client keyed singleton the same way as ICacheStore/ICacheKeyBuilder, resolved lazily so it keeps working after UseDistributedCacheStore() swaps the underlying store. Eviction targets one exact URI — no prefix/pattern eviction, since IDistributedCache has no portable way to enumerate keys — and is unconditional/idempotent (no read-before-remove, avoiding a redundant round trip against a distributed store). A Vary-varied entry's secondary-key variants become unreachable but aren't actively swept, same trade-off Vary storage already makes internally. Extracted the GET-key-building logic shared with §4.4 invalidation into CacheKeyHelpers so both stay in sync with ICacheKeyBuilder's format. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds CacheOptions.AuthorizationCaching (default Never, matching every prior version exactly): - WhenPermittedByResponse: cached only when the response carries Cache-Control: public, must-revalidate, or an explicit s-maxage — the origin decides per response, per §3.5. - Always: cached under the normal rules regardless of what the response's Cache-Control says. Stampede.Http is a private cache, so replaying a caller's own prior response to that same caller isn't the cross-user leak §3.5 guards against. What has to hold instead is that different credentials are never mixed: whenever this isn't Never, both the cache key (DefaultCacheKeyBuilder) and the coalescing key (RequestKey) fold in a SHA-256 hash of the Authorization value — never the raw value, which must not surface in a key, a log line, or a distributed store's key listing. The coalescing-side fold is unconditional, independent of AuthorizationCaching: without it, two different credentials' concurrent requests to the same URL could already be merged into one shared origin response by the coalescer today, regardless of whether caching is even in the pipeline (AddCoalescingOnly). This closes that pre-existing gap as a prerequisite for the caching feature to be safe at all. Two documented limitations, both because HEAD and §4.4 invalidation resolve the plain unauthenticated key: an authenticated HEAD won't hit its own GET entry, and an unsafe-method invalidation only reaches the unauthenticated entry, not per-credential ones (which rely on their own freshness/validator lifecycle, or explicit eviction via IStampedeHttpCache). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t caching) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds CoalescerOptions.ShouldCoalesce: a predicate extending coalescing to methods other than GET/HEAD, which remain coalesceable unconditionally. Typical use is a POST exposed as a read — a GraphQL query, a search endpoint with a large filter body. Default is unset (null), so no other method is ever coalesced unless explicitly matched; GET/HEAD eligibility and behavior are unaffected either way. This asserts the matched requests are idempotent: coalescing merges concurrent identical calls into one execution, which is wrong for a mutation. The predicate runs before the body is read (method/URI/headers only) — a GraphQL gateway would typically match on a header the caller adds when building the query request, since query vs. mutation can't be told apart without reading the body. For a matched method, RequestKey.CreateWithBodyAsync buffers the request body (bounded by MaxCoalescedRequestBodyBytes, default 64 KB — a larger body just executes independently rather than throwing) and folds a SHA-256 hash of it into the coalescing key, so two different bodies to the same URL are never merged. Buffering has a useful side effect: it makes the body replayable for any Polly retry/hedging layered outside the coalescer, the same way a coalesced GET's response already is. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds CacheOptions.EnableEarlyRevalidation (default false) implementing XFetch (Vattani, Padmanabhan & Gionis, "Optimal Probabilistic Cache Stampede Prevention", 2015): on a fresh cache hit, a background refresh may be triggered ahead of the entry's expiry, with the trigger probability rising the closer the entry is to expiring. This targets a different failure mode than stale-while-revalidate, which only reacts once an entry has already gone stale. XFetch instead spreads out *when* different callers or process instances decide to refetch a not-yet-expired resource, so they don't all do it in the same instant it expires. CacheEntry.OriginFetchDurationMs now records the wall-clock time of every origin call (initial fetch, conditional revalidation, or background refresh) using the injected TimeProvider — the same clock already used for freshness calculations, so tests can control it deterministically. The trigger scales by this duration and CacheOptions.EarlyRevalidationBeta (the XFetch paper's beta, default 1.0): the expected lead time before expiry is duration x beta, so expensive-to-recompute resources start refreshing earlier than cheap ones. A second injectable source of [0,1) randomness (defaulting to Random.Shared) makes the otherwise-probabilistic trigger testable. Reuses BackgroundRevalidationCoordinator, so at most one refresh runs per key regardless of how many concurrent fresh hits trigger it. The entry carrying OriginFetchDurationMs = 0 (pre-2.5 deserialized entries, or a key never actually fetched) never triggers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ation) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resumen
Siete features nuevas sobre el core de caching/coalescing, agrupadas en tres tandas de versión (v2.3.0 → v2.5.0). Cada commit es una feature autocontenida, buildable y con sus propios tests.
v2.3.0 — Cumplimiento y observabilidad
EnableHeuristicFreshness): estima un TTL a partir deLast-Modifiedcuando no hays-maxage/max-age/Expires.stampede_http.client_nameen todas las métricas, para poder desglosar hit-rate por cliente nombrado.Cache-Controlde petición (RFC 9111 §5.2.1):max-age/min-freshpueden exigir revalidación aunque la entrada siga fresca para el servidor;max-stalepermite servir una entrada expirada directamente. Aplica a GET y HEAD.v2.4.0 — Superficie de API
IStampedeHttpCache.EvictAsync(Uri): invalidación programática por URI exacta, registrada per-cliente igual queICacheStore/ICacheKeyBuilder.AuthorizationCaching):Never(default) /WhenPermittedByResponse/Always. La clave de caché y la de coalescing pliegan un hash SHA-256 delAuthorization, nunca el valor crudo, para que credenciales distintas nunca se mezclen.AuthorizationCaching), así que protege también a quien useAddCoalescingOnly()sin caché.v2.5.0 — Avanzado
CoalescerOptions.ShouldCoalesce): extiende el coalescing a métodos no-GET/HEAD explícitamente marcados por el usuario, con la clave discriminada por hash del body.EnableEarlyRevalidation): en un hit fresco, dispara un refresco en background con probabilidad creciente cerca de expiry, escalado por cuánto tarda el origen (CacheEntry.OriginFetchDurationMs) yEarlyRevalidationBeta. Desincroniza refrescos entre instancias en vez de que todas repliquen la petición en el mismo instante.Verificación
CacheEntry, etc).Limitaciones documentadas (no bugs, decisiones de alcance)
HEADy la invalidación §4.4 tras un método unsafe resuelven la clave sin credencial, así que nunca alcanzan entradas per-credencial deAuthorizationCaching— quedan a su propio TTL/validador o a purga explícita víaIStampedeHttpCache.IStampedeHttpCache.EvictAsyncevicta por URI exacta; las variantes secundarias deVaryquedan huérfanas y expiran solas (mismo trade-off que ya asume el almacenamiento de Vary internamente).🤖 Generated with Claude Code