Skip to content

fix: make background cache revalidation reliable in force and RFC mode - #4315

Merged
szuecs merged 11 commits into
zalando:masterfrom
larry-dalmeida:fix/cache-revalidation-use-original-request
Oct 6, 2026
Merged

szuecs merged 11 commits into
zalando:masterfrom
larry-dalmeida:fix/cache-revalidation-use-original-request

Conversation

@larry-dalmeida

@larry-dalmeida larry-dalmeida commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Background revalidation (stale-while-revalidate) in filters/cache loops the request back through skipper's own listener so the full filter chain reruns. Two independent bugs made that unreliable:

  1. It replayed ctx.Request(), which earlier filters (e.g. modPath stripping a path prefix) may have already mutated. A route like pathSubtree: /api/contentful/ + modPath("^/api/contentful", "") loses its prefix on replay, so the loopback permanently fails to match any route - stale entries, including cached error responses, can never refresh. Observed in production as a 404 replaying via X-Cache-Status: HIT/STALE indefinitely as seen in the screenshot below.
Screenshot 2026-10-02 at 15 30 54
  1. More generally, self-loopback has to re-match a route a second time, which is fragile against any upstream filter mutation, not just modPath.
  2. RFC mode never actually revalidated: CreateFilter only parsed swrWindow for the force-mode form, so RFC-mode entries always had a zero-width SWR window and Entry.IsStale could never be true - the stale-serve-and-revalidate path was dead code for RFC mode.

Fix

  • Use ctx.OriginalRequest() for the revalidation fetch, falling back to ctx.Request() if nil. The cache key is unchanged.
  • For force-mode routes with a resolvable static backend (ctx.BackendUrl() non-empty), dispatch the revalidation fetch directly there using the filter-transformed request - no re-routing, so no route-matching failure. RFC mode and load-balanced/dynamic backends keep self-loopback: RFC mode because a Response()-filter after cache() can rewrite Cache-Control, and only self-loopback sees that before doRevalidate reads it; LB/dynamic backends because there's no single URL to dial directly.
  • Added resolveSWR (sibling to resolveTTL): force mode keeps operator swrWindow authoritative; RFC mode now honors the response's stale-while-revalidate=N directive (RFC 5861), parsed by parseCacheControl.
  • Added cache.reval_backend_dispatch metric, incremented on direct-dispatch revalidation, since that path bypasses Proxy.ServeHTTP and produces none of the usual backend/access-log metrics the self-loopback path gets for free. Documented in docs/operation/operation.md.
  • Added the -proxy-preserve-original / proxy-preserve-original flag, wiring proxy.PreserveOriginal onto the main listener's Proxy.Flags (previously only reachable via the separate -debug-listener). ctx.OriginalRequest() returns nil without this flag set, so for RFC-mode and load-balanced/dynamic-backend routes - which still use self-loopback - this flag must be enabled for the first fix above to actually take effect, rather than silently falling back to the already-mutated ctx.Request(). Documented in docs/operation/operation.md ("Preserving the Original Request") and cross-linked from docs/reference/filters.md's HTTP Path section (modPath/setPath).

Testing

  • TestCacheFilter_Revalidation_UsesOriginalRequestPath - original-request fallback.
  • TestCacheFilter_Revalidation_DirectDispatchToBackend / _LBBackendFallsBackToLoopback - dispatch-target selection, asserting cache.reval_backend_dispatch.
  • TestRevalidationDispatch_RFCModeIgnoresBackendUrl / _ForceModeUsesBackendUrl - dispatch-gating unit tests.
  • TestCacheFilter_Revalidation_RFCMode_StaleServedAndRevalidated - RFC-mode integration test proving the previously-dead path now fires end-to-end.
  • TestParseCacheControl (extended) - stale-while-revalidate parsing cases.

Background revalidation in filters/cache looped the request back
through skipper's own listener using ctx.Request(), which earlier
filters (e.g. modPath) may have already mutated in place. If a route
strips a path prefix before cache() runs, the replayed request no
longer carries that prefix and permanently fails to match any route,
so a stale entry (including a cached error response) can never be
refreshed.

Use ctx.OriginalRequest() for the actual revalidation fetch instead,
falling back to ctx.Request() if it's nil. The cache key is unaffected
and still intentionally reflects filter-mutated state.

Signed-off-by: Larry D Almeida <hello@larrydalmeida.com>
Background revalidation looped every request back through skipper's own
listener so the full filter chain could rerun on it. That re-entry has to
re-match a route, which is fragile to anything an earlier filter changed
(not just the modPath case zalando#4315 fixed) and was never guaranteed to work
in the first place.

For force-mode routes with a resolvable static backend (ctx.BackendUrl()
non-empty), dispatch the revalidation fetch directly there instead,
using the already filter-transformed ctx.Request() — no re-routing
needed, so no route-matching failure is possible.

RFC-mode routes and load-balanced/dynamic backends (ctx.BackendUrl()
empty) keep today's self-loopback behavior via zalando#4315's
revalidationRequest() fallback: RFC mode because a Response()-filter
positioned after cache() could rewrite Cache-Control, and only the
self-loopback path sees that rewritten value before doRevalidate reads
it for TTL purposes; LB/dynamic backends because there's no single
backend URL to dial directly.

Signed-off-by: Larry D Almeida <hello@larrydalmeida.com>
CreateFilter only parses swrWindow for the 3-5 arg (force-mode) form,
so RFC-mode entries always had a zero-width SWR window and could never
reach the stale-serve-and-revalidate path, regardless of this PR's
dispatch-target change for that path.

Add resolveSWR, a resolveTTL-style sibling that keeps the operator
swrWindow authoritative in force mode but, in RFC mode, honors the
response's stale-while-revalidate directive (RFC 5861) parsed by
parseCacheControl. Use it at all four sites that previously read
f.swrWindow directly.

revalidationDispatch's RFC-mode gating is unchanged: RFC mode still
always self-loopbacks regardless of ctx.BackendUrl(), since a
Response()-filter after cache() can rewrite Cache-Control and only the
self-loopback path sees that rewritten value before doRevalidate reads
it. That reasoning no longer depends on RFC-mode SWR being unreachable,
so the comment and this PR's description are updated accordingly.

Signed-off-by: Larry D Almeida <hello@larrydalmeida.com>
Direct-backend revalidation dispatch bypasses skipper's own proxy
pipeline (Proxy.ServeHTTP), so it never produces the usual
MeasureBackend*/MeasureServe metrics or an access-log line - those
only fire for the self-loopback path's inner hop back through
skipper's listener. Without this counter, direct-dispatch
revalidation traffic was invisible to anything but the
cache_revalidation trace span.

Increment it in doRevalidate at the point backendURL is applied to
the outgoing request, and assert it (==1 for direct dispatch, ==0
for self-loopback) in the existing dispatch-target tests.

Signed-off-by: Larry D Almeida <hello@larrydalmeida.com>
Trim the metric description in doRevalidate's doc comment and its
docs/operation/operation.md duplicate down to what's load-bearing.
Also drop "now actually" from a test doc comment that referenced the
PR's before/after state instead of just describing current behavior.

Signed-off-by: Larry D Almeida <hello@larrydalmeida.com>
@larry-dalmeida larry-dalmeida changed the title fix: cache revalidation replays pre-filter path, not the mutated one fix: cache revalidation replays pre-filter path Oct 2, 2026
@larry-dalmeida
larry-dalmeida marked this pull request as ready for review October 2, 2026 14:07
@larry-dalmeida larry-dalmeida changed the title fix: cache revalidation replays pre-filter path fix: make background cache revalidation reliable in force and RFC mode Oct 2, 2026
req.Header.Set(revalidateHeader, "1") ran unconditionally in
doRevalidate, but only the self-loopback dispatch path ever strips it
(cache()'s own Request() deletes it once the request re-enters
skipper). The direct-dispatch path added for force-mode/static-backend
revalidation never re-enters skipper, so X-Cache-Revalidate: 1 was
being sent straight through to the real origin on every direct-dispatch
revalidation.

Move the header set into the self-loopback branch only, since direct
dispatch has no use for it anyway. Extend
TestCacheFilter_Revalidation_DirectDispatchToBackend to assert the
header is absent from the request f.fetch receives.

Signed-off-by: Larry D Almeida <hello@larrydalmeida.com>
…erence

The new stale-while-revalidate directive support cited "RFC 5861"
without a section, while this file already cites "RFC 5861 §4" for
stale-if-error a few lines away. Section 3 is the stale-while-revalidate
extension; cite it the same way.

docs/reference/filters.md's cache() section still described SWR only
in force-mode terms (the swrWindow parameter). RFC mode now derives its
own SWR window from the response's stale-while-revalidate directive
instead of that dead code path - existing RFC-mode operators may see
new background-revalidation traffic they haven't seen before, so note
where the window comes from in each mode and cross-link
cache.reval_backend_dispatch for the observability difference between
direct-dispatch and self-loopback revalidation.

Signed-off-by: Larry D Almeida <hello@larrydalmeida.com>
Comment thread filters/cache/cache_control.go Outdated
// case-insensitively per RFC 9111 §5.2.
func parseCacheControl(h http.Header) cacheDirectives {
d := cacheDirectives{maxAge: -1, sMaxAge: -1}
d := cacheDirectives{maxAge: -1, sMaxAge: -1, staleWhileRevalidate: -1}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

please add line breaks to make it more readable

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

done

@szuecs

szuecs commented Oct 3, 2026

Copy link
Copy Markdown
Member

Should we document that if you modify path that you should run the proxy with PreserveOriginal Flag?
ref: https://github.com/zalando/skipper/blob/master/proxy/context.go#L155

Signed-off-by: Larry D Almeida <hello@larrydalmeida.com>
doRevalidate's direct-dispatch branch rewrote req.URL.Scheme/Host to
dial the real backend, but left req.Host untouched. net/http sends
req.Host verbatim as the wire Host header when set, falling back to
req.URL.Host only when empty. The cloned request still carried the
client-facing Host from the original inbound request, so the real
backend received the client's Host instead of the one skipper's own
proxy pipeline would send for this route (ctx.outgoingHost, via
PreserveHost/route.Host) - breaking any backend that virtual-hosts by
Host.

Thread ctx.OutgoingHost() through revalidationDispatch ->
enqueueRevalidation -> revalJob -> doRevalidate, mirroring how
backendURL is already threaded, and set req.Host alongside the
existing req.URL.Host rewrite. Self-loopback keeps outgoingHost empty,
since it still needs the client-facing Host to re-match a route on
re-entry.

Extend TestCacheFilter_Revalidation_DirectDispatchToBackend to use a
request with .Host pre-populated like a real inbound request, and
assert the wire Host sent to the backend is ctx.OutgoingHost(), not
the client's. Extend both revalidationDispatch unit tests to assert
the new return value in each mode.

Signed-off-by: Larry D Almeida <hello@larrydalmeida.com>
@larry-dalmeida
larry-dalmeida marked this pull request as draft October 5, 2026 09:11
ctx.OriginalRequest()/ctx.OriginalResponse() only return non-nil when
proxy.PreserveOriginal is set on Proxy.Flags, but no CLI/YAML option
wired it onto the main listener - the only CLI-reachable use was
-debug-listener, which enables it on its own separate diagnostic
listener. Any filter or dataclient that needs the pre-filter-chain
request after a path- or header-modifying filter has run (e.g. after
modPath/setPath) was silently getting nil in production.

Add -proxy-preserve-original / proxy-preserve-original, following the
same pattern as -proxy-preserve-host. Off by default, since cloning
the request/response metadata on every request has a cost.

Document the flag and the general PreserveOriginal/OriginalRequest
mechanism in operation.md, cross-linked from the modPath/setPath
filter reference, which previously had no mention of it anywhere in
docs/.

Addresses szuecs's open question on PR zalando#4315 (referencing
proxy/context.go:155).

Signed-off-by: Larry D Almeida <hello@larrydalmeida.com>
Dataclients fetch routing information from upstream sources and have
no access to the per-request filter context, so they cannot call
ctx.OriginalRequest()/ctx.OriginalResponse(). Only filters can.

Addresses szuecs's review comments on PR zalando#4315.

Signed-off-by: Larry D Almeida <hello@larrydalmeida.com>
@larry-dalmeida
larry-dalmeida marked this pull request as ready for review October 5, 2026 12:05
@larry-dalmeida
larry-dalmeida marked this pull request as draft October 5, 2026 12:07
@larry-dalmeida
larry-dalmeida marked this pull request as ready for review October 5, 2026 12:21
@larry-dalmeida

Copy link
Copy Markdown
Collaborator Author

👍

@larry-dalmeida

Copy link
Copy Markdown
Collaborator Author

Should we document that if you modify path that you should run the proxy with PreserveOriginal Flag? ref: https://github.com/zalando/skipper/blob/master/proxy/context.go#L155

yes - updated doc too.

@szuecs

szuecs commented Oct 6, 2026

Copy link
Copy Markdown
Member

👍

@szuecs
szuecs merged commit 7e8906e into zalando:master Oct 6, 2026
19 checks passed
larry-dalmeida added a commit to larry-dalmeida/kubernetes-on-aws that referenced this pull request Oct 8, 2026
Includes zalando/skipper#4315 (background cache revalidation fix) and
zalando/skipper#4314.

ref: zalando/skipper@v0.28.1...v0.28.29

Signed-off-by: Larry D Almeida <hello@larrydalmeida.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants