Skip to content

Treat null cache value as a miss to fix has/get race#520

Open
mattiasgeniar wants to merge 1 commit intospatie:mainfrom
mattiasgeniar:fix/has-get-race-condition
Open

Treat null cache value as a miss to fix has/get race#520
mattiasgeniar wants to merge 1 commit intospatie:mainfrom
mattiasgeniar:fix/has-get-race-condition

Conversation

@mattiasgeniar
Copy link
Copy Markdown

@mattiasgeniar mattiasgeniar commented May 5, 2026

CacheResponse::getCachedResponse() does two separate cache calls — hasBeenCached()
followed by getCachedResponseFor(). The race:

  1. has() returns true — the key exists.
  2. The key expires, is evicted, or is removed by a concurrent forget() / responsecache:clear.
  3. get() returns null.
  4. ResponseCacheRepository::get() evaluates unserialize($cache->get($key) ?? '')unserialize('')CouldNotUnserialize.`
  5. The middleware catches it, serves a fresh response (correct user-facing behaviour), and calls report().

The end result is correct, but normal behaviour on any cache with a finite TTL under moderate traffic gets surfaced as an exception. This was hit in production: traces showed a cache hit and miss on the same key microseconds apart, on a TTL boundary.

The fix collapses the two calls into a single atomic get(): null means miss, Response means hit. The race window is closed and we save a cache round-trip per cached request. hasBeenCached() stays on the public API for external callers.

Before After
Cache calls per cached request 2 (has + get) 1 (get)
Race window yes no
Fresh response on race yes yes
report() on race yes no

The middleware called hasBeenCached() then getCachedResponseFor() as two
separate cache calls. If the key expired, was evicted, or was removed by a
concurrent forget() between the two, has() returned true and get() returned
null. The repository then evaluated unserialize($cache->get($key) ?? '') and
threw CouldNotUnserialize, which the middleware caught, served a fresh
response, and reported as an exception, surfacing what is normal behaviour on
any cache with a finite TTL as noise.

Collapse the two calls into a single atomic get(): null means miss, Response
means hit. The race window is closed and we save a cache round-trip per
cached request. hasBeenCached() stays on the public API for external callers.
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