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 / DatabaseQueryRecoveryPolicy.php

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

453 lines 16.3 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 require_once __DIR__ . '/DatabaseInfrastructureErrorTaxonomy.php';
8
9 /**
10 * Post-execution recovery policy for DatabaseCore queryAndGetResults().
11 *
12 * DatabaseQueryExecutor owns normalization, preparation, raw execution, and
13 * result harvesting. This policy owns the ordered recovery decisions that run
14 * after the first wpdb call has produced an error: timeout-wrapper fallback,
15 * immediate transient-infrastructure retry, missing-table repair,
16 * invalid-data retry, deadlock retry and notice, collation recovery, timeout
17 * fallback rows, and server-side issue state.
18 */
19 class ABJ_404_Solution_DatabaseQueryRecoveryPolicy {
20
21 /** @var ABJ_404_Solution_DatabaseCore */
22 private $core;
23
24 /** @var ABJ_404_Solution_Logging */
25 private $logger;
26
27 /** @var ABJ_404_Solution_DatabaseWpdbResultHarvester */
28 private $resultHarvester;
29
30 /** @var ABJ_404_Solution_DatabaseQueryDiagnostics */
31 private $queryDiagnostics;
32
33 /**
34 * @param ABJ_404_Solution_DatabaseCore $core
35 * @param ABJ_404_Solution_Logging $logger
36 * @param ABJ_404_Solution_DatabaseWpdbResultHarvester $resultHarvester
37 * @param ABJ_404_Solution_DatabaseQueryDiagnostics $queryDiagnostics
38 */
39 public function __construct(
40 ABJ_404_Solution_DatabaseCore $core,
41 $logger,
42 ABJ_404_Solution_DatabaseWpdbResultHarvester $resultHarvester,
43 ABJ_404_Solution_DatabaseQueryDiagnostics $queryDiagnostics
44 ) {
45 $this->core = $core;
46 $this->logger = $logger;
47 $this->resultHarvester = $resultHarvester;
48 $this->queryDiagnostics = $queryDiagnostics;
49 }
50
51 /**
52 * Classify retry policy without changing the shared infrastructure-error
53 * taxonomy that owns severity decisions.
54 *
55 * Error 2014 needs a distinct immediate-recovery branch because its retry
56 * requires draining mysqli result sets, not reconnecting. Keeping that
57 * decision here lets observed-error reporting and recovery dispatch share
58 * one policy without coupling the severity taxonomy to recovery mechanics.
59 *
60 * @param string $lastError
61 * @return array{strategy: string, branch: string, reason: string}
62 */
63 public function classifyRetry(string $lastError): array {
64 $taxonomy = $this->core->errorClassifier()->taxonomy();
65 if ($taxonomy->connectivity()->isCommandsOutOfSyncError($lastError)) {
66 return array(
67 'strategy' => ABJ_404_Solution_DatabaseInfrastructureErrorTaxonomy::QUERY_RETRY_IMMEDIATE,
68 'branch' => 'commands_out_of_sync',
69 'reason' => 'pending_results_drained',
70 );
71 }
72 return $taxonomy->classifyQueryRetry($lastError);
73 }
74
75 /**
76 * Run all post-execution recovery branches in their legacy order.
77 *
78 * @param string $query
79 * @param array<string, mixed> $result
80 * @param array<string, mixed> $options
81 * @param 'OBJECT'|'OBJECT_K'|'ARRAY_A'|'ARRAY_N' $resultType
82 * @param bool $producesRows
83 * @param int $timeoutSeconds
84 * @param ABJ_404_Solution_DatabaseQueryRecoveryTracer|null $tracer
85 * @return bool Updated produces-rows decision after timeout-wrapper fallback.
86 */
87 public function recoverQueryResult(
88 string &$query,
89 array &$result,
90 array $options,
91 string $resultType,
92 bool $producesRows,
93 int $timeoutSeconds,
94 ?ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer = null
95 ): bool {
96 $tracer = $tracer ?? ABJ_404_Solution_DatabaseQueryRecoveryTracer::begin(null);
97 $tracer->startRecovery();
98 try {
99 $producesRows = $this->retryWithoutSetStatementIfNeeded(
100 $query,
101 $result,
102 $resultType,
103 $producesRows,
104 $tracer
105 );
106 $this->retryImmediateInfrastructureIfNeeded(
107 $query,
108 $result,
109 $resultType,
110 $producesRows,
111 $tracer
112 );
113 $this->repairMissingTableIfNeeded($query, $result, $options, $tracer);
114 $this->retryInvalidDataIfNeeded($query, $result, $tracer);
115 $this->retryDeadlockIfNeeded(
116 $query,
117 $result,
118 $resultType,
119 $producesRows,
120 $tracer
121 );
122 $this->recoverCollationIfNeeded($result, $tracer);
123 $this->handleTimeoutIfNeeded($query, $result, $timeoutSeconds, $tracer);
124 $this->noteDatabaseIssueIfNeeded($result, $tracer);
125 } catch (Throwable $e) {
126 $tracer->completeRecovery('failed', $e);
127 throw $e;
128 }
129 return $producesRows;
130 }
131
132 /**
133 * @param string $query
134 * @param array<string, mixed> $result
135 * @param 'OBJECT'|'OBJECT_K'|'ARRAY_A'|'ARRAY_N' $resultType
136 * @param bool $producesRows
137 * @return bool
138 */
139 private function retryWithoutSetStatementIfNeeded(
140 string &$query,
141 array &$result,
142 string $resultType,
143 bool $producesRows,
144 ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer
145 ): bool {
146 $lastError = $this->lastErrorFromResult($result);
147 if ($lastError === ''
148 || !$this->core->errorClassifier()->taxonomy()->hostState()->classifySetStatementFailure($lastError)
149 || !$this->core->queryTimeoutManager()->queryHasSetStatementWrapper($query)) {
150 return $producesRows;
151 }
152
153 $tracer->traceBranch('timeout_wrapper', function () use (
154 &$query,
155 &$result,
156 $resultType,
157 $tracer
158 ): void {
159 $this->core->queryTimeoutManager()->retryWithoutSetStatementWrapper(
160 $query,
161 $result,
162 $resultType,
163 $tracer
164 );
165 });
166 return $this->core->queryTimeoutManager()->queryProducesResultRows($query);
167 }
168
169 /**
170 * @param string $query
171 * @param array<string, mixed> $result
172 * @param 'OBJECT'|'OBJECT_K'|'ARRAY_A'|'ARRAY_N' $resultType
173 * @param bool $producesRows
174 * @return void
175 */
176 private function retryImmediateInfrastructureIfNeeded(
177 string $query,
178 array &$result,
179 string $resultType,
180 bool $producesRows,
181 ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer
182 ): void {
183 $lastError = $this->lastErrorFromResult($result);
184 $retryDecision = $this->classifyRetry($lastError);
185 if ($lastError === ''
186 || $retryDecision['strategy'] !== ABJ_404_Solution_DatabaseInfrastructureErrorTaxonomy::QUERY_RETRY_IMMEDIATE) {
187 return;
188 }
189
190 $retryBranch = $retryDecision['branch'];
191 $retryReason = $retryDecision['reason'];
192
193 $tracer->traceBranch($retryBranch, function () use (
194 $query,
195 &$result,
196 $resultType,
197 $producesRows,
198 $tracer,
199 $retryBranch,
200 $retryReason,
201 $lastError
202 ): void {
203 $tracer->traceOperation(
204 $retryBranch,
205 'connection_recovery',
206 fn(): bool => $this->core->connectionManager()->ensureConnection()
207 );
208 $reset = $tracer->traceOperation(
209 $retryBranch,
210 'connection_retry_reset',
211 fn(): bool => $this->core->connectionManager()->resetForRetry($lastError)
212 );
213 if (!$reset) {
214 return;
215 }
216 $retried = $tracer->traceAttempt(
217 $retryBranch,
218 $retryReason,
219 fn(): array => $this->executeWpdbQuery($query, $resultType, $producesRows)
220 );
221 $result = array_merge($result, $retried);
222 });
223 }
224
225 /**
226 * @param string $query
227 * @param array<string, mixed> $result
228 * @param array<string, mixed> $options
229 * @return void
230 */
231 private function repairMissingTableIfNeeded(
232 string $query,
233 array &$result,
234 array $options,
235 ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer
236 ): void {
237 $lastError = $this->lastErrorFromResult($result);
238 if ($options['skip_repair'] || $lastError === '' || !$this->core->errorClassifier()->taxonomy()->schema()->isMissingPluginTableError($lastError)) {
239 return;
240 }
241 $tracer->traceBranch('missing_table', function () use (
242 $query,
243 &$result,
244 $tracer
245 ): void {
246 $this->core->repairPolicy()->attemptMissingTableRepairAndRetry(
247 $query,
248 $result,
249 $tracer
250 );
251 });
252 }
253
254 /**
255 * @param string $query
256 * @param array<string, mixed> $result
257 * @return void
258 */
259 private function retryInvalidDataIfNeeded(
260 string $query,
261 array &$result,
262 ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer
263 ): void {
264 $lastError = $this->lastErrorFromResult($result);
265 if ($lastError !== '' && $this->core->errorClassifier()->taxonomy()->schema()->isInvalidDataError($lastError)) {
266 $tracer->traceBranch('invalid_data', function () use (
267 $query,
268 &$result,
269 $tracer
270 ): void {
271 $this->core->tableRepairer()->attemptInvalidDataRetry(
272 $query,
273 $result,
274 $tracer
275 );
276 });
277 }
278 }
279
280 /**
281 * @param string $query
282 * @param array<string, mixed> $result
283 * @param 'OBJECT'|'OBJECT_K'|'ARRAY_A'|'ARRAY_N' $resultType
284 * @param bool $producesRows
285 * @return void
286 */
287 private function retryDeadlockIfNeeded(
288 string $query,
289 array &$result,
290 string $resultType,
291 bool $producesRows,
292 ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer
293 ): void {
294 $lastError = $this->lastErrorFromResult($result);
295 $retryDecision = $this->classifyRetry($lastError);
296 if ($lastError === ''
297 || $retryDecision['strategy'] !== ABJ_404_Solution_DatabaseInfrastructureErrorTaxonomy::QUERY_RETRY_BACKOFF) {
298 return;
299 }
300
301 $tracer->traceBranch('deadlock', function () use (
302 $query,
303 &$result,
304 $resultType,
305 $producesRows,
306 $tracer
307 ): void {
308 $tracer->traceOperation(
309 'deadlock',
310 'retry_backoff',
311 static function (): void {
312 usleep(50000);
313 }
314 );
315 $retried = $tracer->traceAttempt(
316 'deadlock',
317 'deadlock_or_lock_timeout',
318 fn(): array => $this->executeWpdbQuery($query, $resultType, $producesRows)
319 );
320 $result = array_merge($result, $retried);
321 $lastError = $this->lastErrorFromResult($result);
322 if ($lastError !== '' && $this->core->errorClassifier()->taxonomy()->connectivity()->isDeadlockOrLockTimeoutError($lastError)) {
323 $tracer->traceOperation(
324 'deadlock',
325 'notice_update',
326 function () use ($lastError): void {
327 $this->core->noticeState()->setPluginDbNotice(
328 'lock_timeout',
329 function_exists('__') ? __('A database lock wait timeout occurred. If this persists, contact your host - another process may be holding a long-running lock.', '404-solution') : 'A database lock wait timeout occurred. If this persists, contact your host - another process may be holding a long-running lock.',
330 function_exists('__') ? __('A database lock wait timeout occurred. This is usually caused by another process holding a table lock on your database. It may resolve itself automatically, or contact your hosting provider if it persists.', '404-solution') : 'A database lock wait timeout occurred. This is usually caused by another process holding a table lock on your database. It may resolve itself automatically, or contact your hosting provider if it persists.',
331 $lastError
332 );
333 }
334 );
335 }
336 });
337 }
338
339 /**
340 * @param array<string, mixed> $result
341 * @return void
342 */
343 private function recoverCollationIfNeeded(
344 array $result,
345 ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer
346 ): void {
347 $lastError = $this->lastErrorFromResult($result);
348 if ($lastError !== '' && $this->core->errorClassifier()->taxonomy()->schema()->isCollationError($lastError)) {
349 $tracer->traceBranch('collation', function () use ($tracer): void {
350 $tracer->traceOperation(
351 'collation',
352 'schedule_recovery',
353 function (): void {
354 $this->core->collationHelper()->scheduleCollationRecovery();
355 }
356 );
357 });
358 }
359 }
360
361 /**
362 * @param string $query
363 * @param array<string, mixed> $result
364 * @param int $timeoutSeconds
365 * @return void
366 */
367 private function handleTimeoutIfNeeded(
368 string $query,
369 array &$result,
370 int $timeoutSeconds,
371 ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer
372 ): void {
373 $lastError = $this->lastErrorFromResult($result);
374 if ($lastError === '' || !$this->core->errorClassifier()->taxonomy()->connectivity()->isQueryTimeoutError($lastError)) {
375 return;
376 }
377
378 $tracer->traceBranch('timeout', function () use (
379 $query,
380 &$result,
381 $timeoutSeconds,
382 $tracer
383 ): void {
384 $tracer->traceOperation(
385 'timeout',
386 'timeout_log',
387 function () use ($query, $timeoutSeconds): void {
388 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->queryDiagnostics->extractSqlFilename($query);
389 $this->logger->warn(
390 'Query timed out after ' . $timeoutSeconds . 's. ' .
391 'Query: ' . substr(preg_replace('/\s+/', ' ', trim($sqlInfo)) ?? $sqlInfo, 0, 500)
392 );
393 }
394 );
395 $result['rows'] = array();
396 $result['timed_out'] = true;
397 });
398 }
399
400 /**
401 * @param array<string, mixed> $result
402 * @return void
403 */
404 private function noteDatabaseIssueIfNeeded(
405 array $result,
406 ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer
407 ): void {
408 $lastError = $this->lastErrorFromResult($result);
409 if ($lastError !== '') {
410 $tracer->traceBranch('database_issue', function () use (
411 $lastError,
412 $tracer
413 ): void {
414 $tracer->traceOperation(
415 'database_issue',
416 'notice_update',
417 function () use ($lastError): void {
418 $this->core->errorClassifier()->noteDatabaseIssueFromError($lastError);
419 }
420 );
421 });
422 }
423 }
424
425 /**
426 * @param string $query
427 * @param 'OBJECT'|'OBJECT_K'|'ARRAY_A'|'ARRAY_N' $resultType
428 * @param bool $producesRows
429 * @return array<string, mixed>
430 */
431 private function executeWpdbQuery(string $query, string $resultType, bool $producesRows): array {
432 global $wpdb;
433 if ($producesRows) {
434 // DAO-bypass-approved: Recovery retry executes the original SQL outside DAO routing to avoid recursion.
435 $result = array('rows' => $wpdb->get_results($query, $resultType));
436 } else {
437 // DAO-bypass-approved: Recovery retry executes the original SQL outside DAO routing to avoid recursion.
438 $wpdb->query($query);
439 $result = array('rows' => array());
440 }
441 $this->resultHarvester->harvestWpdbResult($result);
442 return $result;
443 }
444
445 /**
446 * @param array<string, mixed> $result
447 * @return string
448 */
449 private function lastErrorFromResult(array $result): string {
450 return isset($result['last_error']) && is_scalar($result['last_error']) ? (string)$result['last_error'] : '';
451 }
452 }
453