PluginProbe
404 Solution / 4.3.3
404 Solution v4.3.3
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / database / DatabaseRepairPolicy.php

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

477 lines 23.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 /**
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