Skip to content

Add fail action 6, will fallback to serving stale if retry attempts are exhausted - #12852

Merged
ezelkow1 merged 16 commits into
apache:masterfrom
ezelkow1:fa6
Feb 23, 2026
Merged

ezelkow1 merged 16 commits into
apache:masterfrom
ezelkow1:fa6

Conversation

@ezelkow1

@ezelkow1 ezelkow1 commented Feb 3, 2026

Copy link
Copy Markdown
Member

Adds a new fail action that will combine actions 2 and 5 so that if retries are exhausted after attempting collapse then it will also check if it can serve stale if it has an object before deciding to go upstream.

This also refactors a tiny bit of the going to origin logic so that when we are in these retry states we can avoid multiple CACHE_LOOKUP hook calls since previously plugins could get hit with these multiple times. Also fixes some stats issues around counting hit vs. stale or dupe counts.

For now I mainly want to see if this passes all tests since I can't run all locally

@ezelkow1 ezelkow1 self-assigned this Feb 3, 2026
@ezelkow1
ezelkow1 requested review from bneradt and removed request for bneradt February 3, 2026 20:37
@bryancall
bryancall requested a review from Copilot February 3, 2026 21:40

This comment was marked as resolved.

ezelkow1 and others added 3 commits February 3, 2026 15:03
yes, this is a good fix

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

This comment was marked as resolved.

@ezelkow1
ezelkow1 marked this pull request as draft February 4, 2026 00:13
@ezelkow1

ezelkow1 commented Feb 4, 2026

Copy link
Copy Markdown
Member Author

converting to draft, there is one corner case Im trying to work out (who knew trying to defer hook firings until the ultimate result after looping through the SM is known would be complicated :) )

@ezelkow1

ezelkow1 commented Feb 4, 2026

Copy link
Copy Markdown
Member Author

[approve ci autest 0]

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

@bneradt bneradt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi, I'm Claude (an AI assistant) — asked by @bneradt to review this PR. Here are my concerns:


1. serving_stale_due_to_write_lock changes behavior for existing actions 2 and 3

The new flag is set inside what_is_document_freshness whenever the STALE_ON_REVALIDATE bitmask check passes:

s->serving_stale_due_to_write_lock = true;

This triggers for all actions with the STALE_ON_REVALIDATE bit set: actions 2 (0x02), 3 (0x03), and the new 6 (0x06). The downstream effects in HandleCacheOpenReadHitFreshness (overriding cache_lookup_result to HIT_STALE) and HandleCacheOpenReadHit (changing VIA strings, skipping the REVALIDATION_FAILED warning code path) alter stats and VIA behavior for existing actions 2 and 3. The PR description mentions fixing stats issues, but this is a behavior change to existing, shipped functionality that should be clearly called out. If these are separate bug fixes for actions 2/3, they might be better as a separate commit or PR to isolate risk.

2. Action 5 stale-object path now skips HandleCacheOpenReadHit entirely

Previously for action 5 when READ_RETRY found a stale object:

// old code
s->hdr_info.server_request.destroy();
HandleCacheOpenReadHitFreshness(s);  // → STALE → HandleCacheOpenReadHit → revalidate with conditional request

Now:

// new code, stale + can_serve_stale==false (action 5)
s->cache_info.action = CacheAction_t::NO_ACTION;
handle_cache_write_lock_go_to_origin(s);  // goes directly to origin

The old path went through HandleCacheOpenReadHit, which would build a conditional revalidation request (If-Modified-Since / If-None-Match) before contacting origin. The new path goes directly to origin with NO_ACTION and a destroyed server_request, so no conditional headers are sent. This means a potential 304 response (saving bandwidth) becomes impossible, replaced by a full 200 response that also won't be cached. Depending on the workload, this could be a meaningful performance regression for action 5 with stale content.

3. Inconsistent use of is_read_retry_action helper

A nice helper is defined in HttpCacheSM.cc's anonymous namespace, but then the exact same two-value comparison is written inline in HttpSM.cc (at least 3 places), HttpConfig.cc, and HttpTransact.cc. If a future action is added, all those inline checks need updating. Consider moving this helper to the header (e.g., HttpConfig.h next to the enum) so it can be shared across all files.

4. VIA_SERVER_RESULT = VIA_SERVER_ERROR seems misleading for write lock failure

if (s->serving_stale_due_to_write_lock) {
    SET_VIA_STRING(VIA_SERVER_RESULT, VIA_SERVER_ERROR);
}

The server wasn't contacted and didn't return an error — the issue is cache write lock contention. Setting the VIA server result to "error" could mislead operators monitoring VIA strings for actual origin issues.

5. Temporary override trick to evaluate "real" freshness is fragile

MgmtByte saved_action           = s->cache_open_write_fail_action;
s->cache_open_write_fail_action = static_cast<MgmtByte>(CacheOpenWriteFailAction_t::READ_RETRY);
Freshness_t freshness           = what_is_document_freshness(s, &s->hdr_info.client_request, obj->response_get());
s->cache_open_write_fail_action = saved_action;

This save/restore pattern to prevent the STALE_ON_REVALIDATE short-circuit in what_is_document_freshness is fragile. If what_is_document_freshness later gains side effects based on the action value, this trick silently changes behavior. Consider a dedicated freshness evaluation path (a parameter, or a separate function) that doesn't have the STALE_ON_REVALIDATE short-circuit, rather than temporarily mutating state.

6. Test coverage is minimal

The test verifies action 6 is accepted, basic caching works, and ATS doesn't crash. It does not test the actual stale-serving behavior that is the feature's purpose. Given the complexity of the state machine changes (deferred hooks, new code paths for both fresh and stale objects, interactions between actions 5 and 6), stronger testing would reduce risk.

7. CACHE_LOOKUP_COMPLETE deferral is a behavioral change for action 5

The PR changes action 5's semantics: previously plugins could see CACHE_LOOKUP_COMPLETE multiple times (which the docs noted), now it fires once with the final result. The docs for action 5 are updated accordingly, but existing plugin authors who rely on the old multi-fire behavior may be surprised. This seems like a good improvement, but it's worth calling out as a breaking change for action 5 consumers.

@ezelkow1

ezelkow1 commented Feb 5, 2026

Copy link
Copy Markdown
Member Author
  1. This is actually a bug fix, the current code is wrong for ALL write_fail_actions involving stale (including previous 2/3). Before it was actually reporting these as FRESH when they were in fact STALE.

  2. Will implement, just instead of always destroying will do it conditionally so we can still get 304's on write lock fails.

  3. Just a nice to have, could be done later

  4. will just remove the server_error so it uses the default since it did not go upstream and served stale here

  5. Sort of agree here too, thinking about just adding a boolean to the what_is_document_freshness instead to denote if it should skip these checks instead of faking it

Evan Zelkowitz added 2 commits February 5, 2026 14:55
…mmediately falling back to serving stale. Now it stores of a copy of the object ptr that was found stale so it can follow the same retry path as 5 which if that fails then it will serve the stale object
@ezelkow1
ezelkow1 marked this pull request as ready for review February 6, 2026 20:31
@ezelkow1

ezelkow1 commented Feb 9, 2026

Copy link
Copy Markdown
Member Author

[approve ci]

@bryancall bryancall added this to the 10.2.0 milestone Feb 9, 2026
// Object is stale. Save it as potential fallback, then trigger actual cache retry.
// HandleCacheOpenReadMiss will serve stale fallback (action 6) or go to origin (action 5).
if (is_stale_cache_response_returnable(s)) {
s->cache_info.stale_fallback = s->cache_info.object_read;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude lists this as a potential use after free:

object_read is a non-owning pointer to CacheVC::alternate, memory owned by the CacheVC. When the new CACHE_LOOKUP is triggered, the old CacheVC can be destroyed (e.g., in HttpCacheSM::state_cache_open_read, the old cache_read_vc is overwritten without being explicitly closed in the non-redirect path). At that point, stale_fallback becomes a dangling pointer. Later, in HandleCacheOpenReadMiss, the code does:

s->cache_info.object_read = s->cache_info.stale_fallback;

This would dereference freed memory. The fix should deep-copy the stale object into owned storage (e.g., HTTPInfo stale_fallback_store as a value member, not a pointer, and copy via CacheHTTPInfo::copy()) before triggering the new lookup.

if (is_stale_cache_response_returnable(s)) {
TxnDbg(dbg_ctl_http_match, "cache_serve_stale_on_write_lock_fail, return FRESH");
TxnDbg(dbg_ctl_http_match, "cache_serve_stale_on_write_lock_fail, return FRESH to bypass revalidation");
s->serving_stale_due_to_write_lock = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe an unexpected modification of state in a function that seems read-only. Perhaps move this out of the function?

@ezelkow1
ezelkow1 merged commit df83065 into apache:master Feb 23, 2026
15 checks passed
@github-project-automation github-project-automation Bot moved this to For v10.2.0 in ATS v10.2.x Feb 23, 2026
@ezelkow1
ezelkow1 deleted the fa6 branch February 23, 2026 17:12
@cmcfarlen cmcfarlen moved this from For v10.2.0 to Picked v10.2.0 in ATS v10.2.x Feb 23, 2026
@cmcfarlen

Copy link
Copy Markdown
Contributor

Cherry-picked to 10.2.x

cmcfarlen pushed a commit that referenced this pull request Feb 23, 2026
…re exhausted (#12852)

* Add fail action 6, will fallback to serving stale if retry attempts are exhausted

(cherry picked from commit df83065)
bneradt added a commit that referenced this pull request Aug 7, 2026
A transaction that revalidates a stale cached object and cannot take the
cache write lock is sent back through a second cache lookup while it
still holds the cache read connection its first lookup opened. The read
that completes for that second lookup replaces the connection the
transaction is using: debug builds abort on the read connection
assertion in HttpCacheSM::state_cache_open_read(), and release builds
close that connection out from under the stale object saved as the retry
fallback, leaving the fallback pointing into freed memory. The re-lookup
runs for every cache_open_write_fail_action rather than only for the two
that configure a read retry, so fail action 2, which is documented to
serve the stale object instead of retrying anything, aborts a debug
build several times a day under production traffic.

This patch limits the re-lookup to the fail actions that configure a
read retry. A transaction that loses the write lock with a cached object
and no retry configured now hands that object straight to the freshness
handling that serves stale content, with no second lookup. The retry
actions do want that lookup, so this also makes replacing the read
connection explicit and drops the saved stale object along with the
connection that owns it, since neither can outlive the other. This adds
an autest covering both configurations that does not depend on
contention between transactions: denying the write lock through
max_open_write_retries makes the failure synchronous, and each
configuration aborts an unpatched debug build on the production
assertion.

The re-lookup arrived with the fail action 6 work in #12852, which
applied it to every non-default fail action; that commit's own test
notes the stale path is timing sensitive and does not exercise it. The
resulting aborts resemble the ones #13487 fixed, because both land in
HttpCacheSM while a cache write retry dispatches events, but they are a
distinct failure. #13487 stopped HttpSM from canceling its own captive
action, which aborts on the cancellation assertion in
HttpCacheSM.cc:138; this is the read connection assertion ten lines
later, reached with that action perfectly valid. Both fixes are needed,
and neither subsumes the other.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
cmcfarlen pushed a commit that referenced this pull request Aug 9, 2026
A transaction that revalidates a stale cached object and cannot take the
cache write lock is sent back through a second cache lookup while it
still holds the cache read connection its first lookup opened. The read
that completes for that second lookup replaces the connection the
transaction is using: debug builds abort on the read connection
assertion in HttpCacheSM::state_cache_open_read(), and release builds
close that connection out from under the stale object saved as the retry
fallback, leaving the fallback pointing into freed memory. The re-lookup
runs for every cache_open_write_fail_action rather than only for the two
that configure a read retry, so fail action 2, which is documented to
serve the stale object instead of retrying anything, aborts a debug
build several times a day under production traffic.

This patch limits the re-lookup to the fail actions that configure a
read retry. A transaction that loses the write lock with a cached object
and no retry configured now hands that object straight to the freshness
handling that serves stale content, with no second lookup. The retry
actions do want that lookup, so this also makes replacing the read
connection explicit and drops the saved stale object along with the
connection that owns it, since neither can outlive the other. This adds
an autest covering both configurations that does not depend on
contention between transactions: denying the write lock through
max_open_write_retries makes the failure synchronous, and each
configuration aborts an unpatched debug build on the production
assertion.

The re-lookup arrived with the fail action 6 work in #12852, which
applied it to every non-default fail action; that commit's own test
notes the stale path is timing sensitive and does not exercise it. The
resulting aborts resemble the ones #13487 fixed, because both land in
HttpCacheSM while a cache write retry dispatches events, but they are a
distinct failure. #13487 stopped HttpSM from canceling its own captive
action, which aborts on the cancellation assertion in
HttpCacheSM.cc:138; this is the read connection assertion ten lines
later, reached with that action perfectly valid. Both fixes are needed,
and neither subsumes the other.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit f5c1b09)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Picked v10.2.0

Development

Successfully merging this pull request may close these issues.

5 participants