Skip to content

Guard Cache::pool() against reentrant construction of the same pool - #19587

Merged
markstory merged 3 commits into
5.xfrom
fix/cache-pool-reentrancy
Aug 7, 2026
Merged

markstory merged 3 commits into
5.xfrom
fix/cache-pool-reentrancy

Conversation

@dereuromark

Copy link
Copy Markdown
Member

Asking for a cache pool that is still being constructed recurses without bound.

The cycle

Cache::pool() returns early only once the registry holds the engine, and the registry is populated by _buildEngine() after $registry->load() returns. A call that arrives while that build is still in progress therefore finds nothing registered, starts another build, and repeats.

The reachable path is a cache engine that cannot connect and logs it. RedisEngine::init() calls Log::error() from inside its connect path, and if a configured log engine reaches for a cache-backed resource on the way to writing that message, it lands back in Cache::pool() for the pool that is mid-build. A schema metadata cache is the usual way this closes, because a log engine writing to a table goes through the connection's schema collection:

Cache::pool('_cake_model_')
  -> Cache::_buildEngine('_cake_model_')
    -> $registry->load(...)
      -> RedisEngine::init()
        -> Log::error('RedisEngine could not connect...')
          -> log engine writing to a table
            -> Connection::getSchemaCollection()
              -> Connection::getCacher()
                -> Cache::pool('_cake_model_')   // still nothing registered, so again

The NullEngine fallback already in _buildEngine() does not help. It runs from the catch around $registry->load(), and the recursion happens inside that call, before the RuntimeException can propagate out of it.

Each round allocates, so the process grows until it dies. With a small stack it segfaults first.

The change

Track which pools are mid-build, and hand reentrant callers a NullEngine instead of starting another build:

if (isset(static::$_building[$config])) {
    return new NullEngine();
}

static::$_building[$config] = true;
try {
    static::_buildEngine($config);
} finally {
    unset(static::$_building[$config]);
}

The inner consumer degrades to an uncached lookup, which is the correct outcome when the cache backend is unreachable anyway. The outer build continues and registers whatever it resolves to, so the caller that actually asked for the pool is unaffected.

Clearing the marker in a finally matters: a build that throws must not leave the pool permanently unbuildable for the rest of the process. There is a test for that case specifically.

Notes

  • Nothing changes for any pool that builds normally. The guard is only reachable from inside a build of the same pool name.
  • I considered fixing this in RedisEngine instead by not logging from init(), but that only covers the one engine that happens to do it today and removes log output people may rely on. Guarding the construction path covers any engine and keeps the logging.
  • Verified the test is a real regression test: with the Cache.php change reverted, tests/TestCase/Cache/CacheTest.php --filter Reentrant crashes the PHP process rather than failing an assertion.
  • This came out of a production incident where a Redis unit failed to start after a reboot. PHP processes grew by GB per minute across every site on the host, the page cache was evicted, and per-minute cron jobs stacked up to 638 stuck processes at load average 257. The site that lost its cache should have degraded on its own instead of taking the machine with it.

Asking for a pool that is still being constructed recursed without bound. The
registry only holds the engine once the build has finished, so the reentrant
call found nothing registered, started another build, and repeated until the
process exhausted its stack or memory.

The reachable path is a cache engine that cannot connect and logs that failure.
RedisEngine::init() calls Log::error() from inside its connect path, and if any
configured log engine reaches for a cache-backed resource on its way to writing
the message, it lands back in Cache::pool() for the pool that is mid-build. A
database schema metadata cache is the usual way this closes, since log engines
that write to a table go through the connection's schema collection.

The NullEngine fallback already in _buildEngine() does not help here: it runs
from the catch around $registry->load(), and the recursion happens inside that
call, before the RuntimeException can propagate.

Track which pools are mid-build and hand a NullEngine to reentrant callers, so
the inner consumer degrades instead of looping. The marker is cleared in a
finally, so a failed build does not mark the pool unbuildable for the rest of
the process.

Without this, the added test crashes the PHP process outright rather than
failing an assertion.
Comment thread src/Cache/Cache.php Outdated
@jamisonbryant

Copy link
Copy Markdown
Contributor

I agree we should fix the issue at the lowest level that is reasonable rather than addressing a symptom by patching RedisEngine specifically. I know of at least two projects where userland code has extended RedisEngine, so it would be nice to have the patch in the core Cache facade.

@dereuromark

Copy link
Copy Markdown
Member Author

Well, my recommendation:
5.x gets the cache guard, 5.next gets the per-stream Log::write() guard. That leaves 5.x users protected on the route that actually took your host down, without shipping a logging semantics change in a patch release.

@dereuromark dereuromark added this to the 5.4.2 milestone Aug 6, 2026
@markstory
markstory merged commit 175d768 into 5.x Aug 7, 2026
15 checks passed
@markstory
markstory deleted the fix/cache-pool-reentrancy branch August 7, 2026 01:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants