Skip to content

feat(db): add optional per-statement timeout to db_* functions#7209

Open
somethingwithproof wants to merge 1 commit into
Cacti:developfrom
somethingwithproof:feat/db-statement-timeout
Open

feat(db): add optional per-statement timeout to db_* functions#7209
somethingwithproof wants to merge 1 commit into
Cacti:developfrom
somethingwithproof:feat/db-statement-timeout

Conversation

@somethingwithproof

Copy link
Copy Markdown
Contributor

Adds an optional trailing $timeout (seconds, decimals allowed) to the db_execute*() / db_fetch_*() family that caps server-side execution time. Requested on the forums (viewtopic.php?t=64124).

Behavior:

  • MariaDB (>= 10.1): SET STATEMENT MAX_STATEMENT_TIME=<sec> FOR <sql>.
  • MySQL (>= 5.7.8): /*+ MAX_EXECUTION_TIME(<ms>) */ hint on a leading SELECT.
  • Anything else (older/unknown engine, MySQL non-SELECT): runs untimed and logs a DBCALL debug line.
  • $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-query SHOW VARIABLES.

Scope: plumbing plus tests only. No call site adopts the timeout here; adoption in slow-query paths can follow.

Verification:

  • Unit suite green (tests/Unit, 770 passing); new tests in tests/Unit/DbTimeoutTest.php cover both engines, version gates, decimal/clamp/non-finite handling, and the wrapper threading.
  • Manual run against MariaDB 10.11 and MySQL 8.0 through db_connect_real() + db_fetch_*: engine/version captured, ? placeholders bind across the timeout wrapper, and SELECT SLEEP(3) capped at 1s is killed in ~1.0s. On MySQL, SLEEP interrupted by MAX_EXECUTION_TIME returns 1 rather than erroring (a known engine quirk); ordinary long SELECTs raise error 3024.

Copilot AI review requested due to automatic review settings June 17, 2026 01:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 MariaDB SET STATEMENT or MySQL MAX_EXECUTION_TIME() hint.
  • Persists server family/version in $database_details and uses them when applying timeouts in db_execute_prepared().
  • Extends public DB wrapper APIs to accept an optional trailing $timeout and 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.

Comment thread lib/database.php
Comment on lines +509 to +525
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);
}
}
Comment thread lib/database.php
Comment on lines +520 to +524
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);
}
Comment thread lib/database.php
Comment on lines +607 to +620
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;
}
}
Comment thread lib/database.php
Comment on lines +500 to +507
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;
}
Comment on lines +39 to +42
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>
@somethingwithproof somethingwithproof force-pushed the feat/db-statement-timeout branch from aeaecf3 to ffaae03 Compare June 17, 2026 01:55
@somethingwithproof

Copy link
Copy Markdown
Contributor Author

Added tests from the Copilot pass: distro/build version suffixes (e.g. 10.11.18-ubu2204) still apply, since version_compare gates on the leading numeric components, and a MySQL SELECT preceded by a comment block is left untimed.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants