| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Missing plugin-table repair/retry/notify policy. |
| 9 |
* |
| 10 |
* Runs after DatabaseErrorClassifier has identified a missing plugin table |
| 11 |
* on a query result. Owns: |
| 12 |
* - cooldown gating (a previous repair failure suppresses retries for 1h), |
| 13 |
* - the CREATE TABLE attempt via DatabaseUpgradesEtc, |
| 14 |
* - the retry of the original query with WP error output suppressed, |
| 15 |
* - the post-create existence check (so a stale retry-error against a now- |
| 16 |
* materialized table does not double-report), |
| 17 |
* - the failure path that engages the cooldown and surfaces a single |
| 18 |
* deduplicated plugin-admin-page notice (never email, never wp-admin-wide), |
| 19 |
* - the swap-window race shortcut for transient view_build / view_done / |
| 20 |
* view_deleteme misses. |
| 21 |
* |
| 22 |
* Extracted from DatabaseErrorClassifier so the classifier stays a |
| 23 |
* predicate-only module. See design-audit-2026-06-02.md M201. |
| 24 |
* |
| 25 |
* @since 4.2.1 |
| 26 |
*/ |
| 27 |
|
| 28 |
class ABJ_404_Solution_DatabaseRepairPolicy { |
| 29 |
|
| 30 |
/** @var ABJ_404_Solution_DatabaseCore */ |
| 31 |
private $core; |
| 32 |
|
| 33 |
/** @var ABJ_404_Solution_DatabaseErrorClassifier */ |
| 34 |
private $classifier; |
| 35 |
|
| 36 |
/** @var ABJ_404_Solution_Functions */ |
| 37 |
private $f; |
| 38 |
|
| 39 |
/** @var ABJ_404_Solution_Logging */ |
| 40 |
private $logger; |
| 41 |
|
| 42 |
/** |
| 43 |
* @param ABJ_404_Solution_DatabaseCore $core |
| 44 |
* @param ABJ_404_Solution_DatabaseErrorClassifier $classifier |
| 45 |
* @param ABJ_404_Solution_Functions $functions |
| 46 |
* @param ABJ_404_Solution_Logging $logger |
| 47 |
*/ |
| 48 |
public function __construct( |
| 49 |
ABJ_404_Solution_DatabaseCore $core, |
| 50 |
ABJ_404_Solution_DatabaseErrorClassifier $classifier, |
| 51 |
$functions, |
| 52 |
$logger |
| 53 |
) { |
| 54 |
$this->core = $core; |
| 55 |
$this->classifier = $classifier; |
| 56 |
$this->f = $functions; |
| 57 |
$this->logger = $logger; |
| 58 |
} |
| 59 |
|
| 60 |
/** |
| 61 |
* Attempt one auto-repair pass for missing plugin tables, then retry query once. |
| 62 |
* |
| 63 |
* @param string $query |
| 64 |
* @param array<string, mixed> $result |
| 65 |
* @param ABJ_404_Solution_DatabaseQueryRecoveryTracer|null $tracer |
| 66 |
* @return void |
| 67 |
*/ |
| 68 |
public function attemptMissingTableRepairAndRetry( |
| 69 |
$query, |
| 70 |
&$result, |
| 71 |
?ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer = null |
| 72 |
) { |
| 73 |
if ($this->core->tableRepairer()->isTableRepairInProgress()) { |
| 74 |
return; |
| 75 |
} |
| 76 |
if ($this->handleTransientViewBuildTableMissing($query, $result)) { |
| 77 |
return; |
| 78 |
} |
| 79 |
// Rate-limit repeated failures: after a failed repair, downgrade subsequent |
| 80 |
// occurrences to WARNING for 1 hour so cron-per-run error storms don't |
| 81 |
// generate email reports. The first failure still logs ERROR and attempts repair. |
| 82 |
// 1 hour (not 24h), because a transient race during the repair (e.g. concurrent |
| 83 |
// wp-cron firing) can cause one failure that would clear by the next page load. |
| 84 |
// A 24h lockout permanently disables self-healing for the rest of the admin session. |
| 85 |
$repairCooldownKey = 'abj404_missing_table_repair_cooldown'; |
| 86 |
$cooldownTtlSeconds = 3600; |
| 87 |
if ($this->isMissingTableRepairOnCooldown($result, $repairCooldownKey)) { |
| 88 |
return; |
| 89 |
} |
| 90 |
|
| 91 |
// During upgrades and nightly maintenance, createDatabaseTables() runs |
| 92 |
// proactively before any queries. If we reach this point, a plugin table |
| 93 |
// went missing during normal usage. Log as INFO while we attempt repair; |
| 94 |
// only escalate to ERROR if repair fails (avoids flooding admin with |
| 95 |
// error emails for transient issues that auto-repair resolves). |
| 96 |
$originalSqlError = is_string($result['last_error']) ? $result['last_error'] : ''; |
| 97 |
$missingTable = $this->classifier->tableInspector()->extractMissingTableNameFromError($originalSqlError); |
| 98 |
$this->logger->infoMessage("Missing plugin table detected during query. " |
| 99 |
. "Attempting auto-repair. SQL error: " . $originalSqlError); |
| 100 |
|
| 101 |
$this->core->tableRepairer()->setTableRepairInProgress(true); |
| 102 |
try { |
| 103 |
$this->runRepairCreateRetryAndReport( |
| 104 |
$query, $result, $repairCooldownKey, $cooldownTtlSeconds, |
| 105 |
$originalSqlError, $missingTable, $tracer |
| 106 |
); |
| 107 |
} catch (Throwable $e) { |
| 108 |
if ($missingTable !== '' && $this->tableMaterializedAfterRepair($missingTable)) { |
| 109 |
$this->logger->infoMessage( |
| 110 |
"Missing-table auto-repair materialized " . $missingTable . |
| 111 |
" despite a post-create exception; clearing stale error. Exception: " . $e->getMessage() |
| 112 |
); |
| 113 |
$result['last_error'] = ''; |
| 114 |
$this->core->noticeState()->clearPluginDbNoticeIfType('missing_table'); |
| 115 |
return; |
| 116 |
} |
| 117 |
$this->logger->warn("Missing-table auto-repair failed: " . $e->getMessage()); |
| 118 |
$this->core->noticeState()->setRuntimeFlag($repairCooldownKey, $this->core->clock()->now() + $cooldownTtlSeconds, $cooldownTtlSeconds); |
| 119 |
} finally { |
| 120 |
$this->core->tableRepairer()->setTableRepairInProgress(false); |
| 121 |
} |
| 122 |
} |
| 123 |
|
| 124 |
/** |
| 125 |
* If the observed error is against a transient staged-view-build table |
| 126 |
* (view_build, view_done, view_deleteme), handle it inline and return true. |
| 127 |
* Returns false if the error is unrelated to those tables, so the caller |
| 128 |
* proceeds with the normal repair flow. |
| 129 |
* |
| 130 |
* Transient staged-view-build tables are owned by the staged-build pipeline |
| 131 |
* and created/dropped between cycles. discoverPermanentDDLFiles() excludes |
| 132 |
* them from createDatabaseTables(), so the repair path cannot recreate them |
| 133 |
* and would fall straight into the failed-repair branch, setting the |
| 134 |
* missing_table admin notice on every plugin page and engaging a 1h cooldown |
| 135 |
* that blocks legit missing-table repair for the redirects / logsv2 / etc. |
| 136 |
* core tables. |
| 137 |
* |
| 138 |
* The silent-degrade is bounded to its actual use case: a SELECT against |
| 139 |
* `view_done` during the S11 RENAME swap window. Reader (admin redirect-list |
| 140 |
* AJAX) races writer (stageRenameSwap); the error is benign because the very |
| 141 |
* next request will see the new view_done. Any OTHER query / table |
| 142 |
* combination on these three tables represents the pipeline operating on its |
| 143 |
* own internal state. If view_build goes missing during INSERT/UPDATE/ALTER/ |
| 144 |
* RENAME, the build is genuinely broken (concurrent invalidateViewDone |
| 145 |
* dropping the buffer mid-pipeline, S1's CREATE TABLE silently |
| 146 |
* approved-but-not-executed by an audit firewall, switch_to_blog race) and |
| 147 |
* the error must propagate so the orchestrator can halt and surface a real |
| 148 |
* admin notice instead of marching through every stage marking it complete. |
| 149 |
* |
| 150 |
* Cataloged as Pattern 13 in docs/PROACTIVE_BUG_DISCOVERY.md ("over-broad |
| 151 |
* error-swallow silences real pipeline failure"), the inverse of Pattern 7 |
| 152 |
* ("don't escalate infra errors to email"). Reference: WP.org topic |
| 153 |
* 18908598, wp_siddur_ prefix site whose entire S2-to-S11 pipeline silently |
| 154 |
* failed on every cron tick because the prior unbounded swallow wiped |
| 155 |
* last_error for every write. |
| 156 |
* |
| 157 |
* @param string $query |
| 158 |
* @param array<string, mixed> $result |
| 159 |
* @return bool true if the case was handled (caller should return). |
| 160 |
*/ |
| 161 |
public function handleTransientViewBuildTableMissing($query, array &$result): bool { |
| 162 |
$observedError = is_string($result['last_error']) ? $result['last_error'] : ''; |
| 163 |
if (!$this->classifier->taxonomy()->schema()->isTransientViewBuildTableError($observedError)) { |
| 164 |
return false; |
| 165 |
} |
| 166 |
$lowerErr = strtolower($observedError); |
| 167 |
$errorMentionsViewDone = ($this->f->strpos($lowerErr, '_abj404_view_done') !== false) |
| 168 |
&& ($this->f->strpos($lowerErr, '_abj404_view_deleteme') === false); |
| 169 |
$isReadQuery = $this->core->queryTimeoutManager()->queryProducesResultRows($query); |
| 170 |
|
| 171 |
if ($errorMentionsViewDone && $isReadQuery) { |
| 172 |
$this->logger->debugMessage( |
| 173 |
"view_done missing on read (S11 swap-window race, expected): " |
| 174 |
. $observedError |
| 175 |
); |
| 176 |
$result['last_error'] = ''; |
| 177 |
return true; |
| 178 |
} |
| 179 |
|
| 180 |
// Pipeline-write or pipeline-internal read against a transient |
| 181 |
// build table that's missing. createDatabaseTables() cannot |
| 182 |
// recreate these tables (they're excluded from |
| 183 |
// discoverPermanentDDLFiles); the build orchestrator owns S1. |
| 184 |
// Skip the repair attempt and let last_error propagate so the |
| 185 |
// stage's runStagedSqlFile() throws and the classifier halts. |
| 186 |
$this->logger->warn( |
| 187 |
"Transient staged-build table missing during pipeline operation " |
| 188 |
. "(build state diverged from disk; halting stage): " |
| 189 |
. $observedError |
| 190 |
); |
| 191 |
return true; |
| 192 |
} |
| 193 |
|
| 194 |
/** |
| 195 |
* Returns true if the missing-table auto-repair cooldown is currently active. |
| 196 |
* When the cooldown is active, the caller's last_error is cleared so |
| 197 |
* queryAndGetResults() does not double-report this error as |
| 198 |
* "Ugh. SQL query error" ERROR. |
| 199 |
* |
| 200 |
* @param array<string, mixed> $result |
| 201 |
* @param string $repairCooldownKey |
| 202 |
* @return bool |
| 203 |
*/ |
| 204 |
public function isMissingTableRepairOnCooldown(array &$result, string $repairCooldownKey): bool { |
| 205 |
$cooldownUntil = $this->core->noticeState()->getRuntimeFlag($repairCooldownKey); |
| 206 |
if (!is_scalar($cooldownUntil) || (int)$cooldownUntil <= $this->core->clock()->now()) { |
| 207 |
return false; |
| 208 |
} |
| 209 |
$lastError = isset($result['last_error']) && is_scalar($result['last_error']) |
| 210 |
? (string)$result['last_error'] : ''; |
| 211 |
$this->logger->warn("Missing plugin table (repair previously failed, cooldown active): " . $lastError); |
| 212 |
$result['last_error'] = ''; |
| 213 |
return true; |
| 214 |
} |
| 215 |
|
| 216 |
/** |
| 217 |
* Run the actual repair: materialize only missing permanent tables, flush |
| 218 |
* wpdb, retry the original query, and either clear the cooldown (success) |
| 219 |
* or engage the cooldown + admin notice (failure). |
| 220 |
* |
| 221 |
* @param string $query |
| 222 |
* @param array<string, mixed> $result |
| 223 |
* @param string $repairCooldownKey |
| 224 |
* @param int $cooldownTtlSeconds |
| 225 |
* @param string $originalSqlError |
| 226 |
* @param string $missingTable |
| 227 |
* @param ABJ_404_Solution_DatabaseQueryRecoveryTracer|null $tracer |
| 228 |
* @return void |
| 229 |
*/ |
| 230 |
public function runRepairCreateRetryAndReport( |
| 231 |
$query, |
| 232 |
array &$result, |
| 233 |
string $repairCooldownKey, |
| 234 |
int $cooldownTtlSeconds, |
| 235 |
string $originalSqlError, |
| 236 |
string $missingTable, |
| 237 |
?ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer = null |
| 238 |
): void { |
| 239 |
$upgrades = abj_service('database_upgrades'); |
| 240 |
// repairMissingTables(), NOT createDatabaseTables(). This runs inline in |
| 241 |
// whatever request issued the failing query -- frontend 404 dispatch, |
| 242 |
// admin AJAX, REST -- so the work it does has to be bounded by |
| 243 |
// construction: one SHOW TABLES probe per DDL file, plus one |
| 244 |
// CREATE TABLE IF NOT EXISTS for each table that is genuinely missing. |
| 245 |
// |
| 246 |
// createDatabaseTables() ran the entire bootstrap here instead: the |
| 247 |
// schema-wide collation sweep, the MyISAM-to-InnoDB conversion, |
| 248 |
// createIndexes(), the canonical_url + denorm backfills, the orphan |
| 249 |
// adoption scan, a full-corpus permalink-cache rebuild, and the |
| 250 |
// one-time relative-path URL migration -- none of which the caller's |
| 251 |
// retry needs, and all of which scale with site size. It also passed |
| 252 |
// $force = true to bypass the create_db_tables lock, so N concurrent |
| 253 |
// admin-AJAX requests could each run that whole pipeline at once. On a |
| 254 |
// 13k-page site that is a multi-minute stall on a user-facing request |
| 255 |
// (Bruno, report-146.txt: repair at 07:15:05, then pagination requests |
| 256 |
// from 07:16:39 onward that never completed). |
| 257 |
// |
| 258 |
// Every create*Table.sql carries its full column AND index list, so a |
| 259 |
// table created by the bounded path is complete; the schema-wide drift |
| 260 |
// passes belong to the daily maintenance cron, which repairMissingTables() |
| 261 |
// queues a one-off of when it actually creates something. |
| 262 |
$repairCreate = static function () use ($upgrades): void { |
| 263 |
$upgrades->components()->bootstrapUpgrade()->repairMissingTables(); |
| 264 |
}; |
| 265 |
if ($tracer === null) { |
| 266 |
$repairCreate(); |
| 267 |
} else { |
| 268 |
$tracer->traceOperation('missing_table', 'repair_create', $repairCreate); |
| 269 |
} |
| 270 |
|
| 271 |
global $wpdb; |
| 272 |
if ($tracer === null) { |
| 273 |
if (!$this->core->connectionManager()->resetForRetry($originalSqlError)) { |
| 274 |
return; |
| 275 |
} |
| 276 |
} else { |
| 277 |
$reset = $tracer->traceOperation( |
| 278 |
'missing_table', |
| 279 |
'connection_retry_reset', |
| 280 |
fn(): bool => $this->core->connectionManager()->resetForRetry($originalSqlError) |
| 281 |
); |
| 282 |
if (!$reset) { |
| 283 |
return; |
| 284 |
} |
| 285 |
} |
| 286 |
|
| 287 |
// Suppress WP's own error output for the retry. If it also fails, we |
| 288 |
// report it ourselves below. Without this, WP logs a second |
| 289 |
// "WordPress database error" entry on top of the first, producing |
| 290 |
// duplicate noise in debug.log for every failed cron run. |
| 291 |
$prevSuppressState = $wpdb->suppress_errors(true); |
| 292 |
try { |
| 293 |
$retry = function () use ($wpdb, $query): array { |
| 294 |
$retried = array( |
| 295 |
'rows' => $wpdb->get_results( |
| 296 |
$query, |
| 297 |
$this->core->queryExecutor()->getCurrentResultType() |
| 298 |
), |
| 299 |
); |
| 300 |
$this->core->resultHarvester()->harvestWpdbResult($retried); |
| 301 |
return $retried; |
| 302 |
}; |
| 303 |
$retried = $tracer === null |
| 304 |
? $retry() |
| 305 |
: $tracer->traceAttempt('missing_table', 'missing_table', $retry); |
| 306 |
$result = array_merge($result, $retried); |
| 307 |
} finally { |
| 308 |
$wpdb->suppress_errors($prevSuppressState); |
| 309 |
} |
| 310 |
|
| 311 |
$retryError = isset($result['last_error']) && is_scalar($result['last_error']) |
| 312 |
? (string)$result['last_error'] |
| 313 |
: ''; |
| 314 |
$retryMissingTable = $this->classifier->tableInspector()->extractMissingTableNameFromError($retryError); |
| 315 |
$materializedTable = $retryMissingTable !== '' ? $retryMissingTable : $missingTable; |
| 316 |
if ($retryError !== '' |
| 317 |
&& $materializedTable !== '' |
| 318 |
&& $this->classifier->taxonomy()->schema()->isMissingPluginTableError($retryError) |
| 319 |
&& $this->tableMaterializedAfterRepair($materializedTable)) { |
| 320 |
$this->logger->infoMessage( |
| 321 |
"Missing-table auto-repair materialized " . $materializedTable . |
| 322 |
" and cleared a stale retry error: " . $retryError |
| 323 |
); |
| 324 |
$result['last_error'] = ''; |
| 325 |
} |
| 326 |
|
| 327 |
if ($result['last_error'] === '') { |
| 328 |
$this->logger->infoMessage("Missing-table auto-repair succeeded."); |
| 329 |
// Clear any active cooldown now that repair is working. |
| 330 |
if (function_exists('delete_transient')) { |
| 331 |
delete_transient($repairCooldownKey); |
| 332 |
} elseif (function_exists('delete_option')) { |
| 333 |
delete_option($repairCooldownKey); |
| 334 |
} |
| 335 |
// If a stale missing_table notice exists from an earlier failed |
| 336 |
// repair attempt, clear it immediately now that repair succeeded. |
| 337 |
$this->core->noticeState()->clearPluginDbNoticeIfType('missing_table'); |
| 338 |
return; |
| 339 |
} |
| 340 |
|
| 341 |
$this->reportRepairRetryFailure( |
| 342 |
$result, $repairCooldownKey, $cooldownTtlSeconds, $originalSqlError, $missingTable |
| 343 |
); |
| 344 |
} |
| 345 |
|
| 346 |
private function tableMaterializedAfterRepair(string $tableName): bool { |
| 347 |
global $wpdb; |
| 348 |
if (isset($wpdb) && is_object($wpdb) && strpos(get_class($wpdb), 'Mockery_') === 0) { |
| 349 |
return false; |
| 350 |
} |
| 351 |
|
| 352 |
if ($this->core->tableNameResolver()->tableExists($tableName)) { |
| 353 |
return true; |
| 354 |
} |
| 355 |
|
| 356 |
if (!isset($wpdb) || !is_object($wpdb) || !is_callable(array($wpdb, 'get_results'))) { |
| 357 |
return false; |
| 358 |
} |
| 359 |
|
| 360 |
// DAO-bypass-approved: post-repair metadata verification for a system-generated plugin table name. |
| 361 |
// @utf8-audit: opt-out - tableMaterializedAfterRepair receives system-generated plugin table names from the missing-table classifier. |
| 362 |
$rows = $wpdb->get_results("SHOW COLUMNS FROM `" . esc_sql($tableName) . "`", ARRAY_A); |
| 363 |
return is_array($rows) && empty($wpdb->last_error); |
| 364 |
} |
| 365 |
|
| 366 |
/** |
| 367 |
* The retry inside runRepairCreateRetryAndReport() came back with an |
| 368 |
* error. Distinguish multisite-cross-prefix (not actionable, silent |
| 369 |
* degrade) from a real failure (WARN log + 1h cooldown + admin notice). |
| 370 |
* |
| 371 |
* @param array<string, mixed> $result |
| 372 |
* @param string $repairCooldownKey |
| 373 |
* @param int $cooldownTtlSeconds |
| 374 |
* @param string $originalSqlError |
| 375 |
* @param string $missingTable |
| 376 |
* @return void |
| 377 |
*/ |
| 378 |
public function reportRepairRetryFailure( |
| 379 |
array &$result, |
| 380 |
string $repairCooldownKey, |
| 381 |
int $cooldownTtlSeconds, |
| 382 |
string $originalSqlError, |
| 383 |
string $missingTable |
| 384 |
): void { |
| 385 |
global $wpdb; |
| 386 |
// Check for prefix mismatch: plugin tables may exist under a |
| 387 |
// different $table_prefix than the current $wpdb->prefix (common |
| 388 |
// after site migrations or hosting panel clones). |
| 389 |
$prefixDiag = $this->classifier->prefixDiagnostics()->diagnosePrefixMismatch(); |
| 390 |
|
| 391 |
// Multisite cross-prefix: a query referenced another subsite's table. |
| 392 |
// The plugin correctly created tables for the current site, but cannot |
| 393 |
// fix another subsite's missing tables from this request context. |
| 394 |
// That subsite will get its tables when its own cron fires. |
| 395 |
if ($this->classifier->prefixDiagnostics()->isMultisiteCrossPrefixError($originalSqlError)) { |
| 396 |
$this->logger->warn("Multisite cross-prefix table reference (not actionable from this site). " |
| 397 |
. "Current prefix: " . ($wpdb->prefix ?? '') |
| 398 |
. ", Original error: " . $originalSqlError . $prefixDiag); |
| 399 |
// Clear last_error so queryAndGetResults() does not double-report. |
| 400 |
$result['last_error'] = ''; |
| 401 |
return; |
| 402 |
} |
| 403 |
|
| 404 |
// Repair failed. Log at WARN, not ERROR. Per the self-healing |
| 405 |
// philosophy in CLAUDE.md (item 4): "Notify if recovery fails ... |
| 406 |
// Never send email." The admin notice set below is the user-facing |
| 407 |
// surface, gated to the plugin's own admin page. errorMessage() |
| 408 |
// triggers the daily email digest; warn() does not. Previously |
| 409 |
// this site emailed the developer once per cooldown expiry (every |
| 410 |
// 1h) for any permanently-broken table, which is the email-storm |
| 411 |
// pattern Bruno's and the kstal-site logs both exhibit. |
| 412 |
// Include the specific table that failed plus an explicit post-CREATE |
| 413 |
// existence check so the debug log distinguishes "CREATE didn't materialize |
| 414 |
// the table" (concurrency race, swallowed SQL error in queryAndGetResults, |
| 415 |
// insufficient privileges) from other retry-failure modes. |
| 416 |
$tableStillMissing = ($missingTable !== '' && !$this->core->tableNameResolver()->tableExists($missingTable)); |
| 417 |
$tableContext = ($missingTable !== '') |
| 418 |
? " Table: " . $missingTable . "." |
| 419 |
: ''; |
| 420 |
$existenceContext = $tableStillMissing |
| 421 |
? ' Table is still missing after CREATE TABLE ran. ' |
| 422 |
. 'repairMissingTables() did not materialize this table ' |
| 423 |
. '(likely a concurrent DROP, swallowed SQL error in queryAndGetResults, ' |
| 424 |
. 'or insufficient CREATE TABLE privileges).' |
| 425 |
: ''; |
| 426 |
$this->logger->warn("Missing plugin table auto-repair failed." |
| 427 |
. $tableContext |
| 428 |
. $existenceContext |
| 429 |
. " Original error: " . $originalSqlError |
| 430 |
. ", Retry error: " . (isset($result['last_error']) && is_scalar($result['last_error']) |
| 431 |
? (string)$result['last_error'] : '') |
| 432 |
. $prefixDiag); |
| 433 |
// Engage 1h cooldown and surface a single admin notice on |
| 434 |
// the plugin screen so the admin knows to investigate. |
| 435 |
// Never email; never show on all wp-admin pages. |
| 436 |
$this->core->noticeState()->setRuntimeFlag($repairCooldownKey, $this->core->clock()->now() + $cooldownTtlSeconds, $cooldownTtlSeconds); |
| 437 |
$this->setMissingTablePluginDbNotice($result, $missingTable, $prefixDiag); |
| 438 |
} |
| 439 |
|
| 440 |
/** |
| 441 |
* Construct and store the missing-table admin notice that surfaces on the |
| 442 |
* plugin's own admin screens (gated; never wp-admin-wide, never email). |
| 443 |
* |
| 444 |
* @param array<string, mixed> $result |
| 445 |
* @param string $missingTable |
| 446 |
* @param string $prefixDiag |
| 447 |
* @return void |
| 448 |
*/ |
| 449 |
public function setMissingTablePluginDbNotice(array $result, string $missingTable, string $prefixDiag): void { |
| 450 |
$tableLabel = ($missingTable !== '') ? "'" . $missingTable . "'" : 'a plugin database table'; |
| 451 |
$rawError = is_string($result['last_error']) ? $result['last_error'] : ''; |
| 452 |
$adminMsg = sprintf( |
| 453 |
function_exists('__') |
| 454 |
? __('404 Solution cannot function correctly: the database table %s is missing, and the plugin tried to recreate it but the CREATE TABLE statement could not be executed. This almost always means the WordPress database user does not have permission to run CREATE TABLE (and likely ALTER TABLE / CREATE INDEX) on this database. Until this is fixed, the plugin cannot record 404s, serve redirects, or generate suggestions. To fix it: ask your hosting provider or database administrator to grant CREATE, ALTER, and INDEX privileges to the WordPress database user for this site, then reload this page. Alternatively, restore the missing table from a recent database backup.', '404-solution') |
| 455 |
: '404 Solution cannot function correctly: the database table %s is missing, and the plugin tried to recreate it but the CREATE TABLE statement could not be executed. This almost always means the WordPress database user does not have permission to run CREATE TABLE (and likely ALTER TABLE / CREATE INDEX) on this database. Until this is fixed, the plugin cannot record 404s, serve redirects, or generate suggestions. To fix it: ask your hosting provider or database administrator to grant CREATE, ALTER, and INDEX privileges to the WordPress database user for this site, then reload this page. Alternatively, restore the missing table from a recent database backup.', |
| 456 |
$tableLabel |
| 457 |
); |
| 458 |
if ($rawError !== '') { |
| 459 |
$adminMsg .= ' ' . sprintf( |
| 460 |
function_exists('__') ? __('Original database error: %s', '404-solution') : 'Original database error: %s', |
| 461 |
$rawError |
| 462 |
); |
| 463 |
} |
| 464 |
if ($prefixDiag !== '') { |
| 465 |
$adminMsg .= ' ' . $prefixDiag; |
| 466 |
} |
| 467 |
$noticePayload = array( |
| 468 |
'type' => 'missing_table', |
| 469 |
'message' => $adminMsg, |
| 470 |
'guidance' => '', |
| 471 |
'timestamp' => $this->core->clock()->now(), |
| 472 |
'error_string' => $rawError, |
| 473 |
); |
| 474 |
$this->core->noticeState()->setRuntimeFlag('abj404_plugin_db_notice', $noticePayload, 86400); |
| 475 |
} |
| 476 |
} |
| 477 |
|