PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
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 / DatabaseCore.php

DatabaseCore.php in 404 Solution 4.2.0, at includes/DatabaseCore.php

1,440 lines 60.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__ . '/DatabaseCoreInterface.php';
8 require_once __DIR__ . '/DatabaseRuntimeState.php';
9 require_once __DIR__ . '/DatabaseConnectionManager.php';
10 require_once __DIR__ . '/DatabaseQueryTimeoutManager.php';
11 require_once __DIR__ . '/DatabaseErrorClassifier.php';
12 require_once __DIR__ . '/DatabaseSqlErrorReporter.php';
13
14 /**
15 * Shared database infrastructure: query execution, error recovery, timeouts,
16 * connection management, table-name resolution, and error classification.
17 *
18 * Extracted from the DataAccess monolith (Phase 0 of the DataAccess refactor).
19 * Every DAO module receives a DatabaseCore instance via constructor injection.
20 */
21 class ABJ_404_Solution_DatabaseCore implements ABJ_404_Solution_DatabaseCoreInterface {
22
23 /** @var int Cooldown when DB query quota is exceeded. */
24 const DB_QUOTA_COOLDOWN_SECONDS = 900;
25 /** @var int Cooldown when DB is read-only or storage is full. */
26 const DB_WRITE_BLOCK_COOLDOWN_SECONDS = 900;
27
28 /** @var ABJ_404_Solution_Functions */
29 private $f;
30
31 /** @var ABJ_404_Solution_Logging */
32 private $logger;
33
34 /** @var ABJ_404_Solution_Clock|null */
35 private $clock = null;
36
37 /** @var ABJ_404_Solution_DatabaseConnectionManager */
38 private $connectionManager;
39
40 /** @var ABJ_404_Solution_DatabaseQueryTimeoutManager */
41 private $queryTimeoutManager;
42
43 /** @var ABJ_404_Solution_DatabaseErrorClassifier */
44 private $errorClassifier;
45
46 /** @var ABJ_404_Solution_DatabaseSqlErrorReporter */
47 private $sqlErrorReporter;
48
49 /** @var bool Prevent recursive auto-repair attempts on SQL errors. */
50 private static $tableRepairInProgress = false;
51
52 /** @var bool Prevent recursive invalid-data retry attempts. */
53 private static $invalidDataRetryInProgress = false;
54
55 /**
56 * @var bool Per-request cache: this server rejected the
57 * `SET STATEMENT max_statement_time=N FOR ...` timeout wrapper, so
58 * applyQueryTimeout() must skip wrapping for the rest of the request.
59 */
60 private static $setStatementWrapperUnsupported = false;
61
62 /** @var string Current wpdb result type for queryAndGetResults (ARRAY_A or OBJECT). */
63 private $currentResultType = ARRAY_A;
64
65 /** @var bool Whether a server-side DB issue was noted this request (for auto-clear). */
66 private $serverSideIssueNoted = false;
67
68 /** @var bool Whether we already checked for a stale notice transient this request. */
69 private $serverSideIssueChecked = false;
70
71 /** @var bool Prevent recursive collation auto-recovery. */
72 private static $collationRecoveryInProgress = false;
73
74 /**
75 * @param ABJ_404_Solution_Functions|null $functions
76 * @param ABJ_404_Solution_Logging|null $logging
77 */
78 public function __construct($functions = null, $logging = null) {
79 $this->f = $functions !== null ? $functions : abj_service('functions');
80 $this->logger = $logging !== null ? $logging : abj_service('logging');
81 $this->connectionManager = new ABJ_404_Solution_DatabaseConnectionManager($this, $this->logger);
82 $this->queryTimeoutManager = new ABJ_404_Solution_DatabaseQueryTimeoutManager($this, $this->logger);
83 $this->errorClassifier = new ABJ_404_Solution_DatabaseErrorClassifier($this, $this->f, $this->logger);
84 $this->sqlErrorReporter = new ABJ_404_Solution_DatabaseSqlErrorReporter($this, $this->logger);
85 }
86
87 /**
88 * Delegate extracted database infrastructure methods to their focused components.
89 *
90 * @param string $name
91 * @param array<int, mixed> $arguments
92 * @return mixed
93 */
94 public function __call(string $name, array $arguments) {
95 foreach (array($this->connectionManager, $this->queryTimeoutManager, $this->errorClassifier, $this->sqlErrorReporter) as $component) {
96 if (method_exists($component, $name)) {
97 return $component->$name(...$arguments);
98 }
99 }
100 throw new BadMethodCallException('Unknown DatabaseCore method: ' . $name);
101 }
102
103 /** @param object $wpdb @param bool $allowReconnect @return bool */
104 public function safeCheckConnection($wpdb, bool $allowReconnect = false): bool { return $this->connectionManager->safeCheckConnection($wpdb, $allowReconnect); }
105 /** @return bool */
106 public function ensureConnection() { return $this->connectionManager->ensureConnection(); }
107 /** @param string $errorText @return bool */
108 public function classifyAndHandleInfrastructureError(string $errorText): bool { return $this->errorClassifier->classifyAndHandleInfrastructureError($errorText); }
109 /** @param int $stageNumber @param string $errorText @return string */
110 public function classifyStageFailure(int $stageNumber, string $errorText): string { return $this->errorClassifier->classifyStageFailure($stageNumber, $errorText); }
111 /** @param string $errorText @return bool */
112 public function isOutOfMemoryError(string $errorText): bool { return $this->errorClassifier->isOutOfMemoryError($errorText); }
113 /** @param mixed $errorText @return bool */
114 public function isInvalidDataError($errorText): bool { return $this->errorClassifier->isInvalidDataError($errorText); }
115 /** @param string|null $errorText @return bool */
116 public function isTransientConnectionError(?string $errorText): bool { return $this->errorClassifier->isTransientConnectionError($errorText); }
117 /** @param string $errorText @return bool */
118 public function isQuotaLimitError(string $errorText): bool { return $this->errorClassifier->isQuotaLimitError($errorText); }
119 /** @param string $errorText @return bool */
120 public function isDiskFullError(string $errorText): bool { return $this->errorClassifier->isDiskFullError($errorText); }
121 /** @param string $errorText @return bool */
122 public function isReadOnlyError(string $errorText): bool { return $this->errorClassifier->isReadOnlyError($errorText); }
123 /** @param string $errorText @return bool */
124 public function isAccessDeniedError(string $errorText): bool { return $this->errorClassifier->isAccessDeniedError($errorText); }
125 /** @param string $errorText @return bool */
126 public function classifySetStatementFailure(string $errorText): bool { return $this->errorClassifier->classifySetStatementFailure($errorText); }
127 /** @param string $errorText @return bool */
128 public function isCollationError(string $errorText): bool { return $this->errorClassifier->isCollationError($errorText); }
129 /** @param string $errorText @return bool */
130 public function isCrashedTableError(string $errorText): bool { return $this->errorClassifier->isCrashedTableError($errorText); }
131 /** @param string $errorText @return bool */
132 public function isIncorrectKeyFileError(string $errorText): bool { return $this->errorClassifier->isIncorrectKeyFileError($errorText); }
133 /** @param string $errorText @return bool */
134 public function isQueryTimeoutError(string $errorText): bool { return $this->errorClassifier->isQueryTimeoutError($errorText); }
135 /** @param string $errorText @return bool */
136 public function isPacketTooLarge(string $errorText): bool { return $this->errorClassifier->isPacketTooLarge($errorText); }
137 /** @param string $errorText @return bool */
138 public function isDeadlockOrLockTimeoutError(string $errorText): bool { return $this->errorClassifier->isDeadlockOrLockTimeoutError($errorText); }
139 /** @param string $errorText @return bool */
140 public function isGaleraConflictError(string $errorText): bool { return $this->errorClassifier->isGaleraConflictError($errorText); }
141 /** @param string $errorText @return bool */
142 public function isPermanentHostSideStagedFailure(string $errorText): bool { return $this->errorClassifier->isPermanentHostSideStagedFailure($errorText); }
143 /** @param string $errorText @return bool */
144 public function isResumableStagedKill(string $errorText): bool { return $this->errorClassifier->isResumableStagedKill($errorText); }
145 /** @param string $errorText @return string|null */
146 public function extractTableNameFromFullError(string $errorText): ?string { return $this->errorClassifier->extractTableNameFromFullError($errorText); }
147 /** @param string $tableName @return bool */
148 public function isInnoDBTable(string $tableName): bool { return $this->errorClassifier->isInnoDBTable($tableName); }
149 /** @param string $errorText @return void */
150 public function noteDatabaseIssueFromError(string $errorText): void { $this->errorClassifier->noteDatabaseIssueFromError($errorText); }
151 /** @return bool */
152 public function isQuotaCooldownActive(): bool { return $this->errorClassifier->isQuotaCooldownActive(); }
153 /** @param string $errorText @return bool */
154 public function isMissingPluginTableError(string $errorText): bool { return $this->errorClassifier->isMissingPluginTableError($errorText); }
155 /** @param string $errorText @return bool */
156 public function isTransientViewBuildTableError(string $errorText): bool { return $this->errorClassifier->isTransientViewBuildTableError($errorText); }
157
158 /**
159 * @param string $query
160 * @param array<string, mixed> $result
161 * @return void
162 */
163 public function attemptMissingTableRepairAndRetry($query, array &$result): void {
164 $this->errorClassifier->attemptMissingTableRepairAndRetry($query, $result);
165 }
166
167 /**
168 * @param string $query
169 * @param array<string, mixed> $result
170 * @return bool
171 */
172 public function handleTransientViewBuildTableMissing($query, array &$result): bool {
173 return $this->errorClassifier->handleTransientViewBuildTableMissing($query, $result);
174 }
175
176 /** @param array<string, mixed> $result @param string $repairCooldownKey @return bool */
177 public function isMissingTableRepairOnCooldown(array &$result, string $repairCooldownKey): bool {
178 return $this->errorClassifier->isMissingTableRepairOnCooldown($result, $repairCooldownKey);
179 }
180
181 /**
182 * @param string $query
183 * @param array<string, mixed> $result
184 * @param string $repairCooldownKey
185 * @param int $cooldownTtlSeconds
186 * @param string $originalSqlError
187 * @param string $missingTable
188 * @return void
189 */
190 public function runRepairCreateRetryAndReport(
191 $query, array &$result, string $repairCooldownKey, int $cooldownTtlSeconds,
192 string $originalSqlError, string $missingTable
193 ): void {
194 $this->errorClassifier->runRepairCreateRetryAndReport(
195 $query, $result, $repairCooldownKey, $cooldownTtlSeconds, $originalSqlError, $missingTable
196 );
197 }
198
199 /**
200 * @param array<string, mixed> $result
201 * @param string $repairCooldownKey
202 * @param int $cooldownTtlSeconds
203 * @param string $originalSqlError
204 * @param string $missingTable
205 * @return void
206 */
207 public function reportRepairRetryFailure(
208 array &$result, string $repairCooldownKey, int $cooldownTtlSeconds,
209 string $originalSqlError, string $missingTable
210 ): void {
211 $this->errorClassifier->reportRepairRetryFailure(
212 $result, $repairCooldownKey, $cooldownTtlSeconds, $originalSqlError, $missingTable
213 );
214 }
215
216 /** @param array<string, mixed> $result @param string $missingTable @param string $prefixDiag @return void */
217 public function setMissingTablePluginDbNotice(array $result, string $missingTable, string $prefixDiag): void {
218 $this->errorClassifier->setMissingTablePluginDbNotice($result, $missingTable, $prefixDiag);
219 }
220
221 /** @param string $errorText @return string */
222 public function extractMissingTableNameFromError(string $errorText): string {
223 return $this->errorClassifier->extractMissingTableNameFromError($errorText);
224 }
225
226 /** @return string */
227 public function diagnosePrefixMismatch(): string {
228 return $this->errorClassifier->diagnosePrefixMismatch();
229 }
230
231 /** @param string $errorText @return bool */
232 public function isMultisiteCrossPrefixError(string $errorText): bool {
233 return $this->errorClassifier->isMultisiteCrossPrefixError($errorText);
234 }
235
236 /** @param string $query @return bool */
237 public function queryStartsWithSelect(string $query): bool {
238 return $this->queryTimeoutManager->queryStartsWithSelect($query);
239 }
240
241 /** @param string $query @return bool */
242 public function queryProducesResultRows(string $query): bool {
243 return $this->queryTimeoutManager->queryProducesResultRows($query);
244 }
245
246 /** @param string $query @param int $timeoutSeconds @return string */
247 public function applyQueryTimeout(string $query, int $timeoutSeconds): string {
248 return $this->queryTimeoutManager->applyQueryTimeout($query, $timeoutSeconds);
249 }
250
251 /** @return bool */
252 public function isMariaDB(): bool {
253 return $this->queryTimeoutManager->isMariaDB();
254 }
255
256 /** @param string $query @param int $timeoutSeconds @return string */
257 public function applySelectTimeout(string $query, int $timeoutSeconds): string {
258 return $this->queryTimeoutManager->applySelectTimeout($query, $timeoutSeconds);
259 }
260
261 /** @param string $query @param int $timeoutSeconds @return string */
262 public function applyNonLeadingSelectTimeout(string $query, int $timeoutSeconds): string {
263 return $this->queryTimeoutManager->applyNonLeadingSelectTimeout($query, $timeoutSeconds);
264 }
265
266 /** @param string $query @param int $timeoutSeconds @return string */
267 public function applyStatementTimeout(string $query, int $timeoutSeconds): string {
268 return $this->queryTimeoutManager->applyStatementTimeout($query, $timeoutSeconds);
269 }
270
271 /** @param string $insertSelectQuery @param int $timeoutSeconds @return string */
272 public function applyTimeoutToInsertSelect(string $insertSelectQuery, int $timeoutSeconds): string {
273 return $this->queryTimeoutManager->applyTimeoutToInsertSelect($insertSelectQuery, $timeoutSeconds);
274 }
275
276 /** @param string $query @return bool */
277 public function queryHasSetStatementWrapper(string $query): bool {
278 return $this->queryTimeoutManager->queryHasSetStatementWrapper($query);
279 }
280
281 /** @param string $query @return string */
282 public function stripSetStatementWrapper(string $query): string {
283 return $this->queryTimeoutManager->stripSetStatementWrapper($query);
284 }
285
286 /**
287 * @param string $query
288 * @param array<string, mixed> $result
289 * @param 'OBJECT'|'OBJECT_K'|'ARRAY_A'|'ARRAY_N' $resultType
290 * @return void
291 */
292 public function retryWithoutSetStatementWrapper(string &$query, array &$result, string $resultType): void {
293 $this->queryTimeoutManager->retryWithoutSetStatementWrapper($query, $result, $resultType);
294 }
295
296 /**
297 * @param string $query
298 * @param array<string, mixed> $result
299 * @param array<string, mixed> $options
300 * @param bool $producesRows
301 * @return void
302 */
303 public function logObservedSqlError(string $query, array $result, array $options, bool $producesRows): void {
304 $this->sqlErrorReporter->logObservedSqlError($query, $result, $options, $producesRows);
305 }
306
307 /** @param string $errorText @return bool */
308 public function isInfrastructureSqlError(string $errorText): bool {
309 return $this->sqlErrorReporter->isInfrastructureSqlError($errorText);
310 }
311
312 /**
313 * @param string $query
314 * @param Throwable $e
315 * @param array<string, mixed> $options
316 * @param bool $producesRows
317 * @return void
318 */
319 public function logSqlThrowable(string $query, Throwable $e, array $options, bool $producesRows): void {
320 $this->sqlErrorReporter->logSqlThrowable($query, $e, $options, $producesRows);
321 }
322
323 /** @return void */
324 public function markServerSideIssueNoted(): void {
325 $this->serverSideIssueNoted = true;
326 }
327
328 /** @return bool */
329 public function isTableRepairInProgress(): bool {
330 return self::$tableRepairInProgress;
331 }
332
333 /** @param bool $value @return void */
334 public function setTableRepairInProgress(bool $value): void {
335 self::$tableRepairInProgress = $value;
336 }
337
338 /** @return string */
339 public function getCurrentResultType(): string {
340 return $this->currentResultType;
341 }
342
343 /** @param ABJ_404_Solution_Clock $clock @return void */
344 public function setClock(ABJ_404_Solution_Clock $clock): void {
345 $this->clock = $clock;
346 }
347
348 /** @return ABJ_404_Solution_Clock */
349 public function clock(): ABJ_404_Solution_Clock {
350 if ($this->clock !== null) { return $this->clock; }
351 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
352 $resolved = ABJ_404_Solution_ServiceContainer::safeGet('clock');
353 if ($resolved instanceof ABJ_404_Solution_Clock) {
354 $this->clock = $resolved;
355 return $this->clock;
356 }
357 }
358 $this->clock = new ABJ_404_Solution_SystemClock();
359 return $this->clock;
360 }
361
362 /**
363 * Reset the per-request "SET STATEMENT wrapper unsupported" cache.
364 *
365 * @param bool $value
366 * @return void
367 */
368 public static function setSetStatementWrapperUnsupported(bool $value): void {
369 self::$setStatementWrapperUnsupported = $value;
370 ABJ_404_Solution_DatabaseRuntimeState::setSetStatementWrapperUnsupported($value);
371 }
372
373 /** @return bool */
374 public static function isSetStatementWrapperUnsupported(): bool {
375 return self::$setStatementWrapperUnsupported
376 || ABJ_404_Solution_DatabaseRuntimeState::isSetStatementWrapperUnsupported();
377 }
378
379 /**
380 * Check if a database table exists.
381 *
382 * @param string $tableName Full table name to check (including prefix)
383 * @return bool
384 */
385 public function tableExists($tableName): bool {
386 global $wpdb;
387 if (!isset($wpdb)) {
388 return false;
389 }
390 // @utf8-audit: opt-out — tableExists receives system-generated plugin table names from DAO/core callers.
391 $table = $wpdb->get_var("SHOW TABLES LIKE '" . esc_sql($tableName) . "'");
392 return ($table == $tableName);
393 }
394
395 /**
396 * Get the column names of an actual database table via SHOW COLUMNS.
397 *
398 * @param string $tableName Full table name (including prefix)
399 * @return array<int, string>
400 */
401 public function getTableColumnNames(string $tableName): array {
402 global $wpdb;
403 if (!isset($wpdb) || !is_object($wpdb) || !is_callable(array($wpdb, 'get_results'))) { return []; }
404 // @utf8-audit: opt-out — getTableColumnNames receives system-generated plugin table names only.
405 $rows = $wpdb->get_results("SHOW COLUMNS FROM `" . esc_sql($tableName) . "`", ARRAY_A);
406 if (!is_array($rows) || !empty($wpdb->last_error)) { return []; }
407 $columns = [];
408 foreach ($rows as $row) {
409 if (isset($row['Field'])) { $columns[] = $row['Field']; }
410 }
411 return $columns;
412 }
413
414 /**
415 * @param string $query
416 * @return string
417 */
418 public function doTableNameReplacements($query): string {
419 global $wpdb;
420
421 $replacements = array();
422 $tables = (isset($wpdb->tables) && is_array($wpdb->tables)) ? $wpdb->tables : array();
423 $prefix = isset($wpdb->prefix) ? $wpdb->prefix : 'wp_';
424 foreach ($tables as $tableName) {
425 $replacements['{wp_' . $tableName . '}'] = $prefix . $tableName;
426 }
427 $replacements['{wp_users}'] = isset($wpdb->users) ? $wpdb->users : ($prefix . 'users');
428 $replacements['{wp_prefix}'] = $prefix;
429 $replacements['{wp_prefix_lower}'] = $this->getLowercasePrefix();
430
431 $wpdbCollate = 'utf8mb4_unicode_ci';
432 if (isset($wpdb->collate) && !empty($wpdb->collate)) {
433 $sanitized = preg_replace('/[^A-Za-z0-9_]/', '', $wpdb->collate);
434 if ($sanitized !== '' && $sanitized !== null) {
435 $wpdbCollate = $sanitized;
436 }
437 }
438 $replacements['{wpdb_collate}'] = $wpdbCollate;
439
440 $query = $this->f->str_replace(array_keys($replacements), array_values($replacements), $query);
441
442 $fpreg = ABJ_404_Solution_FunctionsPreg::getInstance();
443 $query = $fpreg->regexReplace('[{]wp_abj404_(.*?)[}]',
444 $this->getLowercasePrefix() . "abj404_\\1", $query);
445
446 return $query !== null ? $query : '';
447 }
448
449 /** @return string */
450 public function getLowercasePrefix(): string {
451 global $wpdb;
452 return $this->f->strtolower($wpdb->prefix ?? 'wp_');
453 }
454
455 /**
456 * @param string $tableSuffix
457 * @return string
458 */
459 public function getPrefixedTableName($tableSuffix): string {
460 return $this->getLowercasePrefix() . ltrim($tableSuffix, '_');
461 }
462
463 /**
464 * @param string $tableName
465 * @return string
466 */
467 public function getCreateTableDDL($tableName): string {
468 $query = "show create table " . $tableName;
469 $result = $this->queryAndGetResults($query, array('log_errors' => false, 'skip_repair' => true));
470 $rows = $result['rows'];
471 if (!is_array($rows) || empty($rows) || !isset($rows[0]) || !is_array($rows[0])) {
472 return '';
473 }
474 $row1 = array_values($rows[0]);
475 $existingTableSQL = $row1[1];
476 return $existingTableSQL;
477 }
478
479 /**
480 * @param array<string, mixed> $options
481 * @return string
482 */
483 public function buildPostTypeSqlList(array $options): string {
484 $rptVal = $options['recognized_post_types'] ?? '';
485 $postTypes = $this->f->explodeNewline(is_string($rptVal) ? $rptVal : '');
486 $recognizedPostTypes = '';
487 foreach ($postTypes as $postType) {
488 $recognizedPostTypes .= "'" . trim($this->f->strtolower($postType)) . "', ";
489 }
490 return rtrim($recognizedPostTypes, ", ");
491 }
492
493 /**
494 * @param array<string, mixed> $options
495 * @return string
496 */
497 public function buildCategorySqlList(array $options): string {
498 $rcVal = $options['recognized_categories'] ?? '';
499 $categories = $this->f->explodeNewline(is_string($rcVal) ? $rcVal : '');
500 $recognizedCategories = '';
501 foreach ($categories as $category) {
502 $recognizedCategories .= "'" . trim($this->f->strtolower($category)) . "', ";
503 }
504 return rtrim($recognizedCategories, ", ");
505 }
506
507 /** @return void */
508 public function setSqlBigSelects(): void {
509 $ignoreErrorsOptions = array('log_errors' => false);
510 $this->queryAndGetResults("set session max_join_size = 18446744073709551615",
511 $ignoreErrorsOptions);
512 $this->queryAndGetResults("set session sql_big_selects = 1", $ignoreErrorsOptions);
513 }
514
515 /**
516 * @param string $query
517 * @param array<string, mixed> $options
518 * @return int
519 */
520 public function queryScalarInt($query, $options = array()): int {
521 $result = $this->queryAndGetResults($query, $options);
522 $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array();
523 if (empty($rows) || !is_array($rows[0])) {
524 return 0;
525 }
526 $first = reset($rows[0]);
527 return is_scalar($first) ? (int)$first : 0;
528 }
529
530
531 /**
532 * Handle error logging, repair attempts, and slow-query logging after query execution.
533 *
534 * @param string $query
535 * @param array<string, mixed> $result
536 * @param array<string, mixed> $options
537 * @param array<int|string, string> $ignoreErrorStrings
538 * @param ABJ_404_Solution_Timer $timer
539 * @return void
540 */
541 private function handleQueryErrorsAndLogging(
542 string $query, array &$result, array $options,
543 array $ignoreErrorStrings, ABJ_404_Solution_Timer $timer
544 ): void {
545 global $wpdb;
546
547 if ($options['log_errors'] && $result['last_error'] != '') {
548 if ($this->f->strpos($result['last_error'],
549 " is marked as crashed ") !== false) {
550 $this->repairTable($result['last_error']);
551 }
552 if ($this->f->strpos($result['last_error'],
553 "ALTER TABLE causes auto_increment resequencing") !== false &&
554 $this->f->strpos($result['last_error'], "resulting in duplicate entry") !== false) {
555 $this->repairDuplicateIDs($result['last_error'], $query);
556 }
557 if ($this->isIncorrectKeyFileError($result['last_error'])) {
558 $this->repairCorruptedTableAndRetry($query, $result);
559 }
560
561 if ($result['last_error'] === '') { return; }
562
563 $reportError = true;
564 foreach ($ignoreErrorStrings as $ignoreThis) {
565 if (is_string($ignoreThis) && strpos($result['last_error'], $ignoreThis) !== false) {
566 $reportError = false;
567 break;
568 }
569 }
570
571 $lastErrorForClassification = is_string($result['last_error']) ? $result['last_error'] : '';
572 if ($reportError && (
573 $this->isDiskFullError($lastErrorForClassification) ||
574 $this->isReadOnlyError($lastErrorForClassification) ||
575 $this->isQuotaLimitError($lastErrorForClassification) ||
576 $this->isInvalidDataError($lastErrorForClassification) ||
577 $this->isCollationError($lastErrorForClassification) ||
578 $this->isMissingPluginTableError($lastErrorForClassification) ||
579 $this->isIncorrectKeyFileError($lastErrorForClassification) ||
580 $this->isCrashedTableError($lastErrorForClassification) ||
581 $this->isDeadlockOrLockTimeoutError($lastErrorForClassification) ||
582 $this->isGaleraConflictError($lastErrorForClassification) ||
583 $this->isTransientConnectionError($lastErrorForClassification) ||
584 $this->isQueryTimeoutError($lastErrorForClassification) ||
585 $this->isAccessDeniedError($lastErrorForClassification)
586 )) {
587 $this->logger->warn("Server-side DB issue (handled): " . $lastErrorForClassification);
588 $reportError = false;
589 }
590
591 if ($reportError) {
592 $stripped_query = 'n/a';
593 if ($this->isInvalidDataError($result['last_error'])) {
594 $strippedResult = $this->get_stripped_query_result($query);
595 $stripped_query = is_string($strippedResult) ? $strippedResult : 'n/a';
596 }
597
598 $extraDataQuery = "select @@max_join_size as max_join_size, " .
599 "@@sql_big_selects as sql_big_selects, " .
600 "@@character_set_database as character_set_database";
601 $someMySQLVariables = $wpdb->get_results($extraDataQuery, ARRAY_A);
602 $variables = print_r($someMySQLVariables, true);
603
604 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->extractSqlFilename($query);
605
606 $dbVer = $wpdb->db_version();
607 $this->logger->errorMessage("Ugh. SQL query error: " . (is_string($result['last_error']) ? $result['last_error'] : '') .
608 ", SQL: " . $sqlInfo .
609 ", Execution time: " . round($timer->getElapsedTime(), 2) .
610 ", DB ver: " . (is_string($dbVer) ? $dbVer : 'unknown') .
611 ", Variables: " . $variables .
612 ", stripped_query: " . $stripped_query);
613 }
614
615 } else {
616 if ($options['log_too_slow'] && $timer->getElapsedTime() > 5) {
617 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->extractSqlFilename($query);
618 $this->logger->debugMessage("Slow query (" . round($timer->getElapsedTime(), 2) . " seconds): " .
619 $sqlInfo);
620 }
621
622 if ($result['last_error'] === '') {
623 if (!$this->serverSideIssueNoted && !$this->serverSideIssueChecked) {
624 $this->serverSideIssueChecked = true;
625 $existing = $this->getRuntimeFlag('abj404_plugin_db_notice');
626 $excludedTypes = array('stale_permalink_cache', 'missing_table');
627 if (is_array($existing) && !empty($existing['type'])
628 && !in_array($existing['type'], $excludedTypes, true)) {
629 $this->serverSideIssueNoted = true;
630 }
631 }
632 if ($this->serverSideIssueNoted && !$this->isWriteBlockActive() && !$this->isQuotaCooldownActive()) {
633 $this->clearServerSideDbNotice();
634 }
635 }
636 }
637 }
638
639 /**
640 * @param string $query
641 * @param array<string, mixed> $options
642 * @return array<string, mixed>
643 */
644 public function queryAndGetResults($query, $options = array()): array {
645 global $wpdb;
646
647 $this->ensureConnection();
648
649 $ignoreErrorStrings = array();
650
651 $options = array_merge(array('log_errors' => true,
652 'log_too_slow' => true, 'ignore_errors' => array(),
653 'query_params' => array(), 'skip_repair' => false,
654 'result_type' => ARRAY_A, 'timeout' => 0),
655 $options);
656 $resultType = $options['result_type'] === OBJECT ? OBJECT : ARRAY_A;
657 $this->currentResultType = $resultType;
658
659 $ignoreErrorStrings = is_array($options['ignore_errors']) ? $options['ignore_errors'] : array();
660 $queryParameters = is_array($options['query_params']) ? $options['query_params'] : array();
661
662 $query = $this->doTableNameReplacements($query);
663
664 if (!empty($queryParameters)) {
665 /** @var literal-string $queryLiteral */
666 $queryLiteral = $query;
667 try {
668 /** @var wpdb $wpdb */
669 $preparedResult = call_user_func_array(array($wpdb, 'prepare'), array_merge(array($queryLiteral), $queryParameters));
670 $query = is_string($preparedResult) ? $preparedResult : $queryLiteral;
671 } catch (Throwable $t) {
672 $preparedFallback = $wpdb->prepare($queryLiteral, $queryParameters);
673 $query = $preparedFallback !== null ? $preparedFallback : $queryLiteral;
674 }
675 }
676
677 $timeoutRaw = isset($options['timeout']) && is_numeric($options['timeout']) ? (int)$options['timeout'] : 0;
678 $timeoutSeconds = $timeoutRaw > 0 ? $timeoutRaw : 60;
679 $query = $this->applyQueryTimeout($query, $timeoutSeconds);
680
681 $this->applyDiagnosticLatencyIfConfigured();
682
683 $timer = new ABJ_404_Solution_Timer();
684
685 $suppressWpdbErrors = !$options['log_errors'] && method_exists($wpdb, 'suppress_errors');
686 $previousSuppressState = false;
687 if ($suppressWpdbErrors) {
688 /** @var wpdb $wpdb */
689 $previousSuppressState = $wpdb->suppress_errors(true);
690 }
691
692 $producesRows = $this->queryProducesResultRows($query);
693
694 $result = array();
695 try {
696 if ($producesRows) {
697 $result['rows'] = $wpdb->get_results($query, $resultType);
698 } else {
699 $wpdb->query($query);
700 $result['rows'] = array();
701 }
702 } catch (Throwable $e) {
703 $result['elapsed_time'] = $timer->stop();
704 $this->logSqlThrowable($query, $e, $options, $producesRows);
705 if ($suppressWpdbErrors) {
706 /** @var wpdb $wpdb */
707 $wpdb->suppress_errors($previousSuppressState);
708 }
709 throw $e;
710 }
711
712 $result['elapsed_time'] = $timer->stop();
713 $elapsedMs = ((float)$result['elapsed_time']) * 1000.0;
714 if (function_exists('abj404_benchmark_record_db_query')) {
715 abj404_benchmark_record_db_query($elapsedMs);
716 }
717 if (function_exists('abj404_query_budget_record')
718 && class_exists('ABJ_404_Solution_QueryBudgetInstrumentation', false)
719 && ABJ_404_Solution_QueryBudgetInstrumentation::isEnabled()) {
720 abj404_query_budget_record($this->extractSqlFilename($query), $elapsedMs, $timeoutSeconds);
721 }
722 $this->harvestWpdbResult($result);
723 $lastErrorForObservedLog = is_string($result['last_error'] ?? null) ? $result['last_error'] : '';
724 if ($lastErrorForObservedLog === '' || !$this->isTransientConnectionError($lastErrorForObservedLog)) {
725 $this->logObservedSqlError($query, $result, $options, $producesRows);
726 }
727
728 if ($producesRows && !is_array($result['rows'])) {
729 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->extractSqlFilename($query);
730 $this->logger->errorMessage("Query result is not an array. Query: " . $sqlInfo,
731 new Exception("Query result is not an array."));
732 }
733
734 $lastErrorForSetStatement = is_string($result['last_error'] ?? null) ? $result['last_error'] : '';
735 if ($lastErrorForSetStatement !== ''
736 && $this->classifySetStatementFailure($lastErrorForSetStatement)
737 && $this->queryHasSetStatementWrapper($query)) {
738 $this->retryWithoutSetStatementWrapper($query, $result, $resultType);
739 $producesRows = $this->queryProducesResultRows($query);
740 }
741
742 if ($result['last_error'] !== '' && $this->isTransientConnectionError($result['last_error'])) {
743 $this->ensureConnection();
744 $wpdb->flush();
745 if ($producesRows) {
746 $result['rows'] = $wpdb->get_results($query, $resultType);
747 } else {
748 $wpdb->query($query);
749 $result['rows'] = array();
750 }
751 $this->harvestWpdbResult($result);
752 }
753
754 if (!$options['skip_repair'] && $result['last_error'] !== '' && $this->isMissingPluginTableError($result['last_error'])) {
755 $this->attemptMissingTableRepairAndRetry($query, $result);
756 }
757
758 $lastError = isset($result['last_error']) && is_scalar($result['last_error']) ? (string)$result['last_error'] : '';
759
760 if ($lastError !== '' && $this->isInvalidDataError($lastError)) {
761 $this->attemptInvalidDataRetry($query, $result);
762 }
763
764 $lastError = isset($result['last_error']) && is_scalar($result['last_error']) ? (string)$result['last_error'] : '';
765
766 if ($lastError !== '' && $this->isDeadlockOrLockTimeoutError($lastError)) {
767 /** @var wpdb $wpdb */
768 usleep(50000);
769 if ($producesRows) {
770 $result['rows'] = $wpdb->get_results($query, $resultType);
771 } else {
772 $wpdb->query($query);
773 $result['rows'] = array();
774 }
775 $this->harvestWpdbResult($result);
776 $lastError = isset($result['last_error']) && is_scalar($result['last_error']) ? (string)$result['last_error'] : '';
777 if ($lastError !== '' && $this->isDeadlockOrLockTimeoutError($lastError)) {
778 // allow-em-dash: copied verbatim from existing user-facing localized string in DataAccess.php
779 $this->setPluginDbNotice('lock_timeout', $this->localizeOrDefault('A database lock wait timeout occurred. If this persists, contact your host — another process may be holding a long-running lock.'), $lastError);
780 }
781 }
782
783 $lastError = isset($result['last_error']) && is_scalar($result['last_error']) ? (string)$result['last_error'] : '';
784 if ($lastError !== '' && $this->isCollationError($lastError)) {
785 $this->recoverFromCollationMismatchAndRetry($query, $result, $producesRows, $resultType);
786 }
787
788 $lastError = isset($result['last_error']) && is_scalar($result['last_error']) ? (string)$result['last_error'] : '';
789 if ($lastError !== '' && $this->isQueryTimeoutError($lastError)) {
790 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->extractSqlFilename($query);
791 $this->logger->warn(
792 'Query timed out after ' . $timeoutSeconds . 's. ' .
793 'Query: ' . substr(preg_replace('/\s+/', ' ', trim($sqlInfo)) ?? $sqlInfo, 0, 500)
794 );
795 $result['rows'] = array();
796 $result['timed_out'] = true;
797 }
798
799 $lastError = isset($result['last_error']) && is_scalar($result['last_error']) ? (string)$result['last_error'] : '';
800 if ($lastError !== '') {
801 $this->noteDatabaseIssueFromError($lastError);
802 }
803
804 if ($suppressWpdbErrors) {
805 /** @var wpdb $wpdb */
806 $wpdb->suppress_errors($previousSuppressState);
807 }
808
809 $this->handleQueryErrorsAndLogging(
810 $query, $result, $options, $ignoreErrorStrings, $timer
811 );
812
813 return $result;
814 }
815
816 /**
817 * Resolve a stable source identifier for safe logging.
818 *
819 * @param string $query
820 * @return string
821 */
822 public function extractSqlFilename($query) {
823 if (is_string($query) && $query !== '') {
824 if (preg_match('/\/\*\s*abj404:src=([A-Za-z0-9_:#.\\\\\-]+)\s*\*\//i', $query, $m)) {
825 return $m[1];
826 }
827 if (preg_match('/\/\*\s*-+\s*(.+?\.sql)\s+BEGIN\s*-+\s*\*\//i', $query, $m)) {
828 return basename($m[1]);
829 }
830 }
831 return $this->resolveCallerFromBacktrace();
832 }
833
834 /** @return string */
835 public function resolveCallerFromBacktrace() {
836 static $internalMethods = array(
837 'extractSqlFilename' => true,
838 'resolveCallerFromBacktrace' => true,
839 'queryAndGetResults' => true,
840 'attemptInvalidDataRetry' => true,
841 'attemptMissingTableRepairAndRetry' => true,
842 'repairCorruptedTableAndRetry' => true,
843 'recoverFromCollationMismatchAndRetry' => true,
844 'call_user_func_array' => true,
845 'call_user_func' => true,
846 );
847 $frames = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 40);
848 foreach ($frames as $frame) {
849 $fn = $frame['function'];
850 if ($fn === '' || isset($internalMethods[$fn])) {
851 continue;
852 }
853 if (strpos($fn, '{closure') !== false) {
854 continue;
855 }
856 $cls = isset($frame['class']) && is_string($frame['class']) ? $frame['class'] : '';
857 $fullFile = isset($frame['file']) && is_string($frame['file']) ? $frame['file'] : '';
858 if ($cls !== '' && (
859 strpos($cls, 'Patchwork') !== false ||
860 strpos($cls, 'PHPUnit\\') === 0
861 )) {
862 continue;
863 }
864 if (strpos($fn, 'Patchwork\\') !== false) {
865 continue;
866 }
867 if ($fullFile !== '' && (
868 strpos($fullFile, '/patchwork/') !== false ||
869 strpos($fullFile, '\\patchwork\\') !== false
870 )) {
871 continue;
872 }
873 $file = $fullFile !== '' ? basename($fullFile) : '';
874 $fileLabel = preg_replace('/\.php$/i', '', $file);
875 if (!is_string($fileLabel)) {
876 $fileLabel = $file;
877 }
878 if (($cls === 'ABJ_404_Solution_DatabaseCore' || $cls === 'ABJ_404_Solution_DataAccess')
879 && $fileLabel !== '' && $fileLabel !== 'DatabaseCore' && $fileLabel !== 'DataAccess') {
880 return $fileLabel . '::' . $fn;
881 }
882 if ($cls !== '') {
883 $shortClass = $cls;
884 $nsPos = strrpos($shortClass, '\\');
885 if ($nsPos !== false) {
886 $shortClass = substr($shortClass, $nsPos + 1);
887 }
888 if (strpos($shortClass, 'ABJ_404_Solution_') === 0) {
889 $shortClass = substr($shortClass, strlen('ABJ_404_Solution_'));
890 }
891 return $shortClass . '::' . $fn;
892 }
893 if ($fileLabel !== '') {
894 return $fileLabel . '::' . $fn;
895 }
896 return $fn;
897 }
898 return 'unknown-source';
899 }
900
901 /**
902 * @param string $collation
903 * @return string
904 */
905 public function sanitizeCollationIdentifier($collation) {
906 if (!is_string($collation) || $collation === '') {
907 return '';
908 }
909 $sanitized = preg_replace('/[^A-Za-z0-9_]/', '', $collation);
910 return $sanitized !== null ? $sanitized : '';
911 }
912
913 /**
914 * Get the table-level default collation for a given table.
915 *
916 * Queries SHOW CREATE TABLE for the COLLATE clause. Falls back to
917 * utf8mb4_unicode_ci on any failure. Result is validated through
918 * sanitizeCollationIdentifier().
919 *
920 * @param string $tableName Fully-qualified table name (including prefix).
921 * @return string
922 */
923 public function getTableCollationString(string $tableName): string {
924 $fallback = 'utf8mb4_unicode_ci';
925 $ddl = $this->getCreateTableDDL($tableName);
926 if (preg_match('/COLLATE[= ]([A-Za-z0-9_]+)/i', $ddl, $m)) {
927 $sanitized = $this->sanitizeCollationIdentifier($m[1]);
928 return $sanitized !== '' ? $sanitized : $fallback;
929 }
930 global $wpdb;
931 if (isset($wpdb) && method_exists($wpdb, 'prepare')) {
932 /** @var wpdb $wpdb */
933 $sql = $wpdb->prepare(
934 "SELECT TABLE_COLLATION FROM information_schema.TABLES "
935 . "WHERE TABLE_SCHEMA = DATABASE() "
936 . "AND TABLE_NAME = %s "
937 . "LIMIT 1",
938 $tableName
939 );
940 if (is_string($sql) && $sql !== '') {
941 $result = $this->queryAndGetResults($sql, array('log_errors' => false));
942 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
943 if (!empty($rows) && is_array($rows[0])) {
944 $row = array_change_key_case($rows[0]);
945 $collation = $row['table_collation'] ?? '';
946 if (is_string($collation) && $collation !== '') {
947 $sanitized = $this->sanitizeCollationIdentifier($collation);
948 return $sanitized !== '' ? $sanitized : $fallback;
949 }
950 }
951 }
952 }
953 return $fallback;
954 }
955
956 /**
957 * Get the column-level collation for a specific column in a table.
958 *
959 * Queries information_schema.COLUMNS for the COLLATION_NAME of the
960 * given column. Falls back to getTableCollationString() if the column
961 * query fails, and ultimately to utf8mb4_unicode_ci. Result is
962 * validated through sanitizeCollationIdentifier().
963 *
964 * @param string $tableName Fully-qualified table name (including prefix).
965 * @param string $columnName Column name to look up.
966 * @return string
967 */
968 public function getColumnCollationString(string $tableName, string $columnName): string {
969 $fallback = 'utf8mb4_unicode_ci';
970 global $wpdb;
971 if (!isset($wpdb) || !method_exists($wpdb, 'prepare')) {
972 return $this->getTableCollationString($tableName);
973 }
974 /** @var wpdb $wpdb */
975 $sql = $wpdb->prepare(
976 "SELECT COLLATION_NAME FROM information_schema.COLUMNS "
977 . "WHERE TABLE_SCHEMA = DATABASE() "
978 . "AND TABLE_NAME = %s "
979 . "AND COLUMN_NAME = %s "
980 . "LIMIT 1",
981 $tableName,
982 $columnName
983 );
984 if (!is_string($sql) || $sql === '') {
985 return $this->getTableCollationString($tableName);
986 }
987 $result = $this->queryAndGetResults($sql, array('log_errors' => false));
988 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
989 if (empty($rows) || !is_array($rows[0])) {
990 return $this->getTableCollationString($tableName);
991 }
992 $row = array_change_key_case($rows[0]);
993 $collation = $row['collation_name'] ?? '';
994 if (!is_string($collation) || $collation === '') {
995 return $this->getTableCollationString($tableName);
996 }
997 $sanitized = $this->sanitizeCollationIdentifier($collation);
998 return $sanitized !== '' ? $sanitized : $fallback;
999 }
1000
1001 /** @return string */
1002 public function getPreferredUtf8mb4Collation() {
1003 global $wpdb;
1004 if (isset($wpdb) && isset($wpdb->collate) && !empty($wpdb->collate)) {
1005 $wpdbCollation = $this->sanitizeCollationIdentifier((string)$wpdb->collate);
1006 if ($wpdbCollation !== '' && stripos($wpdbCollation, 'utf8mb4') !== false) {
1007 return $wpdbCollation;
1008 }
1009 }
1010 return 'utf8mb4_unicode_ci';
1011 }
1012
1013 /**
1014 * @param string $query
1015 * @param array<string, mixed> $result
1016 * @return void
1017 */
1018 public function attemptInvalidDataRetry($query, &$result) {
1019 if (self::$invalidDataRetryInProgress) {
1020 return;
1021 }
1022 self::$invalidDataRetryInProgress = true;
1023 try {
1024 $retryQuery = $this->get_stripped_query_result($query);
1025 $retryQuery = function_exists('apply_filters')
1026 ? apply_filters('abj404_invalid_data_retry_query', $retryQuery, $query)
1027 : $retryQuery;
1028 if (!is_string($retryQuery) || trim($retryQuery) === '' || $retryQuery === $query) {
1029 return;
1030 }
1031 global $wpdb;
1032 $wpdb->flush();
1033 $result['rows'] = $wpdb->get_results($retryQuery, $this->currentResultType);
1034 $this->harvestWpdbResult($result);
1035 } catch (Throwable $e) {
1036 $this->logger->warn("Invalid-data retry failed: " . $e->getMessage());
1037 } finally {
1038 self::$invalidDataRetryInProgress = false;
1039 }
1040 }
1041
1042 /** @return void */
1043 public function applyDiagnosticLatencyIfConfigured(): void {
1044 if (!function_exists('abj404_get_simulated_db_latency_ms')) {
1045 return;
1046 }
1047 $delayMs = absint(abj404_get_simulated_db_latency_ms());
1048 if ($delayMs <= 0) {
1049 return;
1050 }
1051 $delayMs = min(5000, $delayMs);
1052 usleep($delayMs * 1000);
1053 }
1054
1055 /**
1056 * @param array<string, mixed> $result
1057 * @return void
1058 */
1059 public function harvestWpdbResult(array &$result): void {
1060 global $wpdb;
1061 $result['last_error'] = (string)($wpdb->last_error ?? '');
1062 $result['last_result'] = $wpdb->last_result ?? array();
1063 $result['rows_affected'] = $wpdb->rows_affected ?? 0;
1064 $result['insert_id'] = $wpdb->insert_id ?? 0;
1065 }
1066
1067 /**
1068 * @param string $key
1069 * @param mixed $value
1070 * @param int $ttlSeconds
1071 * @return void
1072 */
1073 public function setRuntimeFlag(string $key, $value, int $ttlSeconds): void {
1074 if (function_exists('set_transient')) {
1075 // allow-cache-empty: passthrough helper. Callers store admin-notice payloads, cooldown timestamps, and lock-state markers, not query results.
1076 set_transient($key, $value, $ttlSeconds);
1077 return;
1078 }
1079 if (function_exists('update_option')) {
1080 update_option($key, $value, false);
1081 }
1082 }
1083
1084 /**
1085 * @param string $key
1086 * @return mixed
1087 */
1088 public function getRuntimeFlag(string $key) {
1089 if (function_exists('get_transient')) {
1090 return get_transient($key);
1091 }
1092 if (function_exists('get_option')) {
1093 return get_option($key, false);
1094 }
1095 return false;
1096 }
1097
1098 /**
1099 * @param string $type
1100 * @param string $message
1101 * @param string $errorString
1102 * @return void
1103 */
1104 public function setPluginDbNotice(string $type, string $message, string $errorString = ''): void {
1105 $payload = array(
1106 'type' => $type,
1107 'message' => $message,
1108 'timestamp' => $this->clock()->now(),
1109 'error_string' => $errorString,
1110 );
1111 $this->setRuntimeFlag('abj404_plugin_db_notice', $payload, self::DB_WRITE_BLOCK_COOLDOWN_SECONDS);
1112 }
1113
1114 /**
1115 * @param string $type
1116 * @return void
1117 */
1118 public function clearPluginDbNoticeIfType(string $type): void {
1119 $existing = $this->getRuntimeFlag('abj404_plugin_db_notice');
1120 if (!is_array($existing)) {
1121 return;
1122 }
1123 $currentType = isset($existing['type']) && is_string($existing['type']) ? $existing['type'] : '';
1124 if ($currentType !== $type) {
1125 return;
1126 }
1127 $this->clearServerSideDbNotice();
1128 }
1129
1130 /** @return void */
1131 public function clearServerSideDbNotice(): void {
1132 if (function_exists('delete_transient')) {
1133 delete_transient('abj404_plugin_db_notice');
1134 } elseif (function_exists('delete_option')) {
1135 delete_option('abj404_plugin_db_notice');
1136 }
1137 $this->serverSideIssueNoted = false;
1138 }
1139
1140 /** @param string $text @return string */
1141 public function localizeOrDefault(string $text): string {
1142 if (function_exists('__')) {
1143 return __($text, '404-solution');
1144 }
1145 return $text;
1146 }
1147
1148 /** @return bool */
1149 public function isWriteBlockActive(): bool {
1150 $rawDiskFlag = $this->getRuntimeFlag('abj404_db_disk_full_until');
1151 $diskUntil = is_scalar($rawDiskFlag) ? (int)$rawDiskFlag : 0;
1152 $rawReadOnlyFlag = $this->getRuntimeFlag('abj404_db_read_only_until');
1153 $readOnlyUntil = is_scalar($rawReadOnlyFlag) ? (int)$rawReadOnlyFlag : 0;
1154 $now = $this->clock()->now();
1155 return ($diskUntil > $now || $readOnlyUntil > $now);
1156 }
1157
1158 /** @return bool */
1159 public function shouldSkipNonEssentialDbWrites(): bool {
1160 return ($this->isQuotaCooldownActive() || $this->isWriteBlockActive());
1161 }
1162
1163 /**
1164 * Attempt REPAIR TABLE after errno 1034, then retry once.
1165 *
1166 * @param string $query
1167 * @param array<string, mixed> $result
1168 * @return void
1169 */
1170 public function repairCorruptedTableAndRetry(string $query, array &$result): void {
1171 $errorMessage = is_string($result['last_error']) ? $result['last_error'] : '';
1172 $this->repairTable($errorMessage);
1173 if (stripos($errorMessage, 'abj404') !== false) {
1174 global $wpdb;
1175 $wpdb->flush();
1176 $result['rows'] = $wpdb->get_results($query, $this->currentResultType);
1177 $result['last_error'] = (string)($wpdb->last_error ?? '');
1178 $result['last_result'] = $wpdb->last_result ?? array();
1179 $result['rows_affected'] = $wpdb->rows_affected ?? 0;
1180 $result['insert_id'] = $wpdb->insert_id ?? 0;
1181 if ($result['last_error'] === '') {
1182 $this->logger->infoMessage("Retry after 'Incorrect key file' repair succeeded for plugin table.");
1183 }
1184 }
1185 }
1186
1187 /**
1188 * @param string $query
1189 * @return NULL|string|WP_Error
1190 */
1191 public function get_stripped_query_result($query) {
1192 try {
1193 if (!class_exists('wpdb')) {
1194 return null;
1195 }
1196 if (!method_exists('wpdb', 'strip_invalid_text_from_query')) {
1197 return null;
1198 }
1199
1200 $filename = ABJ404_PATH . 'includes/php/wordpress/WPDBExtension.php';
1201 if (!file_exists($filename)) {
1202 return null;
1203 }
1204 require_once $filename;
1205
1206 $my_custom_db = null;
1207 if (class_exists('ABJ_404_Solution_WPDBExtension_PHP7')) {
1208 $my_custom_db = new ABJ_404_Solution_WPDBExtension_PHP7(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST);
1209 } else if (class_exists('ABJ_404_Solution_WPDBExtension_PHP5')) {
1210 $my_custom_db = new ABJ_404_Solution_WPDBExtension_PHP5(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST);
1211 }
1212 if ($my_custom_db == null) {
1213 return null;
1214 }
1215
1216 $result = $my_custom_db->public_strip_invalid_text_from_query($query);
1217
1218 if (is_wp_error($result)) {
1219 return 'WP_Error: ' . $result->get_error_message();
1220 }
1221
1222 return $result;
1223
1224 } catch (Throwable $e) {
1225 $this->logger->warn(
1226 'get_stripped_query_result failed; returning null: ' . $e->getMessage()
1227 );
1228 return null;
1229 }
1230 }
1231
1232 // =========================================================================
1233 // Repair / recovery methods (moved from DataAccessTrait_Maintenance, Phase 5)
1234 // =========================================================================
1235
1236 /**
1237 * Auto-recover from a collation mismatch detected at query time.
1238 *
1239 * @param string $query
1240 * @param array<string, mixed> $result passed by reference
1241 * @param bool $producesRows Whether the query returns result rows.
1242 * @param 'OBJECT'|'OBJECT_K'|'ARRAY_A'|'ARRAY_N' $resultType wpdb output type for get_results().
1243 * @return void
1244 */
1245 public function recoverFromCollationMismatchAndRetry(string $query, array &$result, bool $producesRows, string $resultType): void {
1246 if (self::$collationRecoveryInProgress) {
1247 return;
1248 }
1249
1250 $cooldownKey = 'abj404_collation_recovery_cooldown';
1251 $cooldownUntil = $this->getRuntimeFlag($cooldownKey);
1252 $onCooldown = is_scalar($cooldownUntil) && (int)$cooldownUntil > $this->clock()->now();
1253
1254 if (!$onCooldown) {
1255 self::$collationRecoveryInProgress = true;
1256 try {
1257 $this->logger->infoMessage("Collation mismatch detected: running correctCollations() to converge plugin tables."); // allow-em-dash: original string from DataAccessTrait_Maintenance had em dash, replaced with colon
1258 if (class_exists('ABJ_404_Solution_DatabaseUpgradesEtc')) {
1259 $upgrades = abj_service('database_upgrades');
1260 if (method_exists($upgrades, 'correctCollations')) {
1261 $upgrades->correctCollations();
1262 }
1263 }
1264 } catch (Throwable $e) {
1265 $this->logger->warn("correctCollations() threw during collation auto-recovery: " . $e->getMessage());
1266 } finally {
1267 self::$collationRecoveryInProgress = false;
1268 $this->setRuntimeFlag($cooldownKey, $this->clock()->now() + 3600, 3600);
1269 }
1270 }
1271
1272 global $wpdb;
1273 /** @var wpdb $wpdb */
1274 $wpdb->flush();
1275 if ($producesRows) {
1276 $result['rows'] = $wpdb->get_results($query, $resultType);
1277 } else {
1278 $wpdb->query($query);
1279 $result['rows'] = array();
1280 }
1281 $this->harvestWpdbResult($result);
1282
1283 if ($result['last_error'] === '') {
1284 $this->logger->debugMessage("Collation auto-recovery succeeded; query retry passed.");
1285 }
1286 }
1287
1288 /**
1289 * Validate and sanitize a table name extracted from error messages or SQL.
1290 *
1291 * @param string $name Raw table name
1292 * @return string|null Sanitized name, or null if invalid
1293 */
1294 private function sanitizeTableName(string $name): ?string {
1295 $name = trim($name, '`');
1296 if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
1297 $this->logger->warn("sanitizeTableName: rejected invalid table name: " . substr($name, 0, 100));
1298 return null;
1299 }
1300 if (strpos($name, 'abj404') === false) {
1301 $this->logger->warn("sanitizeTableName: rejected non-plugin table name: " . $name);
1302 return null;
1303 }
1304 return $name;
1305 }
1306
1307 /** @inheritDoc */
1308 public function repairTable(string $errorMessage): void {
1309
1310 $re1 = "Table '(.*\/)?(.+)' is marked as crashed and ";
1311 $re2 = "Incorrect key file for table '(?:.*\/)?([^'.]+?)(?:\\.MYI)?'";
1312
1313 $matches = array();
1314 $this->f->regexMatch($re1, $errorMessage, $matches);
1315
1316 if (empty($matches) || count($matches) <= 2 || $this->f->strlen($matches[2]) === 0) {
1317 $this->f->regexMatch($re2, $errorMessage, $matches);
1318 if (!empty($matches) && isset($matches[1]) && $this->f->strlen($matches[1]) > 0) {
1319 $matches[2] = $matches[1];
1320 }
1321 }
1322
1323 if (!empty($matches) && count($matches) > 2 && $this->f->strlen($matches[2]) > 0) {
1324 $rawTableName = $matches[2];
1325 $tableToRepair = $this->sanitizeTableName($rawTableName);
1326 if ($tableToRepair !== null) {
1327 $query = "REPAIR TABLE `{$tableToRepair}`";
1328 $result = $this->queryAndGetResults($query, array('log_errors' => false));
1329 $this->logger->infoMessage("Attempted to repair table " . $tableToRepair . ". Result: " .
1330 json_encode($result));
1331 } else {
1332 $this->logger->warn("The table " . $rawTableName . " needs to be " .
1333 "repaired with something like: repair table " . $rawTableName);
1334
1335 $cooldownKey = 'abj404_corrupted_temp_table_notice_until';
1336 $alreadyNotified = function_exists('get_transient') ? get_transient($cooldownKey) : false;
1337 if (!$alreadyNotified) {
1338 $noticeMessage = $this->localizeOrDefault( // allow-em-dash: verbatim localized string from production, changing it breaks existing translations
1339 'A database temporary table is corrupted — this is usually caused by a full or failing disk. Please contact your host. (MySQL error 1034)');
1340 $this->setPluginDbNotice('corrupted_temp_table', $noticeMessage, $errorMessage);
1341 if (function_exists('set_transient')) {
1342 // @cache-write-audit: opt-out - admin-notice dedup cooldown
1343 // (one notice per 24h per failure type), not a query result.
1344 set_transient($cooldownKey, 1, 86400);
1345 }
1346 }
1347 }
1348 }
1349 }
1350
1351 /** @inheritDoc */
1352 public function repairDuplicateIDs(string $errorMessage, string $sqlThatWasRun): void {
1353
1354 $reForID = 'resulting in duplicate entry \'(.+)\' for key';
1355 $reForTableName = "ALTER TABLE (.+) ADD ";
1356 $matchesForID = null;
1357 $matchesForTableName = null;
1358
1359 $this->f->regexMatch($reForID, $errorMessage, $matchesForID);
1360 $this->f->regexMatch($reForTableName, $sqlThatWasRun, $matchesForTableName);
1361 if (is_array($matchesForID) && isset($matchesForID[1]) && $this->f->strlen($matchesForID[1]) > 0 &&
1362 is_array($matchesForTableName) && isset($matchesForTableName[1]) && $this->f->strlen($matchesForTableName[1]) > 0) {
1363
1364 $idWithDuplicate = $matchesForID[1];
1365 $tableName = $this->sanitizeTableName($matchesForTableName[1]);
1366 if ($tableName === null) {
1367 $this->logger->warn("repairDuplicateIDs: rejected invalid table name from SQL: " . substr($matchesForTableName[1], 0, 100));
1368 return;
1369 }
1370
1371 if (!is_numeric($idWithDuplicate)) {
1372 $this->logger->errorMessage("Invalid ID extracted from error message: " . $idWithDuplicate);
1373 return;
1374 }
1375
1376 if ($idWithDuplicate == 1) {
1377 $idWithDuplicate = 0;
1378 }
1379
1380 $result = $this->queryAndGetResults("DELETE FROM `{$tableName}` where id = %d",
1381 array('log_errors' => false, 'query_params' => array(absint($idWithDuplicate))));
1382 $this->logger->infoMessage("Attempted to fix a duplicate entry issue. Table: " .
1383 $tableName . ", Result: " . json_encode($result));
1384 }
1385 }
1386
1387 /** @inheritDoc */
1388 public function executeAsTransaction(array $statementArray): void {
1389 global $wpdb;
1390 $maxAttempts = 3;
1391 $lastException = null;
1392 $lastError = '';
1393
1394 for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
1395 $allIsWell = true;
1396 $lastError = '';
1397 $lastException = null;
1398 try {
1399 $wpdb->query('START TRANSACTION');
1400 foreach ($statementArray as $statement) {
1401 $wpdb->query($statement);
1402 if ($wpdb->last_error != null && trim((string)$wpdb->last_error) !== '') {
1403 $allIsWell = false;
1404 $lastError = (string)$wpdb->last_error;
1405 if (!$this->classifyAndHandleInfrastructureError($lastError)) {
1406 $this->logger->errorMessage("Error executing SQL transaction: " . $lastError);
1407 $this->logger->errorMessage("SQL causing the transaction error: " . $statement);
1408 }
1409 break;
1410 }
1411 }
1412 } catch (Throwable $ex) {
1413 $allIsWell = false;
1414 $lastException = $ex;
1415 $lastError = $ex->getMessage();
1416 }
1417
1418 if ($allIsWell && $lastException == null) {
1419 $wpdb->query('commit');
1420 return;
1421 }
1422
1423 $wpdb->query('rollback');
1424 $retryable = $this->isDeadlockOrLockTimeoutError($lastError);
1425 if (!$retryable || $attempt >= $maxAttempts) {
1426 break;
1427 }
1428 $sleepMicros = 100000 + random_int(0, 200000);
1429 usleep($sleepMicros);
1430 }
1431
1432 if ($lastException != null) {
1433 throw $lastException;
1434 }
1435 if ($lastError !== '') {
1436 throw new Exception($lastError);
1437 }
1438 }
1439 }
1440