| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
// allow-no-test-found: exercised by DropStagedViewTablesUpgradeTest |
| 8 |
|
| 9 |
/** |
| 10 |
* Denorm Step 3e-D (the one-way door): physically drop the transient staged |
| 11 |
* view-build tables (view_build, view_done, view_deleteme) on upgrade. |
| 12 |
* |
| 13 |
* Background. Through 4.2.x the admin redirect list was served from a staged |
| 14 |
* shared-table pipeline: stageCreateBuildTable() materialized |
| 15 |
* {prefix}abj404_view_build, the build walked it through stages S1-S10 |
| 16 |
* (including the idx_pub_* per-sort composite indexes added by the old |
| 17 |
* 10_index_sort.sql), stageRenameSwap() renamed it to view_done, and |
| 18 |
* view_deleteme was the ephemeral previous-generation served table dropped |
| 19 |
* right after each swap. The denorm chain (Steps 3a-3e-C) replaced that whole |
| 20 |
* subsystem with four derived columns persisted on abj404_redirects |
| 21 |
* (logshits, last_used, dest_for_view, published_status) that the live read |
| 22 |
* resolves directly. After 3e-A/B/C no production code reads, writes, or |
| 23 |
* creates any of the three transient tables: they are pure residue on sites |
| 24 |
* that upgraded from a staged-build version. |
| 25 |
* |
| 26 |
* This component removes that residue. The idx_pub_* / idx_status_disabled |
| 27 |
* composite indexes lived ONLY on view_build (never on the permanent |
| 28 |
* abj404_redirects table), so dropping the tables drops those dead indexes |
| 29 |
* with them. The live read against abj404_redirects is a single-table |
| 30 |
* filesort over the narrow PK row set (status IN (1,2,6) is multi-value, so |
| 31 |
* the plan is never index-ordered anyway), so nothing on the read path |
| 32 |
* depends on the dropped indexes. |
| 33 |
* |
| 34 |
* Safety / defensive philosophy: |
| 35 |
* - Idempotent: DROP TABLE IF EXISTS, so a fresh install (tables never |
| 36 |
* existed) and a re-upgrade (already dropped) are both clean no-ops. |
| 37 |
* - Cron-guarded: refuses to run from a cron tick (operator-driven upgrade |
| 38 |
* path only), so the destructive statement can never fire from daily |
| 39 |
* maintenance. This is the structural backstop that |
| 40 |
* CronReachableDestructiveSqlLintTest (Rule G) proves. |
| 41 |
* - Degrades gracefully on a write-blocked DB (read-only replica / disk |
| 42 |
* full): skip with a debug breadcrumb, never error, never email. The |
| 43 |
* residue is harmless and the next healthy upgrade tick cleans it up. |
| 44 |
* - Only touches the three transient staged tables. It never references a |
| 45 |
* permanent plugin table, so a future maintenance edit cannot widen its |
| 46 |
* blast radius onto user data. |
| 47 |
*/ |
| 48 |
class ABJ_404_Solution_DatabaseUpgradeDropStagedViewTables extends ABJ_404_Solution_DatabaseUpgradeComponent { |
| 49 |
|
| 50 |
/** |
| 51 |
* Bare suffixes of the transient staged-build tables to drop. Resolved to |
| 52 |
* the site's lowercase-prefixed names at drop time. Kept as a single |
| 53 |
* source of truth so the drop list and any future probe agree. |
| 54 |
* |
| 55 |
* @var array<int, string> |
| 56 |
*/ |
| 57 |
private const STAGED_VIEW_TABLE_SUFFIXES = array( |
| 58 |
'abj404_view_build', |
| 59 |
'abj404_view_done', |
| 60 |
'abj404_view_deleteme', |
| 61 |
); |
| 62 |
|
| 63 |
/** |
| 64 |
* Drop the transient staged view-build tables (view_build, view_done, |
| 65 |
* view_deleteme) if present. Runs from the upgrade-only post-create hook |
| 66 |
* (DatabaseUpgradeTableRepair::correctIssuesAfter); never from cron. |
| 67 |
* |
| 68 |
* Returns the count of tables actually dropped (0 on a fresh install, a |
| 69 |
* re-upgrade, a missing $wpdb, or a write-blocked DB) so callers/tests can |
| 70 |
* assert idempotency: the first post-3e-D upgrade returns >=1 when residue |
| 71 |
* exists, every subsequent run returns 0. |
| 72 |
* |
| 73 |
* @return int Number of transient tables that existed and were dropped. |
| 74 |
*/ |
| 75 |
public function dropStagedViewTables(): int { |
| 76 |
// CRON GUARD (Rule G): operator-driven upgrade path only. A daily cron |
| 77 |
// tick must never reach a DROP TABLE on these (or any) tables. Must be |
| 78 |
// the first executable statement so the lint can see it precedes the |
| 79 |
// destructive SQL below. |
| 80 |
if (function_exists('wp_doing_cron') && wp_doing_cron()) { |
| 81 |
return 0; |
| 82 |
} |
| 83 |
|
| 84 |
global $wpdb; |
| 85 |
if (!isset($wpdb)) { |
| 86 |
return 0; |
| 87 |
} |
| 88 |
|
| 89 |
// Read-only replica / disk full: leave the residue in place (it is |
| 90 |
// inert) and let the next healthy upgrade tick clean it up. Logging a |
| 91 |
// failed DROP as an error here would email the admin about a hosting |
| 92 |
// condition the plugin degrades past, so skip quietly instead. |
| 93 |
if ($this->dbCore->noticeState()->isWriteBlockActive()) { |
| 94 |
$this->logger->debugMessage( |
| 95 |
'dropStagedViewTables skipped: DB write block active (read-only / disk full). ' |
| 96 |
. 'Transient staged-view residue is inert; the next healthy upgrade tick will drop it.' |
| 97 |
); |
| 98 |
return 0; |
| 99 |
} |
| 100 |
|
| 101 |
$dropped = 0; |
| 102 |
foreach (self::STAGED_VIEW_TABLE_SUFFIXES as $suffix) { |
| 103 |
$tableName = $this->dbCore->doTableNameReplacements('{wp_' . $suffix . '}'); |
| 104 |
if (!$this->stagedTableExists($tableName)) { |
| 105 |
continue; |
| 106 |
} |
| 107 |
// @utf8-audit: opt-out - $tableName is doTableNameReplacements() of a fixed internal placeholder (lowercase prefix + literal suffix); system-controlled, cannot contain invalid UTF-8. |
| 108 |
// DAO-bypass-approved: idempotent DROP TABLE IF EXISTS on a deprecated transient table guarded by a SHOW TABLES existence probe; routing through queryAndGetResults would surface a benign infrastructure warning on a write-blocked host even though the table is inert residue. |
| 109 |
$result = $wpdb->query('DROP TABLE IF EXISTS `' . esc_sql($tableName) . '`'); |
| 110 |
if ($result === false) { |
| 111 |
// A write failed despite the write-block probe passing (e.g. a |
| 112 |
// lock timeout). Inert residue, hosting-side cause: warn (not |
| 113 |
// error, no email) and move on; the next upgrade retries. |
| 114 |
$this->logger->warn( |
| 115 |
'dropStagedViewTables: DROP of transient table ' . $tableName |
| 116 |
. ' failed (' . (is_string($wpdb->last_error) ? $wpdb->last_error : 'unknown') . '). ' |
| 117 |
. 'Residue is inert; the next upgrade tick will retry.' |
| 118 |
); |
| 119 |
continue; |
| 120 |
} |
| 121 |
$dropped++; |
| 122 |
$this->logger->infoMessage( |
| 123 |
'dropStagedViewTables: dropped vestigial staged view-build table ' . $tableName |
| 124 |
. ' (denorm Step 3e-D). The live read now serves off the abj404_redirects denorm columns.' |
| 125 |
); |
| 126 |
} |
| 127 |
|
| 128 |
return $dropped; |
| 129 |
} |
| 130 |
|
| 131 |
/** |
| 132 |
* Case-sensitive SHOW TABLES existence probe for a transient staged table. |
| 133 |
* |
| 134 |
* Bypasses the DAO on purpose: routing a "does this table exist" probe |
| 135 |
* through queryAndGetResults would log a benign "table doesn't exist" line |
| 136 |
* on every fresh-install upgrade (the common case, where the residue was |
| 137 |
* never present). Same probe shape the denorm backfill/reconcile use. |
| 138 |
* |
| 139 |
* @param string $tableName Fully-qualified, lowercase-prefixed table name. |
| 140 |
* @return bool |
| 141 |
*/ |
| 142 |
private function stagedTableExists(string $tableName): bool { |
| 143 |
global $wpdb; |
| 144 |
if (!isset($wpdb)) { |
| 145 |
return false; |
| 146 |
} |
| 147 |
// @utf8-audit: opt-out - $tableName is a fully-qualified, lowercase-prefixed plugin table name from doTableNameReplacements(); system-controlled, cannot contain invalid UTF-8. |
| 148 |
// DAO-bypass-approved: schema existence probe (SHOW TABLES); see method docblock. |
| 149 |
$found = $wpdb->get_var("SHOW TABLES LIKE '" . esc_sql($tableName) . "'"); |
| 150 |
return $found === $tableName; |
| 151 |
} |
| 152 |
} |
| 153 |
|