Skip to content

Fix server-side prepared statement leak from pooled connections - #13

Merged
adhikjoshi merged 4 commits into
mainfrom
fix/prepared-statement-leak
Aug 20, 2026
Merged

adhikjoshi merged 4 commits into
mainfrom
fix/prepared-statement-leak

Conversation

@adhikjoshi

@adhikjoshi adhikjoshi commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Problem

Production MySQL hit max_prepared_stmt_count (error 1461) on Aug 19. The mstudio service held 10,086 of ~10,800 open server-side prepared statements — up to 1,220 on a single pooled connection, thousands of copies of the same two queries.

Mechanism (reproduced deterministically): a PDOStatement destroyed while its connection is mid-query in another coroutine leaks its server-side prepared statement permanently — mysqlnd skips COM_STMT_CLOSE when the connection is not READY and never retries. The Worker released each request's pooled connection back to the pool before the request's object graph died, so statements held in cycle garbage (unfinished generators, exception traces) were destroyed after another coroutine had already borrowed the connection. 100% reproduction: 20/20 statements leak when destroyed against a busy connection; 0/20 when destroyed against an idle one.

Fix

  1. Worker teardown ordering: detach pooled connections from context first, tear down request state (flush, Context::clear, unset including the caught exception), run gc_collect_cycles() at that safe point while the connections are still idle and unborrowable, and only then release them. A second sweep catches connections that destructors borrow during teardown, and per-request all-pool pruning is preserved.
  2. DatabasePool max_lifetime (default 300s, 0 disables): connections are recycled on release and in the pruner, bounding any residual leak. Abandoned transactions are rolled back before closing, since disconnect() only drops the PDO reference.

End-to-end validation with the real pool against MySQL performance_schema: old order leaks 20/20 statements, new order 0/20. All 160 package tests pass; every new guard is mutation-tested (fix removed → test red).

Immediate prod mitigation already applied on the DB (SET PERSIST max_prepared_stmt_count = 65536) and the leaking service was rolled. This PR is the root-cause fix.

Known residuals (pre-existing, out of scope)

  • Connections borrowed inside app-spawned child coroutines are never released by the Worker (pool counter drift) — needs a follow-up.
  • Garbage abandoned by one coroutine can still be collected while another coroutine's connection is busy; max_lifetime bounds this residue.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Statements a request leaves in cycle garbage were destroyed after the
connection re-entered the pool. mysqlnd skips COM_STMT_CLOSE while a
connection is busy in another coroutine and never retries, so every such
statement leaked server-side until MySQL's max_prepared_stmt_count
tripped (error 1461).

- Worker now detaches pooled connections, tears down request state, runs
  gc_collect_cycles() at that safe point, and only then releases the
  connections back to their pools.
- DatabasePool recycles connections past a configurable max_lifetime
  (default 300s) on release and in the pruner, bounding any residual
  leak from paths the worker cannot see.
- Detach pooled connections first in the finally block so a throw from the
  Livewire mutex or Redis release can never orphan a borrowed slot.
- Sweep connections that destructors borrow into context during teardown
  or gc, and fold flush-time borrows in before Context::clear().
- Unset the caught worker exception before releasing: its trace args
  reference the request graph, including PDO statements.
- Keep per-request pruning across ALL pools (previously done by
  DatabaseManager::releaseConnections, which the Worker no longer calls).
- Gate the teardown gc on gc_status()['roots'].
- Roll back abandoned transactions before recycling an expired connection:
  disconnect() only drops the PDO reference, so a leaked statement could
  keep the session and its row locks alive until a future gc.
- Restart the lifetime clock when the reconnector swaps in a fresh PDO.
- Close (instead of silently dropping) connections the pruner fails to
  re-pool, and trim the '.pool' suffix instead of str_replace.
@adhikjoshi
adhikjoshi merged commit 9c8ff8c into main Aug 20, 2026
3 checks passed
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