diff --git a/config/octane.php b/config/octane.php index 05da47e..7fcf568 100644 --- a/config/octane.php +++ b/config/octane.php @@ -39,6 +39,22 @@ 'mysql_string_bindings' => env('OCTANE_MYSQL_STRING_BINDINGS', true), + /* + |-------------------------------------------------------------------------- + | MySQL Prepared-Statement Cache + |-------------------------------------------------------------------------- + | + | Reuse prepared statements per pooled connection (LRU, keyed by SQL). + | Saves one COM_STMT_PREPARE round trip plus parse work on every + | repeated query. Flushed automatically when the underlying PDO is + | swapped. Watch Prepared_stmt_count server-side when raising the size. + | + */ + + 'mysql_statement_cache' => env('OCTANE_MYSQL_STMT_CACHE', true), + + 'mysql_statement_cache_size' => env('OCTANE_MYSQL_STMT_CACHE_SIZE', 64), + /* |-------------------------------------------------------------------------- diff --git a/src/OctaneServiceProvider.php b/src/OctaneServiceProvider.php index c30affa..566d7c0 100644 --- a/src/OctaneServiceProvider.php +++ b/src/OctaneServiceProvider.php @@ -49,13 +49,16 @@ public function register() return new \Laravel\Octane\Swoole\Database\DatabaseManager($app, $app['db.factory']); }); - // MySQL 9.0.x re-prepares statements on every execute that carries an - // integer-typed parameter; mysqlnd silently retries, costing two extra - // round trips per int-bound query (measured at 54% of all executes in - // production). Bind integers as strings instead - the server casts the - // constant once, plans and results are identical. Escape hatch: - // OCTANE_MYSQL_STRING_BINDINGS=false. - if (filter_var(config('octane.mysql_string_bindings', true), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true) { + // The custom mysql Connection carries two independently-gated + // features: string integer bindings (kills MySQL 9.0's per-execute + // reprepare; OCTANE_MYSQL_STRING_BINDINGS=false) and the prepared- + // statement cache (OCTANE_MYSQL_STMT_CACHE=false). Install it when + // either is on - the class checks each flag itself, so disabling one + // never silently disables the other. + $stringBindings = filter_var(config('octane.mysql_string_bindings', true), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true; + $statementCache = filter_var(config('octane.mysql_statement_cache', true), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true; + + if ($stringBindings || $statementCache) { \Illuminate\Database\Connection::resolverFor('mysql', function ($connection, $database, $prefix, $config) { return new \Laravel\Octane\Swoole\Database\MySqlStringBindingConnection($connection, $database, $prefix, $config); }); diff --git a/src/Swoole/Database/MySqlStringBindingConnection.php b/src/Swoole/Database/MySqlStringBindingConnection.php index 921b0da..b9b6885 100644 --- a/src/Swoole/Database/MySqlStringBindingConnection.php +++ b/src/Swoole/Database/MySqlStringBindingConnection.php @@ -28,15 +28,50 @@ * scanned for non-canonical numeric values before shipping (zero found). * And the dangerous direction remains the old one: an int parameter against * a varchar column disables index use - see dreambooth_models.user_id. + * + * Statement-cache semantic deltas (measured and accepted): + * - A bindings array SHORTER than the placeholder count used to throw + * HY093; on a cached statement the leftover value from the previous + * execution fills the gap silently. Query-builder SQL always binds + * fully; hand-built SQL with conditional bindings must too. + * - prepared() now runs on write paths and cache hits, so the + * StatementPrepared event fires there as well (no app listens today). + * - A cached statement pins its last bound values (and any LOB stream) + * until the same SQL runs again, eviction, flush, or the pool's + * max_lifetime recycle. */ class MySqlStringBindingConnection extends MySqlConnection { + /** + * Prepared statements cached per PDO, keyed by SQL text, LRU-capped. + * + * Laravel prepares every query fresh: one COM_STMT_PREPARE network round + * trip plus client and server parse work per query (7.5% of gpulab's PHP + * CPU, 4% of the frontend's, was PDO::prepare). Statement handles are + * per-session, so the cache lives on this Connection - which the octane + * pool hands to exactly one coroutine at a time - and is flushed whenever + * the underlying PDO is swapped (disconnect, reconnector). + * + * @var array + */ + protected array $statementCache = []; + + protected ?bool $statementCacheEnabled = null; + + protected ?int $statementCacheLimit = null; + /** * @param \PDOStatement $statement * @param array $bindings */ public function bindValues($statement, $bindings): void { + if (! $this->stringBindingsAreEnabled()) { + parent::bindValues($statement, $bindings); + + return; + } + foreach ($bindings as $key => $value) { // Identical to Illuminate\Database\Connection::bindValues except // integers: stringified and sent as PARAM_STR. @@ -47,4 +82,276 @@ public function bindValues($statement, $bindings): void ); } } + + protected ?bool $stringBindingsEnabled = null; + + protected function stringBindingsAreEnabled(): bool + { + return $this->stringBindingsEnabled ??= $this->octaneFlag('octane.mysql_string_bindings'); + } + + /** + * Read a boolean octane config flag, defaulting when no application + * container exists (pure-PDO contexts, very early boot). + */ + protected function octaneFlag(string $key, bool $default = true): bool + { + try { + $value = config($key, $default); + } catch (\Throwable) { + return $default; + } + + return (bool) (filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? $default); + } + + /** + * {@inheritdoc} + */ + public function select($query, $bindings = [], $useReadPdo = true) + { + if (! $this->statementCacheIsEnabled()) { + return parent::select($query, $bindings, $useReadPdo); + } + + return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) { + if ($this->pretending()) { + return []; + } + + $pdo = $this->getPdoForSelect($useReadPdo); + $statement = $this->cachedPrepare($pdo, $query); + + $this->bindValues($statement, $this->prepareBindings($bindings)); + + try { + $this->executeCached($statement, $pdo, $query, $bindings); + + return $statement->fetchAll(); + } finally { + // Always drain: a cached statement no longer frees its result + // in a destructor, and on the unbuffered connections two of + // the apps run, an abandoned result set wedges the session + // (2014 on every later query) - closeCursor un-wedges it. + try { + $statement->closeCursor(); + } catch (\Throwable) { + // The connection may already be gone; nothing to drain. + } + } + }); + } + + /** + * {@inheritdoc} + * + * MySqlConnection overrides insert() past statement() to capture + * lastInsertId, so it needs its own cached variant - this is the + * hottest write path there is (every Eloquent create()). + */ + public function insert($query, $bindings = [], $sequence = null) + { + if (! $this->statementCacheIsEnabled()) { + return parent::insert($query, $bindings, $sequence); + } + + return $this->run($query, $bindings, function ($query, $bindings) use ($sequence) { + if ($this->pretending()) { + return true; + } + + $pdo = $this->getPdo(); + $statement = $this->cachedPrepare($pdo, $query); + + $this->bindValues($statement, $this->prepareBindings($bindings)); + + $this->recordsHaveBeenModified(); + + $result = $this->executeCached($statement, $pdo, $query, $bindings); + + $this->lastInsertId = $this->getPdo()->lastInsertId($sequence); + + $statement->closeCursor(); + + return $result; + }); + } + + /** + * {@inheritdoc} + */ + public function statement($query, $bindings = []) + { + if (! $this->statementCacheIsEnabled()) { + return parent::statement($query, $bindings); + } + + return $this->run($query, $bindings, function ($query, $bindings) { + if ($this->pretending()) { + return true; + } + + $pdo = $this->getPdo(); + $statement = $this->cachedPrepare($pdo, $query); + + $this->bindValues($statement, $this->prepareBindings($bindings)); + + $this->recordsHaveBeenModified(); + + $result = $this->executeCached($statement, $pdo, $query, $bindings); + + $statement->closeCursor(); + + return $result; + }); + } + + /** + * {@inheritdoc} + */ + public function affectingStatement($query, $bindings = []) + { + if (! $this->statementCacheIsEnabled()) { + return parent::affectingStatement($query, $bindings); + } + + return $this->run($query, $bindings, function ($query, $bindings) { + if ($this->pretending()) { + return 0; + } + + $pdo = $this->getPdo(); + $statement = $this->cachedPrepare($pdo, $query); + + $this->bindValues($statement, $this->prepareBindings($bindings)); + + $this->executeCached($statement, $pdo, $query, $bindings); + + $this->recordsHaveBeenModified( + ($count = $statement->rowCount()) > 0 + ); + + $statement->closeCursor(); + + return $count; + }); + } + + /** + * The cache key must identify the SQL AND the server session: the read + * PDO is a different session than the write PDO, and statement handles + * do not survive a session swap. + */ + protected function cachedPrepare(PDO $pdo, string $query): \PDOStatement + { + $key = spl_object_id($pdo).'|'.$query; + + if (isset($this->statementCache[$key])) { + $statement = $this->statementCache[$key]; + + // Re-append for LRU recency, clear any leftover result state, + // and re-run prepared() so the fetch mode and StatementPrepared + // event behave exactly as they would on a fresh prepare. + unset($this->statementCache[$key]); + $this->statementCache[$key] = $statement; + $statement->closeCursor(); + + return $this->prepared($statement); + } + + try { + $statement = $this->prepared($pdo->prepare($query)); + } catch (\PDOException $e) { + // 1461: the server hit max_prepared_stmt_count. This connection's + // cache is part of the pressure - drop it all and retry once with + // an empty cache before surfacing the error. + if (($e->errorInfo[1] ?? null) !== 1461) { + throw $e; + } + + $this->flushStatementCache(); + $statement = $this->prepared($pdo->prepare($query)); + } + + $this->statementCache[$key] = $statement; + + if (count($this->statementCache) > $this->statementCacheLimit()) { + array_shift($this->statementCache); + } + + return $statement; + } + + /** + * Execute, healing MySQL error 1615 ("Prepared statement needs to be + * re-prepared") once by evicting and re-preparing. MySQL re-prepares + * server-side transparently on metadata change; 1615 only surfaces when + * table_definition_cache thrashes, and a fresh prepare resolves it. + * + * @param-out \PDOStatement $statement + */ + protected function executeCached(\PDOStatement &$statement, PDO $pdo, string $query, array $bindings): bool + { + try { + return $statement->execute(); + } catch (\PDOException $e) { + if (($e->errorInfo[1] ?? null) !== 1615) { + throw $e; + } + + unset($this->statementCache[spl_object_id($pdo).'|'.$query]); + + $statement = $this->cachedPrepare($pdo, $query); + $this->bindValues($statement, $this->prepareBindings($bindings)); + + return $statement->execute(); + } + } + + /** + * {@inheritdoc} + */ + public function setPdo($pdo) + { + $this->flushStatementCache(); + + return parent::setPdo($pdo); + } + + /** + * {@inheritdoc} + */ + public function setReadPdo($pdo) + { + $this->flushStatementCache(); + + return parent::setReadPdo($pdo); + } + + /** + * Statement handles belong to the server session behind the PDO that + * prepared them; when any PDO is swapped they must all go. Covers + * disconnect() and the pool reconnector, both of which route through + * setPdo()/setReadPdo(). + */ + public function flushStatementCache(): void + { + $this->statementCache = []; + } + + protected function statementCacheIsEnabled(): bool + { + return $this->statementCacheEnabled ??= $this->octaneFlag('octane.mysql_statement_cache'); + } + + protected function statementCacheLimit(): int + { + try { + $size = (int) config('octane.mysql_statement_cache_size', 64); + } catch (\Throwable) { + $size = 64; + } + + return $this->statementCacheLimit ??= max(1, $size); + } } diff --git a/tests/Feature/StatementCacheTest.php b/tests/Feature/StatementCacheTest.php new file mode 100644 index 0000000..ecde6b5 --- /dev/null +++ b/tests/Feature/StatementCacheTest.php @@ -0,0 +1,220 @@ +prepareCalls++; + + return parent::prepare($query, $options); + } +} + +class StatementCacheTest extends TestCase +{ + protected function makeConnection(?PrepareCountingPdo &$pdo = null): MySqlStringBindingConnection + { + $pdo = new PrepareCountingPdo('sqlite::memory:'); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->exec('create table items (id integer primary key autoincrement, name text, qty integer)'); + + return new MySqlStringBindingConnection($pdo, 'db', '', ['driver' => 'mysql']); + } + + public function test_repeated_selects_prepare_once_and_stay_correct(): void + { + $connection = $this->makeConnection($pdo); + $connection->insert('insert into items (name, qty) values (?, ?)', ['alpha', 1]); + $connection->insert('insert into items (name, qty) values (?, ?)', ['beta', 2]); + + $base = $pdo->prepareCalls; + + $first = $connection->select('select name, qty from items where qty >= ? order by id', [1]); + $second = $connection->select('select name, qty from items where qty >= ? order by id', [2]); + + $this->assertSame($base + 1, $pdo->prepareCalls, 'The second select must reuse the cached statement.'); + $this->assertCount(2, $first); + $this->assertCount(1, $second); + $this->assertSame('beta', $second[0]->name); + } + + public function test_cached_selects_see_fresh_data(): void + { + $connection = $this->makeConnection($pdo); + $connection->insert('insert into items (name, qty) values (?, ?)', ['alpha', 1]); + + $this->assertCount(1, $connection->select('select * from items', [])); + + $connection->insert('insert into items (name, qty) values (?, ?)', ['beta', 2]); + + $this->assertCount(2, $connection->select('select * from items', []), 'A cached statement must never serve stale results.'); + } + + public function test_writes_reuse_the_cached_statement_with_new_bindings(): void + { + $connection = $this->makeConnection($pdo); + $base = $pdo->prepareCalls; + + $connection->insert('insert into items (name, qty) values (?, ?)', ['alpha', 1]); + $connection->insert('insert into items (name, qty) values (?, ?)', ['beta', 2]); + $connection->insert('insert into items (name, qty) values (?, ?)', ['gamma', 3]); + + $this->assertSame($base + 1, $pdo->prepareCalls); + $this->assertSame(3, (int) $connection->selectOne('select count(*) as c from items')->c); + + $affected = $connection->update('update items set qty = qty + 1 where qty >= ?', [2]); + $this->assertSame(2, $affected); + + $affected = $connection->update('update items set qty = qty + 1 where qty >= ?', [100]); + $this->assertSame(0, $affected, 'rowCount must be per-execution, never remembered from the cached statement.'); + } + + public function test_last_insert_id_is_per_execution_on_a_cached_statement(): void + { + $connection = $this->makeConnection($pdo); + + $connection->insert('insert into items (name, qty) values (?, ?)', ['alpha', 1]); + $this->assertSame('1', (string) $connection->getLastInsertId()); + + $connection->insert('insert into items (name, qty) values (?, ?)', ['beta', 2]); + $this->assertSame('2', (string) $connection->getLastInsertId(), 'lastInsertId must track every execution, not the first prepare.'); + } + + public function test_lru_evicts_oldest_statement_at_the_cap(): void + { + config(['octane.mysql_statement_cache_size' => 2]); + + $connection = $this->makeConnection($pdo); + $base = $pdo->prepareCalls; + + $connection->select('select 1 as a', []); + $connection->select('select 2 as a', []); + $connection->select('select 3 as a', []); // evicts "select 1" + $this->assertSame($base + 3, $pdo->prepareCalls); + + $connection->select('select 3 as a', []); // hit + $this->assertSame($base + 3, $pdo->prepareCalls); + + $connection->select('select 1 as a', []); // evicted -> fresh prepare + $this->assertSame($base + 4, $pdo->prepareCalls); + } + + public function test_swapping_the_pdo_flushes_the_cache(): void + { + $connection = $this->makeConnection($pdo); + + $connection->select('select 1 as a', []); + $before = $pdo->prepareCalls; + + $connection->setPdo($pdo); + + $connection->select('select 1 as a', []); + $this->assertSame($before + 1, $pdo->prepareCalls, 'A PDO swap must invalidate every cached statement.'); + } + + public function test_read_and_write_sessions_cache_separately(): void + { + $connection = $this->makeConnection($pdo); + + $readPdo = new PrepareCountingPdo('sqlite::memory:'); + $readPdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $readPdo->exec('create table items (id integer primary key, name text, qty integer)'); + $connection->setReadPdo($readPdo); + + $connection->select('select 1 as a', [], true); + $connection->select('select 1 as a', [], false); + + $this->assertSame(1, $readPdo->prepareCalls); + $this->assertGreaterThanOrEqual(1, $pdo->prepareCalls); + } + + public function test_disabled_cache_prepares_every_query(): void + { + config(['octane.mysql_statement_cache' => false]); + + $connection = $this->makeConnection($pdo); + $base = $pdo->prepareCalls; + + $connection->select('select 1 as a', []); + $connection->select('select 1 as a', []); + + $this->assertSame($base + 2, $pdo->prepareCalls); + } + + public function test_disabling_string_bindings_keeps_the_cache_and_vice_versa(): void + { + config(['octane.mysql_string_bindings' => false]); + + $connection = $this->makeConnection($pdo); + $base = $pdo->prepareCalls; + + $connection->select('select 1 as a', []); + $connection->select('select 1 as a', []); + + $this->assertSame($base + 1, $pdo->prepareCalls, 'The statement cache must survive the string-bindings hatch.'); + } + + public function test_server_statement_cap_1461_flushes_and_retries_once(): void + { + $capError = new \PDOException('SQLSTATE[42000]: 1461 Can\'t create more than max_prepared_stmt_count statements'); + $capError->errorInfo = ['42000', 1461, 'max_prepared_stmt_count']; + + $goodStatement = Mockery::mock(\PDOStatement::class); + $goodStatement->shouldReceive('setFetchMode'); + $goodStatement->shouldReceive('bindValue')->andReturn(true); + $goodStatement->shouldReceive('execute')->once()->andReturn(true); + $goodStatement->shouldReceive('fetchAll')->andReturn([['a' => 1]]); + $goodStatement->shouldReceive('closeCursor'); + + $pdo = Mockery::mock(PDO::class); + $pdo->shouldReceive('prepare')->twice()->andReturnUsing( + function () use ($capError) { throw $capError; }, + fn () => $goodStatement + ); + + $connection = new MySqlStringBindingConnection($pdo, 'db', '', ['driver' => 'mysql']); + + $rows = $connection->select('select a from t', []); + + $this->assertSame([['a' => 1]], $rows); + } + + public function test_error_1615_evicts_and_reprepares_once(): void + { + $goodStatement = Mockery::mock(PDOStatement::class); + $goodStatement->shouldReceive('execute')->once()->andReturn(true); + $goodStatement->shouldReceive('setFetchMode'); + $goodStatement->shouldReceive('fetchAll')->andReturn([['a' => 1]]); + $goodStatement->shouldReceive('closeCursor'); + $goodStatement->shouldReceive('bindValue')->andReturn(true); + + $needsReprepare = new \PDOException('SQLSTATE[HY000]: General error: 1615 Prepared statement needs to be re-prepared'); + $needsReprepare->errorInfo = ['HY000', 1615, 'Prepared statement needs to be re-prepared']; + + $staleStatement = Mockery::mock(PDOStatement::class); + $staleStatement->shouldReceive('setFetchMode'); + $staleStatement->shouldReceive('closeCursor'); + $staleStatement->shouldReceive('bindValue')->andReturn(true); + $staleStatement->shouldReceive('execute')->once()->andThrow($needsReprepare); + + $pdo = Mockery::mock(PDO::class); + $pdo->shouldReceive('prepare')->twice()->andReturn($staleStatement, $goodStatement); + + $connection = new MySqlStringBindingConnection($pdo, 'db', '', ['driver' => 'mysql']); + + $rows = $connection->select('select a from t where x = ?', [5]); + + $this->assertSame([['a' => 1]], $rows); + } +}