Fix cache read VC replacement after a lost write lock - #13515
Conversation
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 apache#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 apache#13487 fixed, because both land in HttpCacheSM while a cache write retry dispatches events, but they are a distinct failure. apache#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>
There was a problem hiding this comment.
Pull request overview
This pull request fixes a cache state-machine correctness issue that can occur when a transaction revalidates a stale cached object, loses the cache write lock, and then incorrectly re-enters a second cache lookup while still holding the original cache read VC—leading to debug assertions and potential stale-fallback lifetime problems.
Changes:
- Limit the “re-lookup after lost write lock” behavior to only the open-write-fail actions that explicitly configure a read retry (actions 5 and 6).
- Make cache read VC replacement explicit for the READ_RETRY path and ensure stale retry fallback state does not outlive the VC that owns it.
- Add gold tests (ATSReplay) that deterministically exercise both “serve stale directly” (action 2) and “retry read then serve stale” (action 6) configurations.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| tests/gold_tests/cache/replay/cache-write-lock-stale-serve.replay.yaml | New replay validating action 2 serves stale without issuing a read retry / second lookup. |
| tests/gold_tests/cache/replay/cache-write-lock-stale-retry.replay.yaml | New replay validating action 6 triggers a read retry and remains stable when the retry completes. |
| tests/gold_tests/cache/cache-write-lock-stale-revalidate.test.py | New AuTest entry point running both replays. |
| tests/gold_tests/cache/cache-write-lock-fail-write.conf | New header_rewrite rules file to deterministically deny the write lock (via max_open_write_retries=0). |
| src/proxy/http/HttpTransact.cc | Avoids second cache lookup for non-read-retry actions when a stale object is already present; preserves READ_RETRY behavior. |
| src/proxy/http/HttpCacheSM.cc | Allows cache read VC replacement in the READ_RETRY write-lock-loss path and clears stale retry fallback when the owning VC is replaced/closed. |
| include/proxy/http/HttpConfig.h | Adds a shared helper is_read_retry_write_fail_action() to centralize which fail actions configure a read retry. |
|
I traced this through and the diagnosis holds up. Recording the path since it spans three files and is not obvious from any one of them.
t_state.cache_info.write_lock_state = HttpTransact::CacheWriteLock_t::READ_RETRY;So fail actions 2 and 3 arrive at the Scoping the branch with The deferred-hook question resolves in your favor, but only by a non-local invariant. My first concern was that the new early return calls Two things follow from that, and they are my only real asks:
On clearing Hoisting This wants backporting to 10.2.x, and I would treat it as RC-blocking. #12852 is already on 10.2.x as Agreed too that this is distinct from #13487 and neither subsumes the other: that one stopped Nice touch making the test synchronous via |
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)
|
Cherry-picked to the 10.2.x branch as 601871f for the 10.2.0 release. |
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.
🤖 Generated with Claude Code