Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions config/octane.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),


/*
|--------------------------------------------------------------------------
Expand Down
17 changes: 10 additions & 7 deletions src/OctaneServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
307 changes: 307 additions & 0 deletions src/Swoole/Database/MySqlStringBindingConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, \PDOStatement>
*/
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.
Expand All @@ -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);
}
}
Loading
Loading