PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / upgrades / DatabaseUpgradeDropStagedViewTables.php

DatabaseUpgradeDropStagedViewTables.php in 404 Solution trunk, at includes/database/upgrades/DatabaseUpgradeDropStagedViewTables.php

153 lines 7.5 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 // 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