PluginProbe
404 Solution / 4.3.3
404 Solution v4.3.3
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / database / DatabaseQueryTimeoutManager.php

DatabaseQueryTimeoutManager.php in 404 Solution 4.3.3, at includes/database/DatabaseQueryTimeoutManager.php

443 lines 18.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Engine-aware per-query timeout helpers.
9 *
10 * Centralizes the SQL-level timeout mechanism so every query routed through
11 * queryAndGetResults() inherits a fail-fast deadline before the host's silent
12 * connection-drop kicks in:
13 *
14 * - MySQL 5.7.8+ pure SELECT: MAX_EXECUTION_TIME(ms) optimizer hint.
15 * - MySQL non-SELECT: no SQL-level mechanism (left unchanged).
16 * - MariaDB 10.1+ any DML/DDL: SET STATEMENT max_statement_time=N FOR ...
17 *
18 * Also provides query-shape probes used by the routing layer in
19 * queryAndGetResults() to decide between $wpdb->get_results() vs
20 * $wpdb->query() based on whether a wrapped query produces rows.
21 *
22 * Composed into ABJ_404_Solution_DataAccess. No state of its own; uses the
23 * global $wpdb to detect engine version. Pure functions otherwise.
24 */
25 class ABJ_404_Solution_DatabaseQueryTimeoutManager {
26
27 /** @var ABJ_404_Solution_DatabaseCore */
28 private $core;
29
30 /** @var ABJ_404_Solution_Logging */
31 private $logger;
32
33 /**
34 * @param ABJ_404_Solution_DatabaseCore $core
35 * @param ABJ_404_Solution_Logging $logger
36 */
37 public function __construct(ABJ_404_Solution_DatabaseCore $core, $logger) {
38 $this->core = $core;
39 $this->logger = $logger;
40 }
41
42 /**
43 * Forward DatabaseCore infrastructure calls that remain owned by the core.
44 *
45 * @param string $name
46 * @param array<int, mixed> $arguments
47 * @return mixed
48 */
49 public function __call(string $name, array $arguments) {
50 return $this->core->$name(...$arguments);
51 }
52
53 /**
54 * @param string $query
55 * @return bool
56 */
57 public function queryStartsWithSelect(string $query): bool {
58 // SQL loaded from .sql files is wrapped in leading comments.
59 // Treat "/* ... */ SELECT ..." as a SELECT query for timeout purposes.
60 return preg_match('/^\s*(?:\/\*[\s\S]*?\*\/\s*)*SELECT\s/i', $query) === 1;
61 }
62
63 /**
64 * Returns true if the query produces a result set (rows), so it should
65 * be sent through $wpdb->get_results(). Returns false for INSERT, UPDATE,
66 * DELETE, REPLACE, DDL, SET, etc. Those should go through $wpdb->query().
67 *
68 * Sees past leading SQL comments and any `SET STATEMENT max_statement_time=N FOR `
69 * timeout wrapper. The wrapper is critical because applyQueryTimeout() prepends
70 * it on MariaDB, which would otherwise mask the underlying statement type.
71 *
72 * Misclassification triggered the 4.1.7 spell-check `mysqli_num_fields(true)`
73 * TypeError on PHP 8.1+ MariaDB sites. See DataAccessNonSelectRoutingTest.
74 *
75 * @param string $query
76 * @return bool
77 */
78 public function queryProducesResultRows(string $query): bool {
79 $stripped = (string)preg_replace('/^\s*(?:\/\*[\s\S]*?\*\/\s*)+/', '', $query);
80 $stripped = (string)preg_replace(
81 '/^\s*SET\s+STATEMENT\s+\w+\s*=\s*\d+\s+FOR\s+/i',
82 '',
83 $stripped,
84 1
85 );
86 // Strip nested leading comments inside the SET STATEMENT wrapper too.
87 $stripped = (string)preg_replace('/^\s*(?:\/\*[\s\S]*?\*\/\s*)+/', '', $stripped);
88 return preg_match('/^\s*(SELECT|SHOW|EXPLAIN|DESCRIBE|DESC)\s/i', $stripped) === 1;
89 }
90
91 /**
92 * Apply a DB-level timeout to any query type.
93 *
94 * Dispatches to the appropriate engine-specific mechanism:
95 * - Pure SELECT: MySQL optimizer hint or MariaDB SET STATEMENT
96 * - INSERT...SELECT (or any non-leading SELECT): MariaDB SET STATEMENT
97 * or MySQL hint injected into the embedded SELECT
98 * - Other DML/DDL: MariaDB SET STATEMENT (MySQL has no mechanism for
99 * non-SELECT timeouts; these queries are typically fast)
100 *
101 * Skips queries that already carry a timeout hint to prevent double-wrapping
102 * (e.g. callers that used to apply timeouts manually before this was centralized).
103 *
104 * @param string $query Any SQL query
105 * @param int $timeoutSeconds Maximum execution time in seconds
106 * @return string The query with timeout applied (or unchanged if no mechanism)
107 */
108 public function applyQueryTimeout(
109 string $query,
110 int $timeoutSeconds,
111 ?ABJ_404_Solution_DatabaseQueryPreflightTracer $preflight = null
112 ): string {
113 // Skip if a timeout hint is already present (prevents double-wrapping).
114 if (preg_match('/MAX_EXECUTION_TIME|max_statement_time/i', $query)) {
115 return $query;
116 }
117
118 if ($this->queryStartsWithSelect($query)) {
119 return $this->applySelectTimeout($query, $timeoutSeconds, $preflight);
120 }
121 if (preg_match('/SELECT\s/i', $query)) {
122 // INSERT...SELECT, CREATE TABLE...SELECT, etc.
123 return $this->applyNonLeadingSelectTimeout($query, $timeoutSeconds, $preflight);
124 }
125 // Plain INSERT, UPDATE, DELETE, DDL: only MariaDB has a timeout mechanism.
126 return $this->applyStatementTimeout($query, $timeoutSeconds, $preflight);
127 }
128
129 /**
130 * Detect the DB engine. Returns true for MariaDB, false for MySQL/unknown.
131 * @return bool
132 */
133 public function isMariaDB(
134 ?ABJ_404_Solution_DatabaseQueryPreflightTracer $preflight = null
135 ): bool {
136 global $wpdb;
137 if (!isset($wpdb) || !is_object($wpdb)) {
138 return false;
139 }
140 $source = isset($wpdb->dbh)
141 && function_exists('mysqli_get_server_info')
142 && $wpdb->dbh instanceof \mysqli
143 ? 'mysqli_server_info'
144 : 'wpdb_db_version';
145 $detect = static function () use ($wpdb): bool {
146 if (isset($wpdb->dbh) && function_exists('mysqli_get_server_info')
147 && $wpdb->dbh instanceof \mysqli) {
148 $dbVersion = mysqli_get_server_info($wpdb->dbh);
149 } else {
150 /** @var wpdb $wpdb */
151 $dbVersion = $wpdb->db_version() ?? '';
152 }
153 return stripos((string)$dbVersion, 'mariadb') !== false;
154 };
155 try {
156 if ($preflight === null) {
157 return $detect();
158 }
159 return $preflight->trace(
160 ABJ_404_Solution_DatabaseQueryPreflightTracer::ENGINE_DETECTION,
161 $detect,
162 array(
163 'fields' => array('engine_source' => $source),
164 'result_fields' => static fn(bool $isMariaDb): array => array(
165 'engine' => $isMariaDb ? 'mariadb' : 'mysql_or_unknown',
166 ),
167 )
168 );
169 } catch (\Throwable $e) {
170 // Falling back to "not MariaDB" is safe (it only forgoes MariaDB's
171 // timeout syntax), and on a test double or an early-boot wpdb
172 // without db_version() it is also expected. But safe is not the
173 // same as uninteresting: if this starts throwing at runtime, the
174 // statement-timeout path quietly turns itself off on every query
175 // for the rest of the request, and a silent catch here is the
176 // reason nobody would ever find out. Record it and degrade.
177 $this->logger->debugMessage(
178 'DB engine detection failed; assuming not-MariaDB and skipping the MariaDB '
179 . 'statement-timeout syntax. Source: ' . $source . '. '
180 . get_class($e) . ' (code ' . (string)$e->getCode() . '): ' . $e->getMessage(),
181 $e
182 );
183 return false;
184 }
185 }
186
187 /**
188 * Apply timeout to a pure SELECT query.
189 *
190 * MySQL 5.7.8+: MAX_EXECUTION_TIME(ms) optimizer hint.
191 * MariaDB 10.1+: SET STATEMENT max_statement_time=N FOR ...
192 *
193 * @param string $query A SELECT query
194 * @param int $timeoutSeconds Maximum execution time in seconds
195 * @return string The query with timeout hint applied
196 */
197 public function applySelectTimeout(
198 string $query,
199 int $timeoutSeconds,
200 ?ABJ_404_Solution_DatabaseQueryPreflightTracer $preflight = null
201 ): string {
202 if ($this->isMariaDB($preflight)
203 && !$this->isSetStatementWrapperUnsupported($preflight)) {
204 return "SET STATEMENT max_statement_time=" . $timeoutSeconds . " FOR " . $query;
205 }
206 // MySQL hint also works for the MariaDB-with-disabled-wrapper case:
207 // MariaDB silently ignores unrecognized optimizer hints (parses as a
208 // comment), so the SELECT runs without a per-statement deadline.
209 $timeoutMs = $timeoutSeconds * 1000;
210 $timedQuery = preg_replace(
211 '/^(\s*(?:\/\*[\s\S]*?\*\/\s*)*SELECT\s)/i',
212 '$1/*+ MAX_EXECUTION_TIME(' . $timeoutMs . ') */ ',
213 $query
214 );
215 return ($timedQuery !== null) ? $timedQuery : $query;
216 }
217
218 /**
219 * Apply timeout to a query containing a non-leading SELECT (INSERT...SELECT, etc.).
220 *
221 * MariaDB 10.1+: SET STATEMENT max_statement_time=N FOR ... (wraps entire statement).
222 * MySQL 5.7.8+: MAX_EXECUTION_TIME(ms) hint injected into the first SELECT keyword.
223 *
224 * @param string $query An INSERT...SELECT or similar query
225 * @param int $timeoutSeconds Maximum execution time in seconds
226 * @return string The query with timeout applied
227 */
228 public function applyNonLeadingSelectTimeout(
229 string $query,
230 int $timeoutSeconds,
231 ?ABJ_404_Solution_DatabaseQueryPreflightTracer $preflight = null
232 ): string {
233 if ($this->isMariaDB($preflight)
234 && !$this->isSetStatementWrapperUnsupported($preflight)) {
235 return "SET STATEMENT max_statement_time=" . $timeoutSeconds . " FOR " . $query;
236 }
237 $timeoutMs = $timeoutSeconds * 1000;
238 $timedQuery = preg_replace(
239 '/(SELECT\s)/i',
240 'SELECT /*+ MAX_EXECUTION_TIME(' . $timeoutMs . ') */ ',
241 $query,
242 1
243 );
244 return ($timedQuery !== null) ? $timedQuery : $query;
245 }
246
247 /**
248 * Apply timeout to a non-SELECT statement (INSERT, UPDATE, DELETE, DDL).
249 *
250 * MariaDB 10.1+: SET STATEMENT max_statement_time=N FOR ... works on all DML.
251 * MySQL: has no SQL-level timeout mechanism for non-SELECT queries.
252 *
253 * @param string $query Any non-SELECT query
254 * @param int $timeoutSeconds Maximum execution time in seconds
255 * @return string The query with timeout applied (unchanged on MySQL)
256 */
257 public function applyStatementTimeout(
258 string $query,
259 int $timeoutSeconds,
260 ?ABJ_404_Solution_DatabaseQueryPreflightTracer $preflight = null
261 ): string {
262 if ($this->isMariaDB($preflight)
263 && !$this->isSetStatementWrapperUnsupported($preflight)) {
264 return "SET STATEMENT max_statement_time=" . $timeoutSeconds . " FOR " . $query;
265 }
266 // MySQL has no timeout mechanism for non-SELECT queries. MariaDB hosts
267 // that have rejected SET STATEMENT (privilege denied or syntax not
268 // understood) earlier in this request fall through here too: the
269 // staged build's per-tick budget enforcement degrades to the cron
270 // tick's own wall-clock deadline rather than per-statement.
271 return $query;
272 }
273
274 /**
275 * Read the request-local/persisted wrapper capability under its own
276 * preflight boundary. The result descriptor exposes only hit/miss state.
277 */
278 private function isSetStatementWrapperUnsupported(
279 ?ABJ_404_Solution_DatabaseQueryPreflightTracer $preflight
280 ): bool {
281 if ($preflight === null) {
282 return ABJ_404_Solution_DatabaseRuntimeState::isSetStatementWrapperUnsupported();
283 }
284 $source = ABJ_404_Solution_DatabaseRuntimeState::setStatementWrapperCapabilitySource();
285 return $preflight->trace(
286 ABJ_404_Solution_DatabaseQueryPreflightTracer::TIMEOUT_CAPABILITY_CACHE,
287 static fn(): bool =>
288 ABJ_404_Solution_DatabaseRuntimeState::isSetStatementWrapperUnsupported(),
289 array(
290 'fields' => array('cache_source' => $source),
291 'result_fields' => static function (bool $unsupported) use ($source): array {
292 if ($source === 'transient') {
293 return array(
294 'cache_outcome' => $unsupported
295 ? 'hit_unsupported'
296 : 'miss_supported',
297 );
298 }
299 return array(
300 'cache_outcome' => $unsupported
301 ? 'request_local_unsupported'
302 : 'request_local_supported',
303 );
304 },
305 )
306 );
307 }
308
309 /**
310 * True when $query begins with the timeout wrapper this trait emits:
311 * `SET STATEMENT max_statement_time=N FOR ...`. Used by the wrapper
312 * fallback path to confirm the failed query was wrapped before stripping.
313 *
314 * @param string $query
315 * @return bool
316 */
317 public function queryHasSetStatementWrapper(string $query): bool {
318 return preg_match(
319 '/^\s*SET\s+STATEMENT\s+max_statement_time\s*=\s*\d+\s+FOR\s+/i',
320 $query
321 ) === 1;
322 }
323
324 /**
325 * Strip the leading `SET STATEMENT max_statement_time=N FOR ` wrapper.
326 * Returns the unwrapped statement, or the input unchanged if no wrapper
327 * is present.
328 *
329 * @param string $query
330 * @return string
331 */
332 public function stripSetStatementWrapper(string $query): string {
333 $stripped = preg_replace(
334 '/^\s*SET\s+STATEMENT\s+max_statement_time\s*=\s*\d+\s+FOR\s+/i',
335 '',
336 $query,
337 1
338 );
339 return is_string($stripped) ? $stripped : $query;
340 }
341
342 /**
343 * Re-execute a query without the `SET STATEMENT max_statement_time=N FOR `
344 * wrapper after the server rejected the wrapper itself (privilege denied
345 * or syntax not understood). Caches the result in request-local state and
346 * a short-lived WordPress transient so subsequent queries and fresh PHP
347 * requests skip the known-unsupported wrapper until the capability is
348 * probed again.
349 *
350 * Result harvest mirrors retrying recovery paths such as
351 * attemptMissingTableRepairAndRetry():
352 * write into $result by reference so the caller's downstream branches see
353 * the retry's outcome instead of the original error.
354 *
355 * $query is also passed by reference and mutated to the unwrapped form
356 * on success. Downstream retry paths in queryAndGetResults() (transient
357 * reconnect, deadlock retry, etc.) re-execute $query, so leaving the
358 * wrapper in place would re-trigger the same rejection on every retry.
359 *
360 * @param string $query Passed by reference. Mutated to the unwrapped form.
361 * @param array<string, mixed> $result Passed by reference; updated with retry rows / error.
362 * @param 'OBJECT'|'OBJECT_K'|'ARRAY_A'|'ARRAY_N' $resultType wpdb output type for get_results().
363 * @param ABJ_404_Solution_DatabaseQueryRecoveryTracer|null $tracer
364 * @return void
365 */
366 public function retryWithoutSetStatementWrapper(
367 string &$query,
368 array &$result,
369 string $resultType,
370 ?ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer = null
371 ): void {
372 if (!$this->queryHasSetStatementWrapper($query)) {
373 // Defensive: nothing to strip. Caller misclassified the error.
374 return;
375 }
376 $unwrapped = $this->stripSetStatementWrapper($query);
377 // Cache the negative result locally and across requests so the host
378 // does not repeatedly pay for the same known-failing capability probe.
379 ABJ_404_Solution_DatabaseRuntimeState::setSetStatementWrapperUnsupported(true);
380 if (class_exists('ABJ_404_Solution_AjaxStageDiagnostics')) {
381 ABJ_404_Solution_AjaxStageDiagnostics::addStageMetadata(array(
382 'db_timeout_mode' => 'unwrapped',
383 ));
384 }
385 $this->logger->warn(
386 'SET STATEMENT timeout wrapper rejected by server; '
387 . 'retrying query without a DB-level timeout and caching the '
388 . 'unsupported capability for one hour.'
389 );
390
391 global $wpdb;
392 /** @var wpdb $wpdb */
393 $retryError = isset($result['last_error']) && is_scalar($result['last_error'])
394 ? (string)$result['last_error']
395 : '';
396 if ($tracer === null) {
397 if (!$this->core->connectionManager()->resetForRetry($retryError)) {
398 return;
399 }
400 } else {
401 $reset = $tracer->traceOperation(
402 'timeout_wrapper',
403 'connection_retry_reset',
404 fn(): bool => $this->core->connectionManager()->resetForRetry($retryError)
405 );
406 if (!$reset) {
407 return;
408 }
409 }
410 // Mutate $query so downstream retry paths execute the unwrapped form.
411 $query = $unwrapped;
412 // Re-route classification past any leading comments and the (now-absent)
413 // wrapper. Using queryProducesResultRows on the unwrapped query keeps
414 // the routing correct for INSERT/UPDATE/DELETE/DDL.
415 // SET STATEMENT wrapper-rejection recovery is a DAO-internal primitive
416 // (parallel to attemptMissingTableRepairAndRetry). It must call
417 // $wpdb directly: re-routing through queryAndGetResults() would
418 // re-enter the same SET STATEMENT detection path, deepening the call
419 // stack on every retry. Per-bypass approval markers are inline below.
420 $unwrappedProducesRows = $this->queryProducesResultRows($unwrapped);
421 $retry = function () use ($wpdb, $unwrapped, $resultType, $unwrappedProducesRows): array {
422 if ($unwrappedProducesRows) {
423 // DAO-bypass-approved: SET STATEMENT wrapper-rejection retry primitive.
424 $retried = array('rows' => $wpdb->get_results($unwrapped, $resultType));
425 } else {
426 // DAO-bypass-approved: SET STATEMENT wrapper-rejection retry primitive.
427 $wpdb->query($unwrapped);
428 $retried = array('rows' => array());
429 }
430 $this->core->resultHarvester()->harvestWpdbResult($retried);
431 return $retried;
432 };
433 $retried = $tracer === null
434 ? $retry()
435 : $tracer->traceAttempt(
436 'timeout_wrapper',
437 'timeout_wrapper_rejected',
438 $retry
439 );
440 $result = array_merge($result, $retried);
441 }
442 }
443