Skip to content

feat: heuristic freshness, request directives, authorized caching, POST coalescing, XFetch - #11

Merged
FranRuiz98 merged 10 commits into
masterfrom
claude/goofy-yalow-e01e7e
Aug 26, 2026
Merged

feat: heuristic freshness, request directives, authorized caching, POST coalescing, XFetch#11
FranRuiz98 merged 10 commits into
masterfrom
claude/goofy-yalow-e01e7e

Conversation

@FranRuiz98

Copy link
Copy Markdown
Owner

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

  • Frescura heurística (RFC 9111 §4.2.2, opt-in vía EnableHeuristicFreshness): estima un TTL a partir de Last-Modified cuando no hay s-maxage/max-age/Expires.
  • Tag stampede_http.client_name en todas las métricas, para poder desglosar hit-rate por cliente nombrado.
  • Directivas Cache-Control de petición (RFC 9111 §5.2.1): max-age/min-fresh pueden exigir revalidación aunque la entrada siga fresca para el servidor; max-stale permite 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 que ICacheStore/ICacheKeyBuilder.
  • Caching opt-in de peticiones autenticadas (RFC 9111 §3.5, AuthorizationCaching): Never (default) / WhenPermittedByResponse / Always. La clave de caché y la de coalescing pliegan un hash SHA-256 del Authorization, nunca el valor crudo, para que credenciales distintas nunca se mezclen.
    • Hallazgo colateral corregido: el coalescer ya mezclaba respuestas de credenciales distintas para la misma URL — un bug preexistente e independiente de esta feature. El fix es incondicional (no depende de AuthorizationCaching), así que protege también a quien use AddCoalescingOnly() sin caché.

v2.5.0 — Avanzado

  • Coalescing de POST idempotente (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.
  • Revalidación temprana probabilística (XFetch) (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) y EarlyRevalidationBeta. Desincroniza refrescos entre instancias en vez de que todas repliquen la petición en el mismo instante.

Verificación

  • Build completo (net8.0 + net10.0) en 0 warnings.
  • 437/437 tests en verde (100+ nuevos: aislamiento de credenciales en caché y coalescer, dedup de revalidación en background, compatibilidad JSON hacia atrás para el nuevo campo de CacheEntry, etc).
  • README actualizado con la documentación y el changelog de cada versión.

Limitaciones documentadas (no bugs, decisiones de alcance)

  • HEAD y la invalidación §4.4 tras un método unsafe resuelven la clave sin credencial, así que nunca alcanzan entradas per-credencial de AuthorizationCaching — quedan a su propio TTL/validador o a purga explícita vía IStampedeHttpCache.
  • IStampedeHttpCache.EvictAsync evicta por URI exacta; las variantes secundarias de Vary quedan huérfanas y expiran solas (mismo trade-off que ya asume el almacenamiento de Vary internamente).

🤖 Generated with Claude Code

FranRuiz98 and others added 10 commits August 26, 2026 18:52
… (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>
@FranRuiz98
FranRuiz98 merged commit e64496d into master Aug 26, 2026
@FranRuiz98
FranRuiz98 deleted the claude/goofy-yalow-e01e7e branch August 26, 2026 17:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant