Skip to content

Cache prepared statements per pooled connection - #18

Merged
adhikjoshi merged 1 commit into
mainfrom
perf/prepared-statement-cache
Aug 20, 2026
Merged

adhikjoshi merged 1 commit into
mainfrom
perf/prepared-statement-cache

Conversation

@adhikjoshi

@adhikjoshi adhikjoshi commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Laravel prepares every query fresh — one synchronous COM_STMT_PREPARE round trip plus parse work per query (PDO::prepare was 7.5% of gpulab PHP CPU, 4% frontend). An LRU cache (default 64) of PDOStatements per Connection, keyed by PDO instance + SQL, now sits in MySqlStringBindingConnection.

Covered paths: select, statement, affectingStatement, and MySqlConnection's own insert override (bypasses statement() to capture lastInsertId — every Eloquent create()). cursor()/selectResultSets() uncached by design.

Safety (all adversarially reviewed and test-pinned):

  • Cache lives on the Connection (one coroutine at a time via the pool); setPdo/setReadPdo flush it (disconnect + reconnector).
  • errno 1615 → evict + re-prepare once; errno 1461 (server stmt cap) → flush whole cache + retry once.
  • select() drains cursors in a finally — on the unbuffered connections two apps run, this also heals the pre-existing abandoned-result wedge (2014 pool poisoning) that vendor code could not.
  • Escape hatches OCTANE_MYSQL_STMT_CACHE / _SIZE, wired independently of OCTANE_MYSQL_STRING_BINDINGS (review catch: one hatch used to kill both features).
  • Known semantic deltas documented in the class docblock (short-bindings HY093 now silent; StatementPrepared fires on writes/hits; last bindings pinned until reuse/evict).

Evidence: 190 tests green; 11 cache tests incl. lastInsertId-per-execution, stale-results, LRU, read/write segmentation, 1615/1461 heal, hatch independence; 6 mutation tests kill guards. E2E on real Octane+Swoole+MySQL: 200 → 2 prepares per 100 requests, 18-probe battery byte-identical in BOTH buffered and unbuffered modes, 10 concurrency rounds with zero errors, statements bounded and freed on connection close.

Rollout: staggered per app, watching Prepared_stmt_count (cap 65,536; steady state ~10.6k today).

🤖 Generated with Claude Code


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

Laravel prepares every query fresh: one COM_STMT_PREPARE round trip
plus client and server parse work per query. Profiling put PDO::prepare
at 7.5% of gpulab's PHP CPU and 4% of the frontend's, and with
EMULATE_PREPARES=false every one of those is a synchronous network
round trip on the request path.

MySqlStringBindingConnection now keeps an LRU cache (default 64) of
PDOStatements keyed by PDO instance + SQL text. select, statement,
affectingStatement, and MySqlConnection's own insert override (which
bypasses statement() to capture lastInsertId) all reuse cached handles;
cursor() and selectResultSets() stay uncached by design. On a hit the
statement is closeCursor'd and re-run through prepared() so fetch mode
and the StatementPrepared event behave exactly as a fresh prepare.

Safety: the cache lives on the Connection, which the pool hands to one
coroutine at a time; setPdo/setReadPdo flush it (covers disconnect and
the reconnector swap); MySQL error 1615 evicts and re-prepares once;
results are freed eagerly since cached statements no longer die with
their request. Escape hatches: OCTANE_MYSQL_STMT_CACHE=false,
OCTANE_MYSQL_STMT_CACHE_SIZE.
@adhikjoshi
adhikjoshi merged commit 658776a into main Aug 20, 2026
1 check passed
@adhikjoshi
adhikjoshi deleted the perf/prepared-statement-cache branch August 20, 2026 23:41
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