| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Self-healing table repair + invalid-data retry for plugin SQL queries. |
| 9 |
* |
| 10 |
* Extracted from DatabaseCore as part of the (4/6) DatabaseCore decomposition. |
| 11 |
* Owns two cohesive responsibilities that are both error-driven, single-retry, |
| 12 |
* recursion-guarded recovery paths for SQL errors observed by queryAndGetResults: |
| 13 |
* |
| 14 |
* 1. REPAIR TABLE for "marked as crashed" and "Incorrect key file" errors. |
| 15 |
* Parses the affected table name out of the wpdb error message, validates |
| 16 |
* it as a plugin table (abj404 prefix only), runs REPAIR TABLE, and (for |
| 17 |
* the "Incorrect key file" path) flushes and retries the original query |
| 18 |
* once. If the table name cannot be sanitized, surfaces a deduplicated |
| 19 |
* admin notice instead. |
| 20 |
* 2. Invalid-data retry. When wpdb reports an invalid-data error, asks |
| 21 |
* WPDBExtension to strip the invalid bytes from the query, then flushes |
| 22 |
* and retries the stripped query once. Recursion guard prevents infinite |
| 23 |
* loops if the stripped query also fails. |
| 24 |
* 3. Duplicate-id repair for the ALTER TABLE auto_increment resequencing |
| 25 |
* failure mode. Parses the duplicate id out of the error and the target |
| 26 |
* table out of the SQL, validates both, and deletes the conflicting row. |
| 27 |
* |
| 28 |
* This class holds no DatabaseCore back-reference. It receives: |
| 29 |
* - a query-runner callable bound over DatabaseCore::queryAndGetResults |
| 30 |
* (signature: function(string, array<string,mixed>): array<string,mixed>); |
| 31 |
* - a result-harvester callable bound over DatabaseWpdbResultHarvester |
| 32 |
* (signature: function(array<string,mixed>): void, by-reference); |
| 33 |
* - a result-type getter bound over DatabaseCore::getCurrentResultType |
| 34 |
* (signature: function(): string); |
| 35 |
* - a notice setter bound over DatabaseNoticeStateHolder::setPluginDbNotice |
| 36 |
* (signature: function(string, string, string, string): void); |
| 37 |
* - a connection-reset callable bound over |
| 38 |
* DatabaseConnectionManager::resetForRetry (signature: function(string): bool); |
| 39 |
* - ABJ_404_Solution_Functions for regex/string helpers and the plugin logger. |
| 40 |
* |
| 41 |
* The recursion guards are static properties on this class (they must survive |
| 42 |
* across helper invocations within a single request) and are functionally |
| 43 |
* identical to the former DatabaseCore::$tableRepairInProgress and |
| 44 |
* DatabaseCore::$invalidDataRetryInProgress. |
| 45 |
*/ |
| 46 |
class ABJ_404_Solution_DatabaseTableRepairer { |
| 47 |
|
| 48 |
/** @var bool Prevent recursive auto-repair attempts on SQL errors. */ |
| 49 |
private static $tableRepairInProgress = false; |
| 50 |
|
| 51 |
/** @var bool Prevent recursive invalid-data retry attempts. */ |
| 52 |
private static $invalidDataRetryInProgress = false; |
| 53 |
|
| 54 |
/** @var callable(string, array<string,mixed>): array<string,mixed> */ |
| 55 |
private $queryRunner; |
| 56 |
|
| 57 |
/** @var callable(array<string,mixed>): void */ |
| 58 |
private $resultHarvester; |
| 59 |
|
| 60 |
/** @var callable(): string */ |
| 61 |
private $resultTypeGetter; |
| 62 |
|
| 63 |
/** @var callable(string, string, string, string): void */ |
| 64 |
private $noticeSetter; |
| 65 |
|
| 66 |
/** @var callable(string): bool */ |
| 67 |
private $connectionResetter; |
| 68 |
|
| 69 |
/** @var ABJ_404_Solution_Functions */ |
| 70 |
private $f; |
| 71 |
|
| 72 |
/** @var ABJ_404_Solution_Logging */ |
| 73 |
private $logger; |
| 74 |
|
| 75 |
/** |
| 76 |
* @param callable(string, array<string,mixed>): array<string,mixed> $queryRunner |
| 77 |
* Runs a SQL query through the centralized error-handling pipeline and |
| 78 |
* returns its result array. Bound by DatabaseCore over queryAndGetResults(). |
| 79 |
* @param callable(array<string,mixed>): void $resultHarvester |
| 80 |
* Copies wpdb->last_error / rows_affected / insert_id into the result array, |
| 81 |
* by reference. Bound by DatabaseCore over DatabaseWpdbResultHarvester. |
| 82 |
* @param callable(): string $resultTypeGetter |
| 83 |
* Returns the current wpdb result type (ARRAY_A or OBJECT) for retries. |
| 84 |
* @param callable(string, string, string, string): void $noticeSetter |
| 85 |
* Persists a plugin-db admin notice (type, message, guidance, errorString). |
| 86 |
* @param callable(string): bool $connectionResetter |
| 87 |
* Resets the active wpdb connection before a recovery retry. |
| 88 |
* @param ABJ_404_Solution_Functions $functions |
| 89 |
* @param ABJ_404_Solution_Logging $logger |
| 90 |
*/ |
| 91 |
public function __construct( |
| 92 |
callable $queryRunner, |
| 93 |
callable $resultHarvester, |
| 94 |
callable $resultTypeGetter, |
| 95 |
callable $noticeSetter, |
| 96 |
callable $connectionResetter, |
| 97 |
$functions, |
| 98 |
$logger |
| 99 |
) { |
| 100 |
$this->queryRunner = $queryRunner; |
| 101 |
$this->resultHarvester = $resultHarvester; |
| 102 |
$this->resultTypeGetter = $resultTypeGetter; |
| 103 |
$this->noticeSetter = $noticeSetter; |
| 104 |
$this->connectionResetter = $connectionResetter; |
| 105 |
$this->f = $functions; |
| 106 |
$this->logger = $logger; |
| 107 |
} |
| 108 |
|
| 109 |
/** |
| 110 |
* Reset the static recursion guards. Intended for test setUp/tearDown only. |
| 111 |
* |
| 112 |
* @return void |
| 113 |
*/ |
| 114 |
public static function resetRecursionGuardsForTests(): void { |
| 115 |
self::$tableRepairInProgress = false; |
| 116 |
self::$invalidDataRetryInProgress = false; |
| 117 |
} |
| 118 |
|
| 119 |
/** @return bool */ |
| 120 |
public function isTableRepairInProgress(): bool { |
| 121 |
return self::$tableRepairInProgress; |
| 122 |
} |
| 123 |
|
| 124 |
/** @param bool $value @return void */ |
| 125 |
public function setTableRepairInProgress(bool $value): void { |
| 126 |
self::$tableRepairInProgress = $value; |
| 127 |
} |
| 128 |
|
| 129 |
/** |
| 130 |
* Validate and sanitize a table name extracted from error messages or SQL. |
| 131 |
* |
| 132 |
* Rejects anything that isn't [A-Za-z0-9_]+ and anything that doesn't |
| 133 |
* contain the plugin's "abj404" prefix substring. Logs (warn) on reject so |
| 134 |
* the rejection is visible at audit time without surfacing to the admin. |
| 135 |
* |
| 136 |
* @param string $name Raw table name (may include backticks). |
| 137 |
* @return string|null Sanitized name, or null if invalid. |
| 138 |
*/ |
| 139 |
public function sanitizeTableName(string $name): ?string { |
| 140 |
$name = trim($name, '`'); |
| 141 |
if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) { |
| 142 |
$this->logger->warn("sanitizeTableName: rejected invalid table name: " . substr($name, 0, 100)); |
| 143 |
return null; |
| 144 |
} |
| 145 |
if (strpos($name, 'abj404') === false) { |
| 146 |
$this->logger->warn("sanitizeTableName: rejected non-plugin table name: " . $name); |
| 147 |
return null; |
| 148 |
} |
| 149 |
return $name; |
| 150 |
} |
| 151 |
|
| 152 |
/** |
| 153 |
* Run REPAIR TABLE for a table named in a wpdb error string. |
| 154 |
* |
| 155 |
* Recognizes both the "is marked as crashed" message and the "Incorrect |
| 156 |
* key file for table" message. If the table name cannot be sanitized |
| 157 |
* (typically because it is a temporary table outside our prefix, e.g. |
| 158 |
* "#sql_xxx_0.MYI" on a corrupted-disk host), falls back to a 24-hour |
| 159 |
* deduplicated admin notice telling the site owner to contact their host. |
| 160 |
* |
| 161 |
* @param string $errorMessage The wpdb->last_error string. |
| 162 |
* @return void |
| 163 |
*/ |
| 164 |
public function repairTable(string $errorMessage): void { |
| 165 |
$re1 = "Table '(.*\/)?(.+)' is marked as crashed and "; |
| 166 |
$re2 = "Incorrect key file for table '(?:.*\/)?([^'.]+?)(?:\\.MYI)?'"; |
| 167 |
|
| 168 |
$matches = array(); |
| 169 |
$this->f->regexMatch($re1, $errorMessage, $matches); |
| 170 |
|
| 171 |
if (empty($matches) || count($matches) <= 2 || $this->f->strlen($matches[2]) === 0) { |
| 172 |
$this->f->regexMatch($re2, $errorMessage, $matches); |
| 173 |
if (!empty($matches) && isset($matches[1]) && $this->f->strlen($matches[1]) > 0) { |
| 174 |
$matches[2] = $matches[1]; |
| 175 |
} |
| 176 |
} |
| 177 |
|
| 178 |
if (!empty($matches) && count($matches) > 2 && $this->f->strlen($matches[2]) > 0) { |
| 179 |
$rawTableName = $matches[2]; |
| 180 |
$tableToRepair = $this->sanitizeTableName($rawTableName); |
| 181 |
if ($tableToRepair !== null) { |
| 182 |
$query = "REPAIR TABLE `{$tableToRepair}`"; |
| 183 |
$result = ($this->queryRunner)($query, array('log_errors' => false)); |
| 184 |
$this->logger->infoMessage("Attempted to repair table " . $tableToRepair . ". Result: " . |
| 185 |
json_encode($result)); |
| 186 |
} else { |
| 187 |
$this->logger->warn("The table " . $rawTableName . " needs to be " . |
| 188 |
"repaired with something like: repair table " . $rawTableName); |
| 189 |
|
| 190 |
$cooldownKey = 'abj404_corrupted_temp_table_notice_until'; |
| 191 |
$alreadyNotified = function_exists('get_transient') ? get_transient($cooldownKey) : false; |
| 192 |
if (!$alreadyNotified) { |
| 193 |
($this->noticeSetter)( |
| 194 |
'corrupted_temp_table', |
| 195 |
function_exists('__') ? __('A database temporary table is corrupted - this is usually caused by a full or failing disk. Please contact your host. (MySQL error 1034)', '404-solution') : 'A database temporary table is corrupted - this is usually caused by a full or failing disk. Please contact your host. (MySQL error 1034)', |
| 196 |
function_exists('__') ? __('A temporary MySQL table was corrupted, usually caused by disk or hardware issues. The plugin cannot repair it. Please contact your hosting provider.', '404-solution') : 'A temporary MySQL table was corrupted, usually caused by disk or hardware issues. The plugin cannot repair it. Please contact your hosting provider.', |
| 197 |
$errorMessage |
| 198 |
); |
| 199 |
if (function_exists('set_transient')) { |
| 200 |
// @cache-write-audit: opt-out - admin-notice dedup cooldown |
| 201 |
// (one notice per 24h per failure type), not a query result. |
| 202 |
set_transient($cooldownKey, 1, 86400); |
| 203 |
} |
| 204 |
} |
| 205 |
} |
| 206 |
} |
| 207 |
} |
| 208 |
|
| 209 |
/** |
| 210 |
* Resolve the ALTER TABLE auto_increment resequencing duplicate-id case. |
| 211 |
* |
| 212 |
* Parses the duplicate id from the error message and the table name from |
| 213 |
* the original ALTER TABLE SQL, validates both, and deletes the conflicting |
| 214 |
* row so the ALTER can be retried by the caller. |
| 215 |
* |
| 216 |
* @param string $errorMessage The wpdb->last_error string. |
| 217 |
* @param string $sqlThatWasRun The ALTER TABLE statement wpdb just ran. |
| 218 |
* @return void |
| 219 |
*/ |
| 220 |
public function repairDuplicateIDs(string $errorMessage, string $sqlThatWasRun): void { |
| 221 |
$reForID = 'resulting in duplicate entry \'(.+)\' for key'; |
| 222 |
$reForTableName = "ALTER TABLE (.+) ADD "; |
| 223 |
$matchesForID = null; |
| 224 |
$matchesForTableName = null; |
| 225 |
|
| 226 |
$this->f->regexMatch($reForID, $errorMessage, $matchesForID); |
| 227 |
$this->f->regexMatch($reForTableName, $sqlThatWasRun, $matchesForTableName); |
| 228 |
if (is_array($matchesForID) && isset($matchesForID[1]) && $this->f->strlen($matchesForID[1]) > 0 && |
| 229 |
is_array($matchesForTableName) && isset($matchesForTableName[1]) && $this->f->strlen($matchesForTableName[1]) > 0) { |
| 230 |
|
| 231 |
$idWithDuplicate = $matchesForID[1]; |
| 232 |
$tableName = $this->sanitizeTableName($matchesForTableName[1]); |
| 233 |
if ($tableName === null) { |
| 234 |
$this->logger->warn("repairDuplicateIDs: rejected invalid table name from SQL: " . substr($matchesForTableName[1], 0, 100)); |
| 235 |
return; |
| 236 |
} |
| 237 |
|
| 238 |
if (!is_numeric($idWithDuplicate)) { |
| 239 |
$this->logger->errorMessage("Invalid ID extracted from error message: " . $idWithDuplicate); |
| 240 |
return; |
| 241 |
} |
| 242 |
|
| 243 |
if ($idWithDuplicate == 1) { |
| 244 |
$idWithDuplicate = 0; |
| 245 |
} |
| 246 |
|
| 247 |
$result = ($this->queryRunner)("DELETE FROM `{$tableName}` where id = %d", |
| 248 |
array('log_errors' => false, 'query_params' => array(absint($idWithDuplicate)))); |
| 249 |
$this->logger->infoMessage("Attempted to fix a duplicate entry issue. Table: " . |
| 250 |
$tableName . ", Result: " . json_encode($result)); |
| 251 |
} |
| 252 |
} |
| 253 |
|
| 254 |
/** |
| 255 |
* Attempt REPAIR TABLE after MySQL errno 1034 ("Incorrect key file"), then |
| 256 |
* retry the original query once. |
| 257 |
* |
| 258 |
* On retry success, $result is mutated to the retried result; on retry |
| 259 |
* failure, $result carries the retried last_error so the surrounding |
| 260 |
* pipeline can continue its error-handling. |
| 261 |
* |
| 262 |
* @param string $query |
| 263 |
* @param array<string, mixed> $result Passed by reference. |
| 264 |
* @param ABJ_404_Solution_DatabaseQueryRecoveryTracer|null $tracer |
| 265 |
* @return void |
| 266 |
*/ |
| 267 |
public function repairCorruptedTableAndRetry( |
| 268 |
string $query, |
| 269 |
array &$result, |
| 270 |
?ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer = null |
| 271 |
): void { |
| 272 |
$errorMessage = is_string($result['last_error']) ? $result['last_error'] : ''; |
| 273 |
$this->repairTable($errorMessage); |
| 274 |
if (stripos($errorMessage, 'abj404') !== false) { |
| 275 |
global $wpdb; |
| 276 |
if ($tracer === null) { |
| 277 |
if (!(($this->connectionResetter)($errorMessage))) { |
| 278 |
return; |
| 279 |
} |
| 280 |
} else { |
| 281 |
$reset = $tracer->traceOperation( |
| 282 |
'corrupted_table', |
| 283 |
'connection_retry_reset', |
| 284 |
fn(): bool => ($this->connectionResetter)($errorMessage) |
| 285 |
); |
| 286 |
if (!$reset) { |
| 287 |
return; |
| 288 |
} |
| 289 |
} |
| 290 |
$resultType = ($this->resultTypeGetter)(); |
| 291 |
// DAO-bypass-approved: retry-after-repair is part of the DAO's |
| 292 |
// self-healing pipeline; calling queryAndGetResults() here would |
| 293 |
// re-enter the error-handler that just invoked us. |
| 294 |
$retry = function () use ($wpdb, $query, $resultType): array { |
| 295 |
$retried = array('rows' => $wpdb->get_results($query, $resultType)); |
| 296 |
$retried['last_error'] = (string)($wpdb->last_error ?? ''); |
| 297 |
$retried['last_result'] = $wpdb->last_result ?? array(); |
| 298 |
$retried['rows_affected'] = $wpdb->rows_affected ?? 0; |
| 299 |
$retried['insert_id'] = $wpdb->insert_id ?? 0; |
| 300 |
return $retried; |
| 301 |
}; |
| 302 |
$retried = $tracer === null |
| 303 |
? $retry() |
| 304 |
: $tracer->traceAttempt( |
| 305 |
'corrupted_table', |
| 306 |
'corrupted_table', |
| 307 |
$retry |
| 308 |
); |
| 309 |
$result = array_merge($result, $retried); |
| 310 |
if ($result['last_error'] === '') { |
| 311 |
$this->logger->infoMessage("Retry after 'Incorrect key file' repair succeeded for plugin table."); |
| 312 |
} |
| 313 |
} |
| 314 |
} |
| 315 |
|
| 316 |
/** |
| 317 |
* Attempt a single invalid-data retry by asking WPDBExtension to strip |
| 318 |
* invalid bytes from the query, then re-running the stripped query. |
| 319 |
* |
| 320 |
* Recursion-guarded: if the stripped query also produces an invalid-data |
| 321 |
* error, the second call short-circuits. The `abj404_invalid_data_retry_query` |
| 322 |
* filter lets site owners override the stripped query (e.g. to enforce a |
| 323 |
* stricter sanitization policy). |
| 324 |
* |
| 325 |
* @param string $query |
| 326 |
* @param array<string, mixed> $result Passed by reference. |
| 327 |
* @param ABJ_404_Solution_DatabaseQueryRecoveryTracer|null $tracer |
| 328 |
* @return void |
| 329 |
*/ |
| 330 |
public function attemptInvalidDataRetry( |
| 331 |
$query, |
| 332 |
&$result, |
| 333 |
?ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer = null |
| 334 |
) { |
| 335 |
if (self::$invalidDataRetryInProgress) { |
| 336 |
return; |
| 337 |
} |
| 338 |
self::$invalidDataRetryInProgress = true; |
| 339 |
try { |
| 340 |
$prepareRetry = function () use ($query) { |
| 341 |
$retryQuery = $this->get_stripped_query_result($query); |
| 342 |
return function_exists('apply_filters') |
| 343 |
? apply_filters('abj404_invalid_data_retry_query', $retryQuery, $query) |
| 344 |
: $retryQuery; |
| 345 |
}; |
| 346 |
$retryQuery = $tracer === null |
| 347 |
? $prepareRetry() |
| 348 |
: $tracer->traceOperation( |
| 349 |
'invalid_data', |
| 350 |
'retry_prepare', |
| 351 |
$prepareRetry |
| 352 |
); |
| 353 |
if (!is_string($retryQuery) || trim($retryQuery) === '' || $retryQuery === $query) { |
| 354 |
return; |
| 355 |
} |
| 356 |
global $wpdb; |
| 357 |
$retryError = isset($result['last_error']) && is_scalar($result['last_error']) |
| 358 |
? (string)$result['last_error'] |
| 359 |
: ''; |
| 360 |
if ($tracer === null) { |
| 361 |
if (!(($this->connectionResetter)($retryError))) { |
| 362 |
return; |
| 363 |
} |
| 364 |
} else { |
| 365 |
$reset = $tracer->traceOperation( |
| 366 |
'invalid_data', |
| 367 |
'connection_retry_reset', |
| 368 |
fn(): bool => ($this->connectionResetter)($retryError) |
| 369 |
); |
| 370 |
if (!$reset) { |
| 371 |
return; |
| 372 |
} |
| 373 |
} |
| 374 |
$resultType = ($this->resultTypeGetter)(); |
| 375 |
// DAO-bypass-approved: retry-after-strip is part of the DAO's |
| 376 |
// self-healing pipeline; calling queryAndGetResults() here would |
| 377 |
// re-enter the error-handler that just invoked us. |
| 378 |
$retry = function () use ($wpdb, $retryQuery, $resultType): array { |
| 379 |
$retried = array('rows' => $wpdb->get_results($retryQuery, $resultType)); |
| 380 |
($this->resultHarvester)($retried); |
| 381 |
return $retried; |
| 382 |
}; |
| 383 |
$retried = $tracer === null |
| 384 |
? $retry() |
| 385 |
: $tracer->traceAttempt('invalid_data', 'invalid_data', $retry); |
| 386 |
$result = array_merge($result, $retried); |
| 387 |
} catch (Throwable $e) { |
| 388 |
$this->logger->warn("Invalid-data retry failed: " . $e->getMessage()); |
| 389 |
} finally { |
| 390 |
self::$invalidDataRetryInProgress = false; |
| 391 |
} |
| 392 |
} |
| 393 |
|
| 394 |
/** |
| 395 |
* Ask WPDBExtension to strip invalid bytes from a query string so the |
| 396 |
* caller can re-issue a safe version. |
| 397 |
* |
| 398 |
* Returns null on any failure (missing extension file, wpdb method |
| 399 |
* unavailable, DB constants undefined, extension constructor throws). |
| 400 |
* Logs (warn) on exception so the caller does not need to. |
| 401 |
* |
| 402 |
* @param string $query |
| 403 |
* @return NULL|string|WP_Error |
| 404 |
*/ |
| 405 |
public function get_stripped_query_result($query) { |
| 406 |
try { |
| 407 |
if (!class_exists('wpdb')) { |
| 408 |
return null; |
| 409 |
} |
| 410 |
if (!method_exists('wpdb', 'strip_invalid_text_from_query')) { |
| 411 |
return null; |
| 412 |
} |
| 413 |
|
| 414 |
$filename = ABJ404_PATH . 'includes/php/wordpress/WPDBExtension.php'; |
| 415 |
if (!file_exists($filename)) { |
| 416 |
return null; |
| 417 |
} |
| 418 |
require_once $filename; |
| 419 |
|
| 420 |
$my_custom_db = null; |
| 421 |
if (class_exists('ABJ_404_Solution_WPDBExtension_PHP7')) { |
| 422 |
$my_custom_db = new ABJ_404_Solution_WPDBExtension_PHP7(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST); |
| 423 |
} else if (class_exists('ABJ_404_Solution_WPDBExtension_PHP5')) { |
| 424 |
$my_custom_db = new ABJ_404_Solution_WPDBExtension_PHP5(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST); |
| 425 |
} |
| 426 |
if ($my_custom_db == null) { |
| 427 |
return null; |
| 428 |
} |
| 429 |
|
| 430 |
$result = $my_custom_db->public_strip_invalid_text_from_query($query); |
| 431 |
|
| 432 |
if (is_wp_error($result)) { |
| 433 |
return 'WP_Error: ' . $result->get_error_message(); |
| 434 |
} |
| 435 |
|
| 436 |
return $result; |
| 437 |
|
| 438 |
} catch (Throwable $e) { |
| 439 |
$this->logger->warn( |
| 440 |
'get_stripped_query_result failed; returning null: ' . $e->getMessage() |
| 441 |
); |
| 442 |
return null; |
| 443 |
} |
| 444 |
} |
| 445 |
|
| 446 |
} |
| 447 |
|