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
15 changes: 15 additions & 0 deletions config/octane.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@

return [

/*
|--------------------------------------------------------------------------
| MySQL Integer Bindings as Strings
|--------------------------------------------------------------------------
|
| MySQL 9.0.x re-prepares statements on every execute that carries an
| integer-typed parameter (mysqlnd silently retries: two extra round
| trips per int-bound query). Binding integers as strings avoids it with
| identical plans and results.
|
*/

'mysql_string_bindings' => env('OCTANE_MYSQL_STRING_BINDINGS', true),


/*
|--------------------------------------------------------------------------
| Octane Server
Expand Down
12 changes: 12 additions & 0 deletions src/OctaneServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ 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) {
\Illuminate\Database\Connection::resolverFor('mysql', function ($connection, $database, $prefix, $config) {
return new \Laravel\Octane\Swoole\Database\MySqlStringBindingConnection($connection, $database, $prefix, $config);
});
}

$this->bindCoroutineRedisManager();

$this->app->bind(RoadRunnerServerProcessInspector::class, function ($app) {
Expand Down
50 changes: 50 additions & 0 deletions src/Swoole/Database/MySqlStringBindingConnection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace Laravel\Octane\Swoole\Database;

use Illuminate\Database\MySqlConnection;
use PDO;

/**
* Binds integer parameters as strings.
*
* MySQL 9.0.1 re-prepares a statement server-side on EVERY execute that
* carries an integer-typed binary-protocol parameter (measured:
* PDO::PARAM_INT -> Com_stmt_reprepare +1 per execute, PDO::PARAM_STR with
* the same value -> 0, identical results). No extra round trips - the server
* transparently parses and plans the whole statement again, costing ~15-50us
* of server CPU per execute. Production measured 226 reprepares/second, 54%
* of all statement executes.
*
* A string PARAMETER stays exact: the server converts it to the column's
* type with full precision at execute time, so plans, index use, and results
* are unchanged - verified for adjacent bigints beyond 2^53, where a quoted
* LITERAL would compare as DOUBLE and merge distinct values. Do not extend
* this reasoning to literals interpolated into SQL text.
*
* Two semantic footnotes. An int bound against a VARCHAR column previously
* compared numerically ('05' matched 5) and defeated the index; it now
* compares as a string - every id-like varchar column in production was
* 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.
*/
class MySqlStringBindingConnection extends MySqlConnection
{
/**
* @param \PDOStatement $statement
* @param array $bindings
*/
public function bindValues($statement, $bindings): void
{
foreach ($bindings as $key => $value) {
// Identical to Illuminate\Database\Connection::bindValues except
// integers: stringified and sent as PARAM_STR.
$statement->bindValue(
is_string($key) ? $key : $key + 1,
is_int($value) ? (string) $value : $value,
is_resource($value) ? PDO::PARAM_LOB : PDO::PARAM_STR,
);
}
}
}
21 changes: 21 additions & 0 deletions tests/Feature/MySqlStringBindingResolverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Tests\Feature;

use Illuminate\Database\Connection;
use Laravel\Octane\Swoole\Database\MySqlStringBindingConnection;
use Tests\TestCase;

class MySqlStringBindingResolverTest extends TestCase
{
public function test_mysql_connections_resolve_to_the_string_binding_class(): void
{
$resolver = Connection::getResolver('mysql');

$this->assertNotNull($resolver, 'The provider must register a mysql connection resolver.');

$connection = $resolver(new \stdClass(), 'db', '', ['driver' => 'mysql']);

$this->assertInstanceOf(MySqlStringBindingConnection::class, $connection);
}
}
49 changes: 49 additions & 0 deletions tests/Unit/MySqlStringBindingConnectionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace Tests\Unit;

use Illuminate\Database\Connection;
use Laravel\Octane\Swoole\Database\MySqlStringBindingConnection;
use Mockery;
use PDO;
use PHPUnit\Framework\TestCase;

class MySqlStringBindingConnectionTest extends TestCase
{
protected function tearDown(): void
{
if ($container = Mockery::getContainer()) {
$this->addToAssertionCount($container->mockery_getExpectationCount());
}

Mockery::close();
parent::tearDown();
}

public function test_integers_are_bound_as_strings(): void
{
$connection = new MySqlStringBindingConnection(new \stdClass(), 'db', '', []);

$statement = Mockery::mock(\PDOStatement::class);
$statement->shouldReceive('bindValue')->once()->with(1, Mockery::mustBe('5'), PDO::PARAM_STR);
$statement->shouldReceive('bindValue')->once()->with(2, Mockery::mustBe('abc'), PDO::PARAM_STR);
$statement->shouldReceive('bindValue')->once()->with(3, Mockery::mustBe(null), PDO::PARAM_STR);

$connection->bindValues($statement, [5, 'abc', null]);

$named = Mockery::mock(\PDOStatement::class);
$named->shouldReceive('bindValue')->once()->with('named', Mockery::mustBe('42'), PDO::PARAM_STR);

$connection->bindValues($named, ['named' => 42]);
}

public function test_large_integers_stringify_losslessly(): void
{
$connection = new MySqlStringBindingConnection(new \stdClass(), 'db', '', []);

$statement = Mockery::mock(\PDOStatement::class);
$statement->shouldReceive('bindValue')->once()->with(1, Mockery::mustBe((string) PHP_INT_MAX), PDO::PARAM_STR);

$connection->bindValues($statement, [PHP_INT_MAX]);
}
}
Loading