From 36d1665f8e0f70a0305bbf9e8f8a6fe8cf18bbde Mon Sep 17 00:00:00 2001 From: Adhik Joshi Date: Thu, 20 Aug 2026 21:39:21 +0530 Subject: [PATCH 1/3] Bind integer parameters as strings on MySQL connections MySQL 9.0.x re-prepares a statement on every execute that carries a PARAM_INT parameter; mysqlnd silently retries, adding two round trips per int-bound query. Measured at 226 reprepares/s (54% of executes) in production. Binding the same values as PARAM_STR produces identical plans and results with zero reprepares. Opt out with OCTANE_MYSQL_STRING_BINDINGS=false. --- config/octane.php | 15 +++++++ src/OctaneServiceProvider.php | 12 +++++ .../Database/MySqlStringBindingConnection.php | 42 +++++++++++++++++ .../MySqlStringBindingResolverTest.php | 21 +++++++++ .../Unit/MySqlStringBindingConnectionTest.php | 45 +++++++++++++++++++ 5 files changed, 135 insertions(+) create mode 100644 src/Swoole/Database/MySqlStringBindingConnection.php create mode 100644 tests/Feature/MySqlStringBindingResolverTest.php create mode 100644 tests/Unit/MySqlStringBindingConnectionTest.php diff --git a/config/octane.php b/config/octane.php index 30bd61e..a61dea7 100644 --- a/config/octane.php +++ b/config/octane.php @@ -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 diff --git a/src/OctaneServiceProvider.php b/src/OctaneServiceProvider.php index 208b917..01cb8d8 100644 --- a/src/OctaneServiceProvider.php +++ b/src/OctaneServiceProvider.php @@ -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 (config('octane.mysql_string_bindings', true) !== false) { + \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) { diff --git a/src/Swoole/Database/MySqlStringBindingConnection.php b/src/Swoole/Database/MySqlStringBindingConnection.php new file mode 100644 index 0000000..7279233 --- /dev/null +++ b/src/Swoole/Database/MySqlStringBindingConnection.php @@ -0,0 +1,42 @@ + + * Com_stmt_reprepare +1 per execute, PDO::PARAM_STR with the same value -> + * 0, identical results). mysqlnd hides the failure by silently re-preparing + * and re-executing, so every int-bound query pays two extra round trips - + * production measured 226 reprepares/second, 54% of all statement executes. + * + * String-typed parameters are cast once by the server at optimization time, + * exactly like a quoted literal, so plans, index use, and results are + * unchanged. (The dangerous direction is the opposite one: an int parameter + * compared against a varchar column disables index use - see the + * dreambooth_models.user_id incident.) + */ +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, + ); + } + } +} diff --git a/tests/Feature/MySqlStringBindingResolverTest.php b/tests/Feature/MySqlStringBindingResolverTest.php new file mode 100644 index 0000000..2ba9328 --- /dev/null +++ b/tests/Feature/MySqlStringBindingResolverTest.php @@ -0,0 +1,21 @@ +assertNotNull($resolver, 'The provider must register a mysql connection resolver.'); + + $connection = $resolver(new \stdClass(), 'db', '', ['driver' => 'mysql']); + + $this->assertInstanceOf(MySqlStringBindingConnection::class, $connection); + } +} diff --git a/tests/Unit/MySqlStringBindingConnectionTest.php b/tests/Unit/MySqlStringBindingConnectionTest.php new file mode 100644 index 0000000..8fad773 --- /dev/null +++ b/tests/Unit/MySqlStringBindingConnectionTest.php @@ -0,0 +1,45 @@ +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, '5', PDO::PARAM_STR); + $statement->shouldReceive('bindValue')->once()->with(2, 'abc', PDO::PARAM_STR); + $statement->shouldReceive('bindValue')->once()->with(3, null, PDO::PARAM_STR); + $statement->shouldReceive('bindValue')->once()->with('named', '42', PDO::PARAM_STR); + + $connection->bindValues($statement, [5, 'abc', null, '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, (string) PHP_INT_MAX, PDO::PARAM_STR); + + $connection->bindValues($statement, [PHP_INT_MAX]); + } +} From 07a0f36bbcb61a3884dd65446495469c7aaabed9 Mon Sep 17 00:00:00 2001 From: Adhik Joshi Date: Thu, 20 Aug 2026 21:39:58 +0530 Subject: [PATCH 2/3] Use strict argument matching in binding tests --- tests/Unit/MySqlStringBindingConnectionTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Unit/MySqlStringBindingConnectionTest.php b/tests/Unit/MySqlStringBindingConnectionTest.php index 8fad773..022c3ba 100644 --- a/tests/Unit/MySqlStringBindingConnectionTest.php +++ b/tests/Unit/MySqlStringBindingConnectionTest.php @@ -25,10 +25,10 @@ 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, '5', PDO::PARAM_STR); - $statement->shouldReceive('bindValue')->once()->with(2, 'abc', PDO::PARAM_STR); - $statement->shouldReceive('bindValue')->once()->with(3, null, PDO::PARAM_STR); - $statement->shouldReceive('bindValue')->once()->with('named', '42', PDO::PARAM_STR); + $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); + $statement->shouldReceive('bindValue')->once()->with('named', Mockery::mustBe('42'), PDO::PARAM_STR); $connection->bindValues($statement, [5, 'abc', null, 'named' => 42]); } @@ -38,7 +38,7 @@ 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, (string) PHP_INT_MAX, PDO::PARAM_STR); + $statement->shouldReceive('bindValue')->once()->with(1, Mockery::mustBe((string) PHP_INT_MAX), PDO::PARAM_STR); $connection->bindValues($statement, [PHP_INT_MAX]); } From 6cf6a992e9c0432467ec3d240607385567a296f2 Mon Sep 17 00:00:00 2001 From: Adhik Joshi Date: Thu, 20 Aug 2026 22:17:04 +0530 Subject: [PATCH 3/3] Address review: boolean gate spellings, comment mechanism, test realism - The gate now honors '0'/'off'/'no' via FILTER_VALIDATE_BOOL, not just boolean false - operators disable features with '0' during incidents. - The class comment claimed string parameters behave 'exactly like a quoted literal'; parameters are exact where literals compare as DOUBLE (adjacent-bigint probe beyond 2^53). Rewritten so nobody extends the literal reasoning into interpolated SQL. - The unit test mixed positional and named placeholders in one statement, which real PDO rejects; split into two statements. --- src/OctaneServiceProvider.php | 2 +- .../Database/MySqlStringBindingConnection.php | 30 ++++++++++++------- .../Unit/MySqlStringBindingConnectionTest.php | 8 +++-- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/OctaneServiceProvider.php b/src/OctaneServiceProvider.php index 01cb8d8..c30affa 100644 --- a/src/OctaneServiceProvider.php +++ b/src/OctaneServiceProvider.php @@ -55,7 +55,7 @@ public function register() // 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 (config('octane.mysql_string_bindings', true) !== 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); }); diff --git a/src/Swoole/Database/MySqlStringBindingConnection.php b/src/Swoole/Database/MySqlStringBindingConnection.php index 7279233..921b0da 100644 --- a/src/Swoole/Database/MySqlStringBindingConnection.php +++ b/src/Swoole/Database/MySqlStringBindingConnection.php @@ -8,18 +8,26 @@ /** * Binds integer parameters as strings. * - * MySQL 9.0.1 re-prepares a statement 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). mysqlnd hides the failure by silently re-preparing - * and re-executing, so every int-bound query pays two extra round trips - - * production measured 226 reprepares/second, 54% of all statement executes. + * 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. * - * String-typed parameters are cast once by the server at optimization time, - * exactly like a quoted literal, so plans, index use, and results are - * unchanged. (The dangerous direction is the opposite one: an int parameter - * compared against a varchar column disables index use - see the - * dreambooth_models.user_id incident.) + * 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 { diff --git a/tests/Unit/MySqlStringBindingConnectionTest.php b/tests/Unit/MySqlStringBindingConnectionTest.php index 022c3ba..7a30fc8 100644 --- a/tests/Unit/MySqlStringBindingConnectionTest.php +++ b/tests/Unit/MySqlStringBindingConnectionTest.php @@ -28,9 +28,13 @@ public function test_integers_are_bound_as_strings(): void $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); - $statement->shouldReceive('bindValue')->once()->with('named', Mockery::mustBe('42'), PDO::PARAM_STR); - $connection->bindValues($statement, [5, 'abc', null, 'named' => 42]); + $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