Skip to content

ConnectionPool: release the claimed slot when the factory throws - #46

Merged
syntaxwanderer merged 1 commit into
developfrom
fix/connection-pool-slot-leak
Jul 20, 2026
Merged

ConnectionPool: release the claimed slot when the factory throws#46
syntaxwanderer merged 1 commit into
developfrom
fix/connection-pool-slot-leak

Conversation

@syntaxwanderer

Copy link
Copy Markdown
Member

Problem

ConnectionPool::pop() (and fill()) claim a pool slot with an atomic cmpset before calling the connection factory. The factory is a real new PDO(...) connect, which throws on any failure — a transient network blip, MySQL momentarily refusing, an auth hiccup. When it threw, the exception propagated out with the slot still counted: nothing anywhere decrements created except full teardown.

So every failed connect permanently burned a slot. After size such failures the pool is full-but-empty forever — the fast-path guard isEmpty() && current < size is never true again, so every pop() falls through to Channel->pop($timeout) with the default timeout = -1 and blocks its caller indefinitely, waiting for a connection that will never be pushed. A ratcheting deadlock that only a process restart cleared.

Production impact

This took down production. Over ~4 days of uptime the busiest (default) connection accumulated slot leaks until all Swoole workers were asleep in ConnectionPool::pop() and every request hung. MySQL itself was healthy and nowhere near its connection limit (Max_used_connections 88 / 151) — because the "held" connections were phantom slots that were never actually opened. That mismatch (app deadlocked, DB fine) is the signature.

Fix

Wrap the factory call in try/catch and roll back the cmpset with created->sub(1) before rethrowing, in both pop() and fill(). A failed connect now leaves the slot free for the next attempt, so transient DB unavailability degrades gracefully (the request errors) instead of permanently poisoning the pool.

Test

Adds pop_releases_the_claimed_slot_when_the_factory_throws: drives repeated factory failures and asserts the slot counter never leaks and the pool stays usable once the factory recovers. It fails fast (asserts created == 0 before any blocking pop()) rather than hanging against the unpatched code — verified: it errors with "Failed asserting that 1 is identical to 0" on the old code and passes on the fix.

🤖 Generated with Claude Code

pop() (and fill()) claim a pool slot with an atomic cmpset BEFORE calling
the connection factory, but the factory is a real PDO connect that can
throw — a transient network blip, MySQL momentarily refusing, an auth
hiccup. When it threw, the exception propagated out with the slot still
counted: nothing anywhere decrements `created` except full teardown.

Each failed connect therefore permanently burned a slot. After `size`
such failures the pool was full-but-empty forever: the fast-path guard
`isEmpty() && current < size` is never true again, so every pop() falls
through to `Channel->pop($timeout)` with the default timeout of -1 and
blocks its caller indefinitely, waiting for a connection that will never
be pushed. A ratcheting deadlock that only a restart cleared.

This took down production: over ~4 days of uptime the busiest (default)
connection accumulated slot leaks until all Swoole workers were asleep in
ConnectionPool::pop() and every request hung — while MySQL itself was
healthy and nowhere near its connection limit, because the "held"
connections were phantom slots that were never actually opened.

Fix: wrap the factory call in try/catch and roll back the cmpset with
`created->sub(1)` before rethrowing, in both pop() and fill(). A failed
connect now leaves the slot free for the next attempt.

Adds a regression test that drives repeated factory failures and asserts
the slot counter never leaks and the pool stays usable once the factory
recovers. The test fails fast (not hangs) against the unpatched code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cca7f1c8-6319-43fe-a915-0b195ea6cac1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/connection-pool-slot-leak

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@syntaxwanderer
syntaxwanderer requested a review from Copilot July 20, 2026 05:15

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9fa5bf23f0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Channel->pop() below and (with timeout -1) blocks every
// caller indefinitely. That ratcheting deadlock took down
// production with all workers asleep in pop().
$this->created->sub(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wake or retry waiters after releasing a failed slot

When a DB outage hits while more coroutines are calling pop() than the pool size, the extra callers can already be blocked in Channel->pop(-1) before the in-flight factory attempts throw. This rollback frees capacity, but it does not wake those waiters or make them re-check created < size, so if all factory attempts fail and no later request enters pop(), those existing requests remain asleep indefinitely even though the slot counter is back below the limit.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a slot-leak bug in Semitexa\Orm\Adapter\ConnectionPool where a pool slot was claimed (via Atomic::cmpset) before invoking the connection factory, and a thrown exception would previously leave the created counter permanently incremented—eventually wedging the pool into a “full-but-empty” state that can block indefinitely.

Changes:

  • Roll back the claimed created slot in ConnectionPool::pop() when the factory throws, then rethrow the exception.
  • Apply the same rollback behavior in ConnectionPool::fill() when the factory throws.
  • Add a PHPUnit test that repeatedly simulates factory failure and asserts created never leaks and the pool remains usable after recovery.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
tests/Unit/Adapter/ConnectionPoolTest.php Adds regression coverage ensuring factory exceptions don’t leak pool slots and the pool recovers.
src/Adapter/ConnectionPool.php Adds try/catch rollback logic to prevent created counter leaks in pop() and fill().

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@syntaxwanderer
syntaxwanderer merged commit 9fa5bf2 into develop Jul 20, 2026
2 checks passed
@syntaxwanderer
syntaxwanderer deleted the fix/connection-pool-slot-leak branch July 20, 2026 05:43
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.

2 participants