From adbcd1221a4a6a2bc1fd2559d0a3c29f932d8b9c Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:40:10 +0100 Subject: [PATCH] feat: bound MySQL writes with session lock-wait timeout MySQL::setTimeout only injected a max_execution_time optimizer hint, which MySQL honors for read-only SELECTs only. INSERT/UPDATE/DDL had no app-level bound: row-lock waits sat for the 50s default and metadata-lock waits for lock_wait_timeout's one-year default, holding the connection the whole time. Add a session-level write bound (innodb_lock_wait_timeout + lock_wait_timeout) so blocked writes fail fast with error 1205 and release their connection, and map 1205 to TimeoutException. The bound is a connection-scoped floor that Pool re-applies on each checkout, so there's no paired clearTimeout reset to leak through. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Database/Adapter/MySQL.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index 5aaa28107..30223aec7 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -32,6 +32,7 @@ public function setTimeout(int $milliseconds, string $event = Database::EVENT_AL $this->timeout = $milliseconds; + // Bound reads: the max_execution_time optimizer hint only applies to SELECTs. $this->before($event, 'timeout', function ($sql) use ($milliseconds) { return \preg_replace( pattern: '/SELECT/', @@ -40,6 +41,13 @@ public function setTimeout(int $milliseconds, string $event = Database::EVENT_AL limit: 1 ); }); + + // Bound writes: hints are ignored by INSERT/UPDATE/DDL, so cap how long a + // statement waits on row (InnoDB) and metadata locks before failing fast. + // This is a connection-scoped floor: Pool re-applies it on each checkout, + // so it tracks the latest timeout without a paired reset to leak through. + $seconds = \max(1, (int) \ceil($milliseconds / 1000)); + $this->getPDO()->exec("SET SESSION innodb_lock_wait_timeout = {$seconds}, SESSION lock_wait_timeout = {$seconds}"); } /** @@ -162,6 +170,11 @@ protected function processException(PDOException $e): \Exception return new TimeoutException('Query timed out', $e->getCode(), $e); } + // Lock wait timeout (blocked write released by innodb_lock_wait_timeout/lock_wait_timeout) + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1205) { + return new TimeoutException('Query timed out', $e->getCode(), $e); + } + // Functional index dependency if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 3837) { return new DependencyException('Attribute cannot be deleted because it is used in an index', $e->getCode(), $e);