): array); * - the legacy result-harvester constructor argument, retained so requests * spanning a plugin update can keep their already-loaded composition root; * - a result-type getter bound over DatabaseCore::getCurrentResultType * (signature: function(): string); * - a notice setter bound over DatabaseNoticeStateHolder::setPluginDbNotice * (signature: function(string, string, string, string): void); * - a connection-reset callable bound over * DatabaseConnectionManager::resetForRetry (signature: function(string): bool); * - ABJ_404_Solution_Functions for regex/string helpers and the plugin logger. * * The recursion guards are static properties on this class (they must survive * across helper invocations within a single request) and are functionally * identical to the former DatabaseCore::$tableRepairInProgress and * DatabaseCore::$invalidDataRetryInProgress. */ class ABJ_404_Solution_DatabaseTableRepairer { /** @var bool Prevent recursive auto-repair attempts on SQL errors. */ private static $tableRepairInProgress = false; /** @var bool Prevent recursive invalid-data retry attempts. */ private static $invalidDataRetryInProgress = false; /** @var callable(string, array): array */ private $queryRunner; /** @var callable(): string */ private $resultTypeGetter; /** @var callable(string, string, string, string): void */ private $noticeSetter; /** @var callable(string): bool */ private $connectionResetter; /** @var ABJ_404_Solution_Functions */ private $f; /** @var ABJ_404_Solution_Logging */ private $logger; /** * @param callable(string, array): array $queryRunner * Runs a SQL query through the centralized error-handling pipeline and * returns its result array. Bound by DatabaseCore over queryAndGetResults(). * @param callable(array&): void $resultHarvester * Reserved for compatibility with already-loaded composition roots. * Retries now use queryRunner so execution and metadata capture stay paired. * @param callable(): string $resultTypeGetter * Returns the current wpdb result type (ARRAY_A or OBJECT) for retries. * @param callable(string, string, string, string): void $noticeSetter * Persists a plugin-db admin notice (type, message, guidance, errorString). * @param callable(string): bool $connectionResetter * Resets the active wpdb connection before a recovery retry. * @param ABJ_404_Solution_Functions $functions * @param ABJ_404_Solution_Logging $logger */ public function __construct( callable $queryRunner, callable $resultHarvester, callable $resultTypeGetter, callable $noticeSetter, callable $connectionResetter, $functions, $logger ) { // Intentionally discard the retired callback while preserving its // positional slot for composition roots loaded before an update. unset($resultHarvester); $this->queryRunner = $queryRunner; $this->resultTypeGetter = $resultTypeGetter; $this->noticeSetter = $noticeSetter; $this->connectionResetter = $connectionResetter; $this->f = $functions; $this->logger = $logger; } /** * Reset the static recursion guards. Intended for test setUp/tearDown only. * * @return void */ public static function resetRecursionGuardsForTests(): void { self::$tableRepairInProgress = false; self::$invalidDataRetryInProgress = false; } /** @return bool */ public function isTableRepairInProgress(): bool { return self::$tableRepairInProgress; } /** @param bool $value @return void */ public function setTableRepairInProgress(bool $value): void { self::$tableRepairInProgress = $value; } /** * Validate and sanitize a table name extracted from error messages or SQL. * * Rejects anything that isn't [A-Za-z0-9_]+ and anything that doesn't * contain the plugin's "abj404" prefix substring. Logs (warn) on reject so * the rejection is visible at audit time without surfacing to the admin. * * @param string $name Raw table name (may include backticks). * @return string|null Sanitized name, or null if invalid. */ public function sanitizeTableName(string $name): ?string { $name = trim($name, '`'); if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) { $this->logger->warn("sanitizeTableName: rejected invalid table name: " . substr($name, 0, 100)); return null; } if (strpos($name, 'abj404') === false) { $this->logger->warn("sanitizeTableName: rejected non-plugin table name: " . $name); return null; } return $name; } /** * Run REPAIR TABLE for a table named in a wpdb error string. * * Recognizes the "is marked as crashed", "Incorrect key file for table" and * "Index for table ... is corrupt" messages. If the table name cannot be sanitized * (typically because it is a temporary table outside our prefix, e.g. * "#sql_xxx_0.MYI" on a corrupted-disk host), falls back to a 24-hour * deduplicated admin notice telling the site owner to contact their host. * * @param string $errorMessage The wpdb->last_error string. * @return void */ public function repairTable(string $errorMessage): void { $rawTableName = $this->tableNameFromRepairableError($errorMessage); if ($rawTableName !== '') { $tableToRepair = $this->sanitizeTableName($rawTableName); if ($tableToRepair !== null) { $query = "REPAIR TABLE `{$tableToRepair}`"; $result = ($this->queryRunner)($query, array('log_errors' => false)); $this->logger->infoMessage("Attempted to repair table " . $tableToRepair . ". Result: " . json_encode($result)); } else { $this->logger->warn("The table " . $rawTableName . " needs to be " . "repaired with something like: repair table " . $rawTableName); $cooldownKey = 'abj404_corrupted_temp_table_notice_until'; $alreadyNotified = function_exists('get_transient') ? get_transient($cooldownKey) : false; if (!$alreadyNotified) { ABJ_404_Solution_DeferredTranslation::whenReady(function () use ($errorMessage): void { ($this->noticeSetter)( 'corrupted_temp_table', __('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 temporary MySQL table was corrupted, usually caused by disk or hardware issues. The plugin cannot repair it. Please contact your hosting provider.', '404-solution'), $errorMessage ); }); if (function_exists('set_transient')) { // @cache-write-audit: opt-out - admin-notice dedup cooldown // (one notice per 24h per failure type), not a query result. set_transient($cooldownKey, 1, 86400); } } } } } /** * Resolve the ALTER TABLE auto_increment resequencing duplicate-id case. * * Parses the duplicate id from the error message and the table name from * the original ALTER TABLE SQL, validates both, and deletes the conflicting * row so the ALTER can be retried by the caller. * * @param string $errorMessage The wpdb->last_error string. * @param string $sqlThatWasRun The ALTER TABLE statement wpdb just ran. * @return void */ public function repairDuplicateIDs(string $errorMessage, string $sqlThatWasRun): void { $reForID = 'resulting in duplicate entry \'(.+)\' for key'; $reForTableName = "ALTER TABLE (.+) ADD "; $matchesForID = null; $matchesForTableName = null; $this->f->regexMatch($reForID, $errorMessage, $matchesForID); $this->f->regexMatch($reForTableName, $sqlThatWasRun, $matchesForTableName); if (is_array($matchesForID) && isset($matchesForID[1]) && $this->f->strlen($matchesForID[1]) > 0 && is_array($matchesForTableName) && isset($matchesForTableName[1]) && $this->f->strlen($matchesForTableName[1]) > 0) { $idWithDuplicate = $matchesForID[1]; $tableName = $this->sanitizeTableName($matchesForTableName[1]); if ($tableName === null) { $this->logger->warn("repairDuplicateIDs: rejected invalid table name from SQL: " . substr($matchesForTableName[1], 0, 100)); return; } if (!is_numeric($idWithDuplicate)) { $this->logger->errorMessage("Invalid ID extracted from error message: " . $idWithDuplicate); return; } if ($idWithDuplicate == 1) { $idWithDuplicate = 0; } $result = ($this->queryRunner)("DELETE FROM `{$tableName}` where id = %d", array('log_errors' => false, 'query_params' => array(absint($idWithDuplicate)))); $this->logger->infoMessage("Attempted to fix a duplicate entry issue. Table: " . $tableName . ", Result: " . json_encode($result)); } } /** * The table name a MyISAM corruption error names, or '' when the text is none of * the recognized shapes. * * Each shape is a pattern plus the index of its table-name capture group: * "Table '...' is marked as crashed and should be repaired", * "Incorrect key file for table '...MYI'; try to repair it" (MySQL), and * "Index for table '...MYI' is corrupt; try to repair it" (MariaDB). * * @param string $errorMessage The wpdb->last_error string. * @return string */ private function tableNameFromRepairableError(string $errorMessage): string { $shapes = array( array("Table '(.*\/)?(.+)' is marked as crashed and ", 2), array("Incorrect key file for table '(?:.*\/)?([^'.]+?)(?:\\.MYI)?'", 1), array("Index for table '(?:.*\/)?([^'.]+?)(?:\\.MYI)?' is corrupt", 1), ); foreach ($shapes as $shape) { $matches = array(); $this->f->regexMatch($shape[0], $errorMessage, $matches); if (isset($matches[$shape[1]]) && $this->f->strlen($matches[$shape[1]]) > 0) { return $matches[$shape[1]]; } } return ''; } /** * Attempt REPAIR TABLE after MySQL errno 1034 ("Incorrect key file"), then * retry the original query once. * * On retry success, $result is mutated to the retried result; on retry * failure, $result carries the retried last_error so the surrounding * pipeline can continue its error-handling. * * @param string $query * @param array $result Passed by reference. * @param ABJ_404_Solution_DatabaseQueryRecoveryTracer|null $tracer * @return void */ public function repairCorruptedTableAndRetry( string $query, array &$result, ?ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer = null ): void { $errorMessage = is_string($result['last_error']) ? $result['last_error'] : ''; $this->repairTable($errorMessage); if (stripos($errorMessage, 'abj404') !== false) { if ($tracer === null) { if (!(($this->connectionResetter)($errorMessage))) { return; } } else { $reset = $tracer->traceOperation( 'corrupted_table', 'connection_retry_reset', fn(): bool => ($this->connectionResetter)($errorMessage) ); if (!$reset) { return; } } $resultType = ($this->resultTypeGetter)(); // Use the version-stable query boundary. These options suppress // final repair hooks and missing-table repair on the nested attempt; // invalid-data retries also have their own request-local guard. $retry = fn(): array => ($this->queryRunner)($query, array( 'log_errors' => false, 'skip_repair' => true, 'result_type' => $resultType, )); $retried = $tracer === null ? $retry() : $tracer->traceAttempt( 'corrupted_table', 'corrupted_table', $retry ); $result = array_merge($result, $retried); if ($result['last_error'] === '') { $this->logger->infoMessage("Retry after 'Incorrect key file' repair succeeded for plugin table."); } } } /** * Attempt a single invalid-data retry by asking WPDBExtension to strip * invalid bytes from the query, then re-running the stripped query. * * Recursion-guarded: if the stripped query also produces an invalid-data * error, the second call short-circuits. The `abj404_invalid_data_retry_query` * filter lets site owners override the stripped query (e.g. to enforce a * stricter sanitization policy). * * @param string $query * @param array $result Passed by reference. * @param ABJ_404_Solution_DatabaseQueryRecoveryTracer|null $tracer * @return void */ public function attemptInvalidDataRetry( $query, &$result, ?ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer = null ) { if (self::$invalidDataRetryInProgress) { return; } self::$invalidDataRetryInProgress = true; try { $prepareRetry = function () use ($query) { $retryQuery = $this->get_stripped_query_result($query); return function_exists('apply_filters') ? apply_filters('abj404_invalid_data_retry_query', $retryQuery, $query) : $retryQuery; }; $retryQuery = $tracer === null ? $prepareRetry() : $tracer->traceOperation( 'invalid_data', 'retry_prepare', $prepareRetry ); if (!is_string($retryQuery) || trim($retryQuery) === '' || $retryQuery === $query) { return; } $retryError = isset($result['last_error']) && is_scalar($result['last_error']) ? (string)$result['last_error'] : ''; if ($tracer === null) { if (!(($this->connectionResetter)($retryError))) { return; } } else { $reset = $tracer->traceOperation( 'invalid_data', 'connection_retry_reset', fn(): bool => ($this->connectionResetter)($retryError) ); if (!$reset) { return; } } $resultType = ($this->resultTypeGetter)(); // The guarded shared pipeline captures the retry's driver result // before any diagnostic or recovery query can overwrite it. $retry = fn(): array => ($this->queryRunner)($retryQuery, array( 'log_errors' => false, 'skip_repair' => true, 'result_type' => $resultType, )); $retried = $tracer === null ? $retry() : $tracer->traceAttempt('invalid_data', 'invalid_data', $retry); $result = array_merge($result, $retried); } catch (Throwable $e) { $this->logger->warn("Invalid-data retry failed: " . $e->getMessage()); } finally { self::$invalidDataRetryInProgress = false; } } /** * Ask WPDBExtension to strip invalid bytes from a query string so the * caller can re-issue a safe version. * * Returns null on any failure (missing extension file, wpdb method * unavailable, DB constants undefined, extension constructor throws). * Logs (warn) on exception so the caller does not need to. * * @param string $query * @return NULL|string|WP_Error */ public function get_stripped_query_result($query) { try { if (!class_exists('wpdb')) { return null; } if (!method_exists('wpdb', 'strip_invalid_text_from_query')) { return null; } $filename = ABJ404_PATH . 'includes/php/wordpress/WPDBExtension.php'; if (!file_exists($filename)) { return null; } require_once $filename; $my_custom_db = null; if (class_exists('ABJ_404_Solution_WPDBExtension_PHP7')) { $my_custom_db = new ABJ_404_Solution_WPDBExtension_PHP7(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST); } else if (class_exists('ABJ_404_Solution_WPDBExtension_PHP5')) { $my_custom_db = new ABJ_404_Solution_WPDBExtension_PHP5(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST); } if ($my_custom_db == null) { return null; } $result = $my_custom_db->public_strip_invalid_text_from_query($query); if (is_wp_error($result)) { return 'WP_Error: ' . $result->get_error_message(); } return $result; } catch (Throwable $e) { $this->logger->warn( 'get_stripped_query_result failed; returning null: ' . $e->getMessage() ); return null; } } }