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 / DatabaseSchemaErrorTaxonomy.php

DatabaseSchemaErrorTaxonomy.php in 404 Solution trunk, at includes/database/DatabaseSchemaErrorTaxonomy.php

223 lines 9.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Pure database schema / data-shape error taxonomy.
4 *
5 * Owns string-based classification of integrity failures: corrupted or
6 * crashed tables (MyISAM "marked as crashed", "Incorrect key file"),
7 * missing plugin tables, transient view-build table churn, and data-shape
8 * issues (invalid UTF-8, collation mismatch). These are the error classes
9 * the auto-repair, table-recreate, and collation-degrade policies act on.
10 *
11 * No side effects: callers reuse the matchers without inheriting recovery
12 * behavior.
13 */
14
15 if (!defined('ABSPATH')) {
16 exit;
17 }
18
19 // allow-no-test-found: covered through DatabaseInfrastructureErrorTaxonomy facade by tests/ErrorClassifierTest.php and tests/StagedBuildHostQuirksTest.php
20 class ABJ_404_Solution_DatabaseSchemaErrorTaxonomy {
21
22 /**
23 * Failures of the STATEMENT rather than of the host: the server answered,
24 * and its answer was that the query cannot be run as written. Re-running it
25 * produces the same answer however long the caller waits.
26 *
27 * @var array<int, string>
28 */
29 private const MALFORMED_STATEMENT_MARKERS = array(
30 'error in your sql syntax',
31 'error 1064',
32 'errno 1064',
33 'unknown column',
34 'unknown table',
35 'unknown database',
36 "doesn't exist",
37 'no such table',
38 );
39
40 /**
41 * The engines' several ways of saying that a schema change is one the
42 * schema already reflects. Every entry is an end state the caller asked
43 * for, reported through an error channel because the caller was not the one
44 * who brought it about. See {@see isRedundantSchemaChangeError()}.
45 *
46 * The DROP wording is listed three times on purpose: MySQL 5.7 says
47 * "check that column/key exists", MySQL 8.0.29 shortened it to "check that
48 * it exists", and MariaDB names the object type ("Can't DROP INDEX `x`").
49 * One marker cannot cover an estate running 5.6 through 11.x.
50 *
51 * @var array<int, string>
52 */
53 private const REDUNDANT_SCHEMA_CHANGE_MARKERS = array(
54 'duplicate key name', // ER_DUP_KEYNAME 1061: ADD INDEX, that index is already there.
55 'duplicate column name', // ER_DUP_FIELDNAME 1060: ADD COLUMN, that column is already there.
56 'already exists', // ER_TABLE_EXISTS_ERROR 1050: CREATE/RENAME onto a name in use.
57 'check that column/key exists', // ER_CANT_DROP_FIELD_OR_KEY 1091: the drop target is already gone.
58 'check that it exists', // Same, as MySQL 8.0.29+ and MariaDB word it.
59 );
60
61 /** @var ABJ_404_Solution_Functions */
62 private $f;
63
64 /**
65 * @param ABJ_404_Solution_Functions $functions
66 */
67 public function __construct($functions) {
68 $this->f = $functions;
69 }
70
71 /** @param string $errorText @return bool */
72 public function isCrashedTableError(string $errorText): bool {
73 if ($errorText === '') {
74 return false;
75 }
76 return stripos($errorText, 'is marked as crashed') !== false;
77 }
78
79 /** @param string $errorText @return bool */
80 public function isIncorrectKeyFileError(string $errorText): bool {
81 if ($errorText === '') {
82 return false;
83 }
84 return stripos($errorText, 'Incorrect key file') !== false;
85 }
86
87 /** @param string $errorText @return bool */
88 public function isMissingPluginTableError(string $errorText): bool {
89 if ($errorText === '') {
90 return false;
91 }
92 $lower = strtolower($errorText);
93 if ($this->f->strpos($lower, '_abj404_logs_hits') !== false) {
94 return false;
95 }
96 return ($this->f->strpos($lower, "doesn't exist") !== false &&
97 $this->f->strpos($lower, '_abj404_') !== false);
98 }
99
100 /** @param string $errorText @return bool */
101 public function isTransientViewBuildTableError(string $errorText): bool {
102 if ($errorText === '') {
103 return false;
104 }
105 $lower = strtolower($errorText);
106 return ($this->f->strpos($lower, '_abj404_view_build') !== false ||
107 $this->f->strpos($lower, '_abj404_view_done') !== false ||
108 $this->f->strpos($lower, '_abj404_view_deleteme') !== false);
109 }
110
111 /**
112 * Determine whether an error indicates invalid text/charset payload.
113 *
114 * @param mixed $errorText
115 * @return bool
116 */
117 public function isInvalidDataError($errorText): bool {
118 if (!is_string($errorText) || $errorText === '') {
119 return false;
120 }
121 $lower = strtolower($errorText);
122 return (
123 $this->f->strpos($lower, 'contains invalid data') !== false ||
124 $this->f->strpos($lower, 'incorrect string value') !== false ||
125 $this->f->strpos($lower, 'invalid utf8') !== false
126 );
127 }
128
129 /**
130 * True when the statement itself is wrong rather than the host being
131 * temporarily unable to answer it: a syntax error, an unknown column, or a
132 * table that does not exist.
133 *
134 * The distinction matters wherever a caller decides whether to try again.
135 * A dropped connection is worth retrying; a query the server has already
136 * rejected on its own terms is not, and retrying it on a short cadence only
137 * buries the one report that would have said what is broken.
138 *
139 * Deliberately NOT part of {@see isInfrastructureSqlError()}: that union
140 * drives notice state and repair for HOST problems, and a malformed
141 * statement is neither. It is a defect in the plugin or in the install's
142 * schema, and it belongs in front of a human.
143 *
144 * @param string $errorText
145 * @return bool
146 */
147 public function isMalformedStatementError(string $errorText): bool {
148 if ($errorText === '') {
149 return false;
150 }
151 // View-build scratch tables are created and dropped continuously, so
152 // one of them missing is ordinary churn rather than a broken statement.
153 // Reading it as permanent would stop a pipeline that recovers on its
154 // own next pass.
155 if ($this->isTransientViewBuildTableError($errorText)) {
156 return false;
157 }
158 $lower = strtolower($errorText);
159 foreach (self::MALFORMED_STATEMENT_MARKERS as $marker) {
160 if ($this->f->strpos($lower, $marker) !== false) {
161 return true;
162 }
163 }
164 return false;
165 }
166
167 /**
168 * The statement asked for a schema change the schema already reflects: the
169 * index, column or table it wanted to add is there, or the one it wanted to
170 * drop is gone. The goal state was reached -- by somebody else.
171 *
172 * This exists because "ensure it exists" cannot be written any other way.
173 * MySQL has no transactional DDL, so SHOW INDEX / SHOW COLUMNS and the
174 * ALTER they authorize are two statements with a gap between them, and a
175 * plugin update arrives on every concurrent request at once. Report 270
176 * (dianthus.zuidplas.net, 2026-08-16 07:10:52) caught two front-end
177 * requests inside that gap: both read the redirects table before either
178 * wrote, both decided idx_status_disabled_timestamp_id was missing, one
179 * won, and the loser reported the winner's success to the developer as
180 * five ERROR lines. Closing the gap is not available; classifying its
181 * outcome correctly is.
182 *
183 * NOT part of {@see ABJ_404_Solution_DatabaseInfrastructureErrorTaxonomy::isInfrastructureSqlError()}:
184 * that union arms write-block cooldowns, notice state and repair passes for
185 * a host in trouble, and a host that answered "already done" is not in
186 * trouble. The only thing this class changes is which channel the answer is
187 * recorded on.
188 *
189 * The matching is deliberately narrow. ER_DUP_ENTRY ("Duplicate entry '17'
190 * for key 'PRIMARY'") is a row the write LOST, not a schema state it
191 * reached, and it shares its first word with ER_DUP_KEYNAME; a marker loose
192 * enough to cover both would silence real data failures. Anything not
193 * listed keeps today's error channel, which is the safe direction to miss
194 * in.
195 *
196 * @param string $errorText
197 * @return bool
198 */
199 public function isRedundantSchemaChangeError(string $errorText): bool {
200 if ($errorText === '') {
201 return false;
202 }
203 $lower = strtolower($errorText);
204 foreach (self::REDUNDANT_SCHEMA_CHANGE_MARKERS as $marker) {
205 if ($this->f->strpos($lower, $marker) !== false) {
206 return true;
207 }
208 }
209 return false;
210 }
211
212 /** @param string $errorText @return bool */
213 public function isCollationError(string $errorText): bool {
214 if ($errorText === '') {
215 return false;
216 }
217 $lower = strtolower($errorText);
218 return ($this->f->strpos($lower, 'illegal mix of collations') !== false ||
219 $this->f->strpos($lower, 'unknown collation') !== false ||
220 $this->f->strpos($lower, 'collation') !== false && $this->f->strpos($lower, 'not valid') !== false);
221 }
222 }
223