feat(db): add optional per-statement timeout to db_* functions#7209
feat(db): add optional per-statement timeout to db_* functions#7209somethingwithproof wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds server-side, per-statement SQL timeouts and propagates an optional $timeout argument through the public DB wrapper functions, with unit tests validating behavior across MariaDB/MySQL cases.
Changes:
- Introduces
db_sql_apply_timeout()to wrap statements using MariaDBSET STATEMENTor MySQLMAX_EXECUTION_TIME()hint. - Persists server family/version in
$database_detailsand uses them when applying timeouts indb_execute_prepared(). - Extends public DB wrapper APIs to accept an optional trailing
$timeoutand adds unit coverage.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| tests/Unit/DbTimeoutTest.php | Adds unit tests for SQL timeout wrapping logic and wrapper API signatures. |
| lib/database.php | Implements timeout wrapping + propagates $timeout through DB wrappers; stores DB server/version details. |
| if ($server === 'MariaDB') { | ||
| if (version_compare($version, '10.1', '>=')) { | ||
| // number_format forces a '.' decimal separator regardless of locale; | ||
| // trim trailing zeros so a whole number reads '5', not '5.000'. | ||
| // Clamp to 1ms floor: MAX_STATEMENT_TIME=0 means no limit in MariaDB, | ||
| // so sub-millisecond values must not round down to zero. | ||
| $seconds = rtrim(rtrim(number_format(max($timeout, 0.001), 3, '.', ''), '0'), '.'); | ||
|
|
||
| return 'SET STATEMENT MAX_STATEMENT_TIME=' . $seconds . ' FOR ' . $sql; | ||
| } | ||
| } elseif ($server === 'MySQL') { | ||
| if (version_compare($version, '5.7.8', '>=') && preg_match('/^\s*SELECT\b/i', $sql) && !preg_match('/^\s*SELECT\s*\/\*\+/i', $sql)) { | ||
| $ms = max(1, (int) round($timeout * 1000)); | ||
|
|
||
| return preg_replace('/^(\s*SELECT)\b/i', '$1 /*+ MAX_EXECUTION_TIME(' . $ms . ') */', $sql, 1); | ||
| } | ||
| } |
| if (version_compare($version, '5.7.8', '>=') && preg_match('/^\s*SELECT\b/i', $sql) && !preg_match('/^\s*SELECT\s*\/\*\+/i', $sql)) { | ||
| $ms = max(1, (int) round($timeout * 1000)); | ||
|
|
||
| return preg_replace('/^(\s*SELECT)\b/i', '$1 /*+ MAX_EXECUTION_TIME(' . $ms . ') */', $sql, 1); | ||
| } |
| if ($timeout > 0) { | ||
| $timeout_hash = spl_object_hash($db_conn); | ||
| $timeout_server = $database_details[$timeout_hash]['database_server'] ?? ''; | ||
| $timeout_version = $database_details[$timeout_hash]['database_version'] ?? ''; | ||
| $timeout_sql = db_sql_apply_timeout($sql, $timeout, $timeout_server, $timeout_version); | ||
|
|
||
| if ($timeout_sql === $sql) { | ||
| $timeout_secs = rtrim(rtrim(number_format($timeout, 3, '.', ''), '0'), '.'); | ||
|
|
||
| cacti_log(sprintf('DEBUG: SQL statement timeout of %s seconds not applied for %s %s (unsupported engine/version or non-SELECT)', $timeout_secs, $timeout_server, $timeout_version), false, 'DBCALL', POLLER_VERBOSITY_DEBUG); | ||
| } else { | ||
| $sql = $timeout_sql; | ||
| } | ||
| } |
| function db_sql_apply_timeout(string $sql, float $timeout, string $server, string $version) : string { | ||
| if ($timeout <= 0) { | ||
| return $sql; | ||
| } | ||
|
|
||
| if (!is_finite($timeout)) { | ||
| return $sql; | ||
| } |
| test('MySQL injects the MAX_EXECUTION_TIME hint after SELECT', function () { | ||
| expect(db_sql_apply_timeout('SELECT 1', 2, 'MySQL', '8.0.30')) | ||
| ->toBe('SELECT /*+ MAX_EXECUTION_TIME(2000) */ 1'); | ||
| }); |
Adds a trailing $timeout argument (seconds, decimals allowed) to db_execute*/db_fetch_* that caps server-side execution time. MariaDB uses SET STATEMENT MAX_STATEMENT_TIME, MySQL uses the MAX_EXECUTION_TIME optimizer hint on SELECTs; 0 (default) keeps current behavior. Forum request t=64124. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
aeaecf3 to
ffaae03
Compare
|
Added tests from the Copilot pass: distro/build version suffixes (e.g. On the other points: the comment-prefixed SELECT is an intentional limitation (Cacti doesn't emit such SQL, and it degrades to an untimed run; MariaDB is unaffected as SET STATEMENT wraps the whole statement). The debug line only fires when a timeout is explicitly requested and the engine can't honor it, at DEBUG verbosity, so it stays quiet in normal operation. The helper skips its own control-char stripping because db_execute_prepared already strips before calling it. |
Adds an optional trailing
$timeout(seconds, decimals allowed) to thedb_execute*()/db_fetch_*()family that caps server-side execution time. Requested on the forums (viewtopic.php?t=64124).Behavior:
SET STATEMENT MAX_STATEMENT_TIME=<sec> FOR <sql>./*+ MAX_EXECUTION_TIME(<ms>) */hint on a leading SELECT.$timeout = 0(default) is unchanged behavior; no current caller passes a timeout, so this is additive.The rewrite is isolated in a pure
db_sql_apply_timeout()for unit testing. Server family/version are captured once at connect into$database_details, so there is no per-querySHOW VARIABLES.Scope: plumbing plus tests only. No call site adopts the timeout here; adoption in slow-query paths can follow.
Verification:
tests/Unit/DbTimeoutTest.phpcover both engines, version gates, decimal/clamp/non-finite handling, and the wrapper threading.db_connect_real()+db_fetch_*: engine/version captured,?placeholders bind across the timeout wrapper, andSELECT SLEEP(3)capped at 1s is killed in ~1.0s. On MySQL,SLEEPinterrupted byMAX_EXECUTION_TIMEreturns 1 rather than erroring (a known engine quirk); ordinary long SELECTs raise error 3024.