PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.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 / database / DatabaseRepairPolicy.php

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

418 lines 20.7 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 * @return void
66 */
67 public function attemptMissingTableRepairAndRetry($query, &$result) {
68 if ($this->core->tableRepairer()->isTableRepairInProgress()) {
69 return;
70 }
71 if ($this->handleTransientViewBuildTableMissing($query, $result)) {
72 return;
73 }
74 // Rate-limit repeated failures: after a failed repair, downgrade subsequent
75 // occurrences to WARNING for 1 hour so cron-per-run error storms don't
76 // generate email reports. The first failure still logs ERROR and attempts repair.
77 // 1 hour (not 24h), because a transient race during the repair (e.g. concurrent
78 // wp-cron firing) can cause one failure that would clear by the next page load.
79 // A 24h lockout permanently disables self-healing for the rest of the admin session.
80 $repairCooldownKey = 'abj404_missing_table_repair_cooldown';
81 $cooldownTtlSeconds = 3600;
82 if ($this->isMissingTableRepairOnCooldown($result, $repairCooldownKey)) {
83 return;
84 }
85
86 // During upgrades and nightly maintenance, createDatabaseTables() runs
87 // proactively before any queries. If we reach this point, a plugin table
88 // went missing during normal usage. Log as INFO while we attempt repair;
89 // only escalate to ERROR if repair fails (avoids flooding admin with
90 // error emails for transient issues that auto-repair resolves).
91 $originalSqlError = is_string($result['last_error']) ? $result['last_error'] : '';
92 $missingTable = $this->classifier->tableInspector()->extractMissingTableNameFromError($originalSqlError);
93 $this->logger->infoMessage("Missing plugin table detected during query. "
94 . "Attempting auto-repair. SQL error: " . $originalSqlError);
95
96 $this->core->tableRepairer()->setTableRepairInProgress(true);
97 try {
98 $this->runRepairCreateRetryAndReport(
99 $query, $result, $repairCooldownKey, $cooldownTtlSeconds,
100 $originalSqlError, $missingTable
101 );
102 } catch (Throwable $e) {
103 if ($missingTable !== '' && $this->tableMaterializedAfterRepair($missingTable)) {
104 $this->logger->infoMessage(
105 "Missing-table auto-repair materialized " . $missingTable .
106 " despite a post-create exception; clearing stale error. Exception: " . $e->getMessage()
107 );
108 $result['last_error'] = '';
109 $this->core->noticeState()->clearPluginDbNoticeIfType('missing_table');
110 return;
111 }
112 $this->logger->warn("Missing-table auto-repair failed: " . $e->getMessage());
113 $this->core->noticeState()->setRuntimeFlag($repairCooldownKey, $this->core->clock()->now() + $cooldownTtlSeconds, $cooldownTtlSeconds);
114 } finally {
115 $this->core->tableRepairer()->setTableRepairInProgress(false);
116 }
117 }
118
119 /**
120 * If the observed error is against a transient staged-view-build table
121 * (view_build, view_done, view_deleteme), handle it inline and return true.
122 * Returns false if the error is unrelated to those tables, so the caller
123 * proceeds with the normal repair flow.
124 *
125 * Transient staged-view-build tables are owned by the staged-build pipeline
126 * and created/dropped between cycles. discoverPermanentDDLFiles() excludes
127 * them from createDatabaseTables(), so the repair path cannot recreate them
128 * and would fall straight into the failed-repair branch, setting the
129 * missing_table admin notice on every plugin page and engaging a 1h cooldown
130 * that blocks legit missing-table repair for the redirects / logsv2 / etc.
131 * core tables.
132 *
133 * The silent-degrade is bounded to its actual use case: a SELECT against
134 * `view_done` during the S11 RENAME swap window. Reader (admin redirect-list
135 * AJAX) races writer (stageRenameSwap); the error is benign because the very
136 * next request will see the new view_done. Any OTHER query / table
137 * combination on these three tables represents the pipeline operating on its
138 * own internal state. If view_build goes missing during INSERT/UPDATE/ALTER/
139 * RENAME, the build is genuinely broken (concurrent invalidateViewDone
140 * dropping the buffer mid-pipeline, S1's CREATE TABLE silently
141 * approved-but-not-executed by an audit firewall, switch_to_blog race) and
142 * the error must propagate so the orchestrator can halt and surface a real
143 * admin notice instead of marching through every stage marking it complete.
144 *
145 * Cataloged as Pattern 13 in docs/PROACTIVE_BUG_DISCOVERY.md ("over-broad
146 * error-swallow silences real pipeline failure"), the inverse of Pattern 7
147 * ("don't escalate infra errors to email"). Reference: WP.org topic
148 * 18908598, wp_siddur_ prefix site whose entire S2-to-S11 pipeline silently
149 * failed on every cron tick because the prior unbounded swallow wiped
150 * last_error for every write.
151 *
152 * @param string $query
153 * @param array<string, mixed> $result
154 * @return bool true if the case was handled (caller should return).
155 */
156 public function handleTransientViewBuildTableMissing($query, array &$result): bool {
157 $observedError = is_string($result['last_error']) ? $result['last_error'] : '';
158 if (!$this->classifier->taxonomy()->schema()->isTransientViewBuildTableError($observedError)) {
159 return false;
160 }
161 $lowerErr = strtolower($observedError);
162 $errorMentionsViewDone = ($this->f->strpos($lowerErr, '_abj404_view_done') !== false)
163 && ($this->f->strpos($lowerErr, '_abj404_view_deleteme') === false);
164 $isReadQuery = $this->core->queryTimeoutManager()->queryProducesResultRows($query);
165
166 if ($errorMentionsViewDone && $isReadQuery) {
167 $this->logger->debugMessage(
168 "view_done missing on read (S11 swap-window race, expected): "
169 . $observedError
170 );
171 $result['last_error'] = '';
172 return true;
173 }
174
175 // Pipeline-write or pipeline-internal read against a transient
176 // build table that's missing. createDatabaseTables() cannot
177 // recreate these tables (they're excluded from
178 // discoverPermanentDDLFiles); the build orchestrator owns S1.
179 // Skip the repair attempt and let last_error propagate so the
180 // stage's runStagedSqlFile() throws and the classifier halts.
181 $this->logger->warn(
182 "Transient staged-build table missing during pipeline operation "
183 . "(build state diverged from disk; halting stage): "
184 . $observedError
185 );
186 return true;
187 }
188
189 /**
190 * Returns true if the missing-table auto-repair cooldown is currently active.
191 * When the cooldown is active, the caller's last_error is cleared so
192 * queryAndGetResults() does not double-report this error as
193 * "Ugh. SQL query error" ERROR.
194 *
195 * @param array<string, mixed> $result
196 * @param string $repairCooldownKey
197 * @return bool
198 */
199 public function isMissingTableRepairOnCooldown(array &$result, string $repairCooldownKey): bool {
200 $cooldownUntil = $this->core->noticeState()->getRuntimeFlag($repairCooldownKey);
201 if (!is_scalar($cooldownUntil) || (int)$cooldownUntil <= $this->core->clock()->now()) {
202 return false;
203 }
204 $lastError = isset($result['last_error']) && is_scalar($result['last_error'])
205 ? (string)$result['last_error'] : '';
206 $this->logger->warn("Missing plugin table (repair previously failed, cooldown active): " . $lastError);
207 $result['last_error'] = '';
208 return true;
209 }
210
211 /**
212 * Run the actual repair: createDatabaseTables(), flush wpdb, retry the
213 * original query, and either clear the cooldown (success) or engage the
214 * cooldown + admin notice (failure).
215 *
216 * @param string $query
217 * @param array<string, mixed> $result
218 * @param string $repairCooldownKey
219 * @param int $cooldownTtlSeconds
220 * @param string $originalSqlError
221 * @param string $missingTable
222 * @return void
223 */
224 public function runRepairCreateRetryAndReport(
225 $query,
226 array &$result,
227 string $repairCooldownKey,
228 int $cooldownTtlSeconds,
229 string $originalSqlError,
230 string $missingTable
231 ): void {
232 $upgrades = abj_service('database_upgrades');
233 // Pass $force = true so the repair bypasses the concurrency lock. If another
234 // request holds the lock (e.g. a concurrent upgrade), calling createDatabaseTables
235 // without $force would silently return without creating anything, leaving the
236 // missing table unrepaired. Concurrent CREATE TABLE IF NOT EXISTS calls are safe
237 // (idempotent), so bypassing the lock here is correct.
238 $upgrades->components()->bootstrapUpgrade()->createDatabaseTables(false, true);
239
240 global $wpdb;
241 $wpdb->flush();
242
243 // Suppress WP's own error output for the retry. If it also fails, we
244 // report it ourselves below. Without this, WP logs a second
245 // "WordPress database error" entry on top of the first, producing
246 // duplicate noise in debug.log for every failed cron run.
247 $prevSuppressState = $wpdb->suppress_errors(true);
248 $result['rows'] = $wpdb->get_results($query, $this->core->queryExecutor()->getCurrentResultType());
249 $wpdb->suppress_errors($prevSuppressState);
250 $this->core->resultHarvester()->harvestWpdbResult($result);
251
252 $retryError = isset($result['last_error']) && is_scalar($result['last_error'])
253 ? (string)$result['last_error']
254 : '';
255 $retryMissingTable = $this->classifier->tableInspector()->extractMissingTableNameFromError($retryError);
256 $materializedTable = $retryMissingTable !== '' ? $retryMissingTable : $missingTable;
257 if ($retryError !== ''
258 && $materializedTable !== ''
259 && $this->classifier->taxonomy()->schema()->isMissingPluginTableError($retryError)
260 && $this->tableMaterializedAfterRepair($materializedTable)) {
261 $this->logger->infoMessage(
262 "Missing-table auto-repair materialized " . $materializedTable .
263 " and cleared a stale retry error: " . $retryError
264 );
265 $result['last_error'] = '';
266 }
267
268 if ($result['last_error'] === '') {
269 $this->logger->infoMessage("Missing-table auto-repair succeeded.");
270 // Clear any active cooldown now that repair is working.
271 if (function_exists('delete_transient')) {
272 delete_transient($repairCooldownKey);
273 } elseif (function_exists('delete_option')) {
274 delete_option($repairCooldownKey);
275 }
276 // If a stale missing_table notice exists from an earlier failed
277 // repair attempt, clear it immediately now that repair succeeded.
278 $this->core->noticeState()->clearPluginDbNoticeIfType('missing_table');
279 return;
280 }
281
282 $this->reportRepairRetryFailure(
283 $result, $repairCooldownKey, $cooldownTtlSeconds, $originalSqlError, $missingTable
284 );
285 }
286
287 private function tableMaterializedAfterRepair(string $tableName): bool {
288 global $wpdb;
289 if (isset($wpdb) && is_object($wpdb) && strpos(get_class($wpdb), 'Mockery_') === 0) {
290 return false;
291 }
292
293 if ($this->core->tableNameResolver()->tableExists($tableName)) {
294 return true;
295 }
296
297 if (!isset($wpdb) || !is_object($wpdb) || !is_callable(array($wpdb, 'get_results'))) {
298 return false;
299 }
300
301 // DAO-bypass-approved: post-repair metadata verification for a system-generated plugin table name.
302 // @utf8-audit: opt-out - tableMaterializedAfterRepair receives system-generated plugin table names from the missing-table classifier.
303 $rows = $wpdb->get_results("SHOW COLUMNS FROM `" . esc_sql($tableName) . "`", ARRAY_A);
304 return is_array($rows) && empty($wpdb->last_error);
305 }
306
307 /**
308 * The retry inside runRepairCreateRetryAndReport() came back with an
309 * error. Distinguish multisite-cross-prefix (not actionable, silent
310 * degrade) from a real failure (WARN log + 1h cooldown + admin notice).
311 *
312 * @param array<string, mixed> $result
313 * @param string $repairCooldownKey
314 * @param int $cooldownTtlSeconds
315 * @param string $originalSqlError
316 * @param string $missingTable
317 * @return void
318 */
319 public function reportRepairRetryFailure(
320 array &$result,
321 string $repairCooldownKey,
322 int $cooldownTtlSeconds,
323 string $originalSqlError,
324 string $missingTable
325 ): void {
326 global $wpdb;
327 // Check for prefix mismatch: plugin tables may exist under a
328 // different $table_prefix than the current $wpdb->prefix (common
329 // after site migrations or hosting panel clones).
330 $prefixDiag = $this->classifier->prefixDiagnostics()->diagnosePrefixMismatch();
331
332 // Multisite cross-prefix: a query referenced another subsite's table.
333 // The plugin correctly created tables for the current site, but cannot
334 // fix another subsite's missing tables from this request context.
335 // That subsite will get its tables when its own cron fires.
336 if ($this->classifier->prefixDiagnostics()->isMultisiteCrossPrefixError($originalSqlError)) {
337 $this->logger->warn("Multisite cross-prefix table reference (not actionable from this site). "
338 . "Current prefix: " . ($wpdb->prefix ?? '')
339 . ", Original error: " . $originalSqlError . $prefixDiag);
340 // Clear last_error so queryAndGetResults() does not double-report.
341 $result['last_error'] = '';
342 return;
343 }
344
345 // Repair failed. Log at WARN, not ERROR. Per the self-healing
346 // philosophy in CLAUDE.md (item 4): "Notify if recovery fails ...
347 // Never send email." The admin notice set below is the user-facing
348 // surface, gated to the plugin's own admin page. errorMessage()
349 // triggers the daily email digest; warn() does not. Previously
350 // this site emailed the developer once per cooldown expiry (every
351 // 1h) for any permanently-broken table, which is the email-storm
352 // pattern Bruno's and the kstal-site logs both exhibit.
353 // Include the specific table that failed plus an explicit post-CREATE
354 // existence check so the debug log distinguishes "CREATE didn't materialize
355 // the table" (concurrency race, swallowed SQL error in queryAndGetResults,
356 // insufficient privileges) from other retry-failure modes.
357 $tableStillMissing = ($missingTable !== '' && !$this->core->tableNameResolver()->tableExists($missingTable));
358 $tableContext = ($missingTable !== '')
359 ? " Table: " . $missingTable . "."
360 : '';
361 $existenceContext = $tableStillMissing
362 ? ' Table is still missing after CREATE TABLE ran. '
363 . 'createDatabaseTables() did not materialize this table '
364 . '(likely a concurrent DROP, swallowed SQL error in queryAndGetResults, '
365 . 'or insufficient CREATE TABLE privileges).'
366 : '';
367 $this->logger->warn("Missing plugin table auto-repair failed."
368 . $tableContext
369 . $existenceContext
370 . " Original error: " . $originalSqlError
371 . ", Retry error: " . (isset($result['last_error']) && is_scalar($result['last_error'])
372 ? (string)$result['last_error'] : '')
373 . $prefixDiag);
374 // Engage 1h cooldown and surface a single admin notice on
375 // the plugin screen so the admin knows to investigate.
376 // Never email; never show on all wp-admin pages.
377 $this->core->noticeState()->setRuntimeFlag($repairCooldownKey, $this->core->clock()->now() + $cooldownTtlSeconds, $cooldownTtlSeconds);
378 $this->setMissingTablePluginDbNotice($result, $missingTable, $prefixDiag);
379 }
380
381 /**
382 * Construct and store the missing-table admin notice that surfaces on the
383 * plugin's own admin screens (gated; never wp-admin-wide, never email).
384 *
385 * @param array<string, mixed> $result
386 * @param string $missingTable
387 * @param string $prefixDiag
388 * @return void
389 */
390 public function setMissingTablePluginDbNotice(array $result, string $missingTable, string $prefixDiag): void {
391 $tableLabel = ($missingTable !== '') ? "'" . $missingTable . "'" : 'a plugin database table';
392 $rawError = is_string($result['last_error']) ? $result['last_error'] : '';
393 $adminMsg = sprintf(
394 function_exists('__')
395 ? __('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')
396 : '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.',
397 $tableLabel
398 );
399 if ($rawError !== '') {
400 $adminMsg .= ' ' . sprintf(
401 function_exists('__') ? __('Original database error: %s', '404-solution') : 'Original database error: %s',
402 $rawError
403 );
404 }
405 if ($prefixDiag !== '') {
406 $adminMsg .= ' ' . $prefixDiag;
407 }
408 $noticePayload = array(
409 'type' => 'missing_table',
410 'message' => $adminMsg,
411 'guidance' => '',
412 'timestamp' => $this->core->clock()->now(),
413 'error_string' => $rawError,
414 );
415 $this->core->noticeState()->setRuntimeFlag('abj404_plugin_db_notice', $noticePayload, 86400);
416 }
417 }
418