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 / upgrades / DatabaseUpgradeBootstrap.php

DatabaseUpgradeBootstrap.php in 404 Solution 4.3.0, at includes/database/upgrades/DatabaseUpgradeBootstrap.php

490 lines 23.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 * Table-bootstrap orchestration sub-component of DatabaseUpgradesEtc.
9 *
10 * Owns the CREATE TABLE / first-activation flow:
11 * - Synchronized createDatabaseTables() entry point.
12 * - reallyCreateDatabaseTables() orchestrator that walks the per-site path
13 * (single site vs network activation vs network upgrade), runs the
14 * permanent-DDL bootstrap, ensures collations / engine / indexes, and
15 * schedules the canonical_url backfill. One-time n-gram cache rebuild
16 * scheduling is delegated to
17 * ABJ_404_Solution_DatabaseUpgradeNGramCacheInitializer.
18 * - Per-DDL-file discovery (discoverPermanentDDLFiles), post-CREATE
19 * materialization verification (verifyTableMaterialized), charset/collation
20 * rewriting (applyPluginTableCharsetCollate), lowercase rename pass
21 * (renameAbj404TablesToLowerCase) and post-column-add hooks
22 * (handleSpecificCases).
23 *
24 * Reached through the explicit createDatabaseTables() facade method on
25 * ABJ_404_Solution_DatabaseUpgradesEtc.
26 */
27 class ABJ_404_Solution_DatabaseUpgradeBootstrap extends ABJ_404_Solution_DatabaseUpgradeComponent {
28
29 /** Create the tables when the plugin is first activated.
30 * @param bool $updatingToNewVersion
31 * @return void
32 */
33 function createDatabaseTables($updatingToNewVersion = false, bool $force = false) {
34
35 $synchronizedKeyFromUser = "create_db_tables";
36 $uniqueID = null;
37
38 if (!$force) {
39 $uniqueID = $this->syncUtils->synchronizerAcquireLockTry($synchronizedKeyFromUser);
40
41 if ($uniqueID == '' || $uniqueID == null) {
42 $this->logger->debugMessage("Avoiding multiple calls for creating database tables.");
43 return;
44 }
45 }
46
47 // Fixed: Use finally block to ensure lock is ALWAYS released, even on fatal errors
48 try {
49 $this->reallyCreateDatabaseTables($updatingToNewVersion);
50
51 } catch (\Exception $e) {
52 $this->logger->errorMessage("Error creating database tables. ", $e);
53 throw $e; // Re-throw to propagate the error
54 } finally {
55 // Release the lock only if one was acquired (non-forced path).
56 if ($uniqueID !== null) {
57 $this->syncUtils->synchronizerReleaseLock($uniqueID, $synchronizedKeyFromUser);
58 }
59 }
60 }
61
62 /**
63 * @param bool $updatingToNewVersion
64 * @return void
65 */
66 private function reallyCreateDatabaseTables($updatingToNewVersion = false) {
67 if ($updatingToNewVersion) {
68 $this->upgrades()->tableRepairUpgrade()->correctIssuesBefore();
69 }
70
71 // MULTISITE: Process current site immediately, schedule background task for remaining sites
72 if ($this->upgrades()->nGramUpgrade()->isNetworkActivated() && !$updatingToNewVersion) {
73 // Activation path: create tables for current site + schedule background for others.
74 $currentBlogId = get_current_blog_id();
75 $this->runInitialCreateTables();
76 $this->upgrades()->collationDriftUpgrade()->correctCollations();
77 $this->upgrades()->engineNormalizationUpgrade()->updateTableEngineToInnoDB();
78 $this->upgrades()->indexesUpgrade()->createIndexes();
79
80 // First chunk of the canonical_url backfill runs in-band so newly
81 // upgraded small sites finish in one shot. Larger sites converge
82 // over subsequent daily-maintenance cron ticks (same method).
83 $this->upgrades()->canonicalUrlBackfillUpgrade()->backfillRedirectsCanonicalUrl();
84
85 $this->logger->infoMessage(sprintf(
86 "Network activation: Created tables for current site (ID %d). Scheduling background task for remaining sites.",
87 $currentBlogId
88 ));
89
90 $this->upgrades()->multiSiteUpgrade()->scheduleBackgroundMultisiteActivation($currentBlogId);
91
92 } else if ($this->upgrades()->nGramUpgrade()->isNetworkActivated() && $updatingToNewVersion) {
93 // Upgrade path on a network install: update tables for current site + schedule
94 // background upgrade for other sites (so sub-site tables are also updated).
95 $currentBlogId = get_current_blog_id();
96 $this->runInitialCreateTables();
97 $this->upgrades()->collationDriftUpgrade()->correctCollations();
98 $this->upgrades()->engineNormalizationUpgrade()->updateTableEngineToInnoDB();
99 $this->upgrades()->indexesUpgrade()->createIndexes();
100
101 // First chunk of the canonical_url backfill runs in-band so newly
102 // upgraded small sites finish in one shot. Larger sites converge
103 // over subsequent daily-maintenance cron ticks (same method).
104 $this->upgrades()->canonicalUrlBackfillUpgrade()->backfillRedirectsCanonicalUrl();
105
106 $this->logger->infoMessage(sprintf(
107 "Network upgrade: Updated tables for current site (ID %d). Scheduling background upgrade for remaining sites.",
108 $currentBlogId
109 ));
110
111 $this->upgrades()->multiSiteUpgrade()->scheduleBackgroundMultisiteUpgrade($currentBlogId);
112
113 } else {
114 // Single site (or non-network-activated): create/update tables for current site only.
115 $this->runInitialCreateTables();
116 $this->upgrades()->collationDriftUpgrade()->correctCollations();
117 $this->upgrades()->engineNormalizationUpgrade()->updateTableEngineToInnoDB();
118 $this->upgrades()->indexesUpgrade()->createIndexes();
119
120 // First chunk of the canonical_url backfill runs in-band so newly
121 // upgraded small sites finish in one shot. Larger sites converge
122 // over subsequent daily-maintenance cron ticks (same method).
123 $this->upgrades()->canonicalUrlBackfillUpgrade()->backfillRedirectsCanonicalUrl();
124 }
125
126 // Open the narrow-sort-key read gate immediately for installs that are
127 // already fully populated (a fresh activation has no legacy rows; an
128 // upgrade from a build that already carried the column has its keys set).
129 // Activation-safe: this only flips the latch when no NULL key remains, it
130 // never runs the time-budgeted drain (that stays on the daily cron), so a
131 // large fresh-upgrade table never blocks activation. Until the cron drain
132 // converges on such a table the admin read falls back to the wide source
133 // column (correct order, filesort bounded to the Page Redirects minority).
134 $this->upgrades()->redirectsSortKeyBackfillUpgrade()->refreshSortKeyBackfillLatches();
135
136 // Adopt orphaned tables AFTER target tables exist (rename handles prefix mismatches).
137 $this->renameAbj404TablesToLowerCase();
138
139 // we could do this only when a table is created or when the "meta" column is created
140 // but it doesn't take long anyway so we do it every night.
141 $this->permalinkCache->updatePermalinkCache(1);
142
143 // One-time N-gram cache initialization (async via WP-Cron to prevent
144 // blocking). Owned by the dedicated initializer collaborator.
145 (new ABJ_404_Solution_DatabaseUpgradeNGramCacheInitializer($this->upgrades(), $this->logger))
146 ->scheduleRebuildIfUninitialized($updatingToNewVersion);
147
148 // Run one-time migration to relative paths (Issue #24)
149 if (get_option('abj404_migrated_to_relative_paths') !== '1') {
150 $migrationResults = $this->upgrades()->pluginUpdateUpgrade()->migrateURLsToRelativePaths();
151
152 // Show admin notice if migration occurred
153 if ($updatingToNewVersion && is_array($migrationResults) && !empty($migrationResults['redirects_updated'])) {
154 $rawRedirectsUpdated = $migrationResults['redirects_updated'];
155 $redirectsUpdated = is_scalar($rawRedirectsUpdated) ? (int)$rawRedirectsUpdated : 0;
156 $message = sprintf(
157 _n(
158 '404 Solution: Migrated %d redirect to subdirectory-independent format.',
159 '404 Solution: Migrated %d redirects to subdirectory-independent format.',
160 $redirectsUpdated,
161 '404-solution'
162 ),
163 $redirectsUpdated
164 );
165 if (function_exists('add_settings_error')) {
166 add_settings_error('abj404_settings', 'migration_success', $message, 'updated');
167 }
168 }
169 }
170
171 if ($updatingToNewVersion) {
172 $this->upgrades()->tableRepairUpgrade()->correctIssuesAfter();
173 }
174 }
175
176 /**
177 * Makes all plugin table names lowercase, in case someone thought it was funny to use
178 * the lower_case_table_names=0 setting. Also detects and adopts orphaned plugin tables
179 * under old prefixes (from site migrations or the rename bug in v2.35.16 through v3.x).
180 * @return void
181 */
182 function renameAbj404TablesToLowerCase() {
183 global $wpdb;
184
185 // On case-insensitive MySQL (lower_case_table_names >= 1), table names
186 // are already treated as lowercase internally. Renaming is pointless and
187 // can cause issues on some hosting setups.
188 // DAO-bypass-approved: Schema-bootstrap inside renameAbj404TablesToLowerCase(). Runs before plugin DAO is fully wired during DB upgrades.
189 $lctnResult = $wpdb->get_row("SHOW VARIABLES LIKE 'lower_case_table_names'", ARRAY_A);
190 if (is_array($lctnResult)) {
191 $lctnValue = null;
192 foreach ($lctnResult as $key => $value) {
193 if (strtolower((string)$key) === 'value') {
194 $lctnValue = $value;
195 break;
196 }
197 }
198 if (is_scalar($lctnValue) && (int)$lctnValue >= 1) {
199 // MySQL already handles table names case-insensitively.
200 // Still run adoption check in case of prefix mismatch.
201 $this->upgrades()->orphanAdoptionUpgrade()->adoptOrphanedTables();
202 return;
203 }
204 }
205
206 // Fetch all tables containing "abj404", case-insensitive
207 $dbNameRaw = $wpdb->dbname ?? '';
208 if ($dbNameRaw === '') {
209 $this->logger->warn("Could not determine database name for lowercase rename.");
210 return;
211 }
212 $dbNameEscaped = esc_sql($dbNameRaw);
213 $dbName = is_array($dbNameEscaped) ? '' : $dbNameEscaped;
214 $query = "SELECT table_name
215 FROM information_schema.tables
216 WHERE table_schema = '{$dbName}'
217 AND LOWER(table_name) LIKE '%abj404%'";
218 $results = $this->dbCore->queryAndGetResults($query);
219
220 if (!is_array($results['rows'])) {
221 $this->logger->warn("Could not query information_schema tables for lowercase rename.");
222 return;
223 }
224
225 foreach ($results['rows'] as $row) {
226 if (!is_array($row)) {
227 continue;
228 }
229 // Case-insensitive key lookup: MySQL drivers return information_schema
230 // column names in varying cases (table_name, TABLE_NAME, Table_Name).
231 $tableName = null;
232 foreach ($row as $key => $value) {
233 if (strtolower((string)$key) === 'table_name' && is_scalar($value)) {
234 $tableName = (string)$value;
235 break;
236 }
237 }
238
239 if (!empty($tableName)) {
240 $lowercaseName = strtolower($tableName);
241
242 // Check if the table name is already lowercase, skip if it is
243 if ($tableName !== $lowercaseName) {
244 // Rename the table to lowercase
245 $renameQuery = "RENAME TABLE `{$tableName}` TO `{$lowercaseName}`";
246 $this->dbCore->queryAndGetResults($renameQuery,
247 ['ignore_errors' => ["already exists"]]);
248 $this->logger->infoMessage("Renamed table {$tableName} to {$lowercaseName}\n");
249 }
250 } else {
251 $this->logger->warn("I didn't find a table name in the results of this row: " .
252 print_r($row, true));
253 }
254 }
255
256 // After renaming, check for orphaned tables under old prefixes.
257 $this->upgrades()->orphanAdoptionUpgrade()->adoptOrphanedTables();
258 }
259
260 /** When certain columns are created we have to populate data.
261 * @param string $tableName
262 * @param string $colName
263 * @return void
264 */
265 function handleSpecificCases($tableName, $colName) {
266 if (empty($tableName) || !is_string($tableName)) {
267 return;
268 }
269
270 if (strpos($tableName, 'abj404_logsv2') !== false && $colName == 'min_log_id') {
271 global $wpdb;
272 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../../sql/logsSetMinLogID.sql");
273 $this->dbCore->queryAndGetResults($query);
274 // Ensure composite index exists after backfilling min_log_id.
275 $this->upgrades()->indexesUpgrade()->ensureLogsCompositeIndex($tableName);
276 }
277 if (strpos($tableName, 'abj404_permalink_cache') !== false && $colName == 'url_length') {
278 // clear the permalink cache so that the url length column will be populated.
279 // this could be more efficient but I'll assume that's not necessary.
280 $this->contentRepo->truncatePermalinkCacheTable();
281 }
282 }
283
284 /**
285 * Discover all permanent (non-Temp) DDL files and extract table metadata.
286 *
287 * @return array<int, array{placeholder: string, bareTableName: string, ddlContent: string}>
288 */
289 function discoverPermanentDDLFiles(): array {
290 $sqlDir = __DIR__ . '/../../sql';
291 $files = glob($sqlDir . '/create*Table.sql');
292 if (!is_array($files)) {
293 $files = [];
294 }
295 sort($files);
296
297 $result = [];
298 foreach ($files as $file) {
299 if (stripos(basename($file), 'Temp') !== false) {
300 continue;
301 }
302 $ddlContent = ABJ_404_Solution_FileSystemService::readFileContents($file);
303 if (!is_string($ddlContent) || trim($ddlContent) === '') {
304 continue;
305 }
306 if (!preg_match('/\{(wp_(abj404_\w+))\}/', $ddlContent, $m)) {
307 continue;
308 }
309 // Transient staged-build tables (view_build, view_done, view_deleteme)
310 // are owned by the staged view-build collaborators.
311 // stageCreateBuildTable() creates view_build on demand, stageRenameSwap()
312 // renames it to view_done, and view_deleteme is the ephemeral previous-
313 // generation served table that gets dropped right after the swap. None
314 // of them should participate in the permanent-DDL bootstrap, repair, or
315 // missing-table check loops. Their absence between builds is normal,
316 // not a corruption signal.
317 if (in_array($m[2], array('abj404_view_build', 'abj404_view_done', 'abj404_view_deleteme'), true)) {
318 continue;
319 }
320 $result[] = [
321 'placeholder' => '{' . $m[1] . '}',
322 'bareTableName' => $m[2],
323 'ddlContent' => $ddlContent,
324 ];
325 }
326 // Extension point: add-ons can register extra permanent abj404_* tables
327 // (same entry shape as above) to join the create/verify loops; malformed
328 // entries from a misbehaving callback are dropped.
329 $filtered = apply_filters('abj404_permanent_ddl_files', $result);
330 if (!is_array($filtered)) {
331 return $result;
332 }
333 $validated = array();
334 foreach ($filtered as $entry) {
335 if (is_array($entry)
336 && isset($entry['placeholder'], $entry['bareTableName'], $entry['ddlContent'])
337 && is_string($entry['placeholder']) && is_string($entry['bareTableName'])
338 && is_string($entry['ddlContent'])) {
339 $validated[] = array('placeholder' => $entry['placeholder'],
340 'bareTableName' => $entry['bareTableName'], 'ddlContent' => $entry['ddlContent']);
341 }
342 }
343 return $validated;
344 }
345
346 /** @return void */
347 function runInitialCreateTables() {
348 // Re-add a stripped `id` PRIMARY KEY (via ALTER) BEFORE any CREATE TABLE
349 // IF NOT EXISTS runs. Without this step, an existing-but-broken table
350 // (missing the file's `id` PRIMARY KEY) would survive the IF NOT EXISTS
351 // check and verifyColumns would only ALTER ADD the missing non-PK
352 // columns, leaving the table without its primary key. Lives here (not
353 // just in correctIssuesBefore) so cron callers of createDatabaseTables()
354 // (which don't pass the $updatingToNewVersion flag) also repair
355 // stripped tables instead of propagating the broken state.
356 $this->upgrades()->tableRepairUpgrade()->repairStrippedViewCacheTable();
357
358 $ngramTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_ngram_cache');
359 $ngramEpochMigrationSafe = $this->upgrades()->nGramUpgrade()->ensureLastUpdatedEpochColumn($ngramTable);
360
361 $ddlEntries = $this->discoverPermanentDDLFiles();
362 foreach ($ddlEntries as $ddlEntry) {
363 if (!is_array($ddlEntry)) {
364 continue;
365 }
366 $placeholder = isset($ddlEntry['placeholder']) && is_string($ddlEntry['placeholder'])
367 ? $ddlEntry['placeholder'] : '';
368 $bareTableName = isset($ddlEntry['bareTableName']) && is_string($ddlEntry['bareTableName'])
369 ? $ddlEntry['bareTableName'] : '';
370 $ddlContent = isset($ddlEntry['ddlContent']) && is_string($ddlEntry['ddlContent'])
371 ? $ddlEntry['ddlContent'] : '';
372
373 $query = $this->applyPluginTableCharsetCollate($ddlContent);
374 $this->dbCore->queryAndGetResults($query);
375
376 $tableName = $this->dbCore->doTableNameReplacements($placeholder);
377
378 // Per-table post-CREATE verification: confirm the table actually
379 // exists on disk. queryAndGetResults logs SQL errors generically,
380 // but a silently-failing CREATE (concurrent DROP, swallowed parse
381 // error, prefix drift, or insufficient privileges) is invisible
382 // without an explicit existence check. Log per-table so the debug
383 // log identifies which DDL didn't materialize and why downstream
384 // auto-repair attempts will keep failing.
385 if (!$this->verifyTableMaterialized($tableName, $placeholder)) {
386 // Don't abort the loop. Other tables can still get created.
387 continue;
388 }
389
390 // Targeted online-DDL column add(s) before the generic verifyColumns()
391 // flow runs a bare ALTER. On large logsv2 tables (multi-GB on
392 // busy sites) bare ADD COLUMN can block the table for tens of
393 // seconds; the targeted helper uses ALGORITHM=INPLACE, LOCK=NONE
394 // so InnoDB 5.6 or newer picks the lockless online-DDL path. If the
395 // engine doesn't support it the helper falls back silently and
396 // verifyColumns() picks up the column add as a safety net.
397 if ($bareTableName === 'abj404_logsv2') {
398 $this->upgrades()->indexesUpgrade()->ensureLogsv2CanonicalUrlColumn($tableName);
399 }
400 // Same logic for the redirects side. canonical_url is required by
401 // setupRedirect() and was added in 4.1.11; on a small fraction of
402 // sites dbDelta silently fails to add it, so every captured 404
403 // emits "Unknown column 'canonical_url' in 'field list'" until
404 // verifyColumns eventually retries. Eagerly running the targeted
405 // add closes that window.
406 if ($bareTableName === 'abj404_redirects') {
407 $this->upgrades()->indexesUpgrade()->ensureRedirectsCanonicalUrlColumn($tableName);
408 // Denorm Step 3a (i459): same eager online-DDL add for the four
409 // derived columns (logshits, last_used, dest_for_view,
410 // published_status) so they exist before verifyColumns() and
411 // before the chunked backfill reads them. Idempotent: each
412 // column is SHOW COLUMNS-guarded, so this is a no-op once added.
413 $this->upgrades()->indexesUpgrade()->ensureRedirectsDenormColumns($tableName);
414 }
415 if ($bareTableName === 'abj404_ngram_cache' && !$ngramEpochMigrationSafe) {
416 continue;
417 }
418
419 $this->upgrades()->schemaDiffUpgrade()->verifyColumns($tableName, $query);
420 }
421
422 // Table-specific post-creation steps.
423 $logsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}");
424 $this->upgrades()->indexesUpgrade()->ensureLogsCompositeIndex($logsTable);
425 }
426
427 /**
428 * Verify that a CREATE TABLE actually materialized the named table on disk.
429 * Returns true if the table exists, false (and logs a per-table error) if not.
430 *
431 * Distinguishes silently-failing CREATEs from generic SQL errors so the
432 * debug log identifies which specific DDL didn't materialize. Common causes:
433 * concurrent DROP from a parallel cron, SQL parse error swallowed by
434 * queryAndGetResults, prefix drift between request and table_prefix in
435 * wp-config, or missing CREATE TABLE privileges on the DB user.
436 *
437 * @param string $tableName Fully-qualified table name (with prefix).
438 * @param string $placeholder Original placeholder (e.g. "{wp_abj404_redirects}") for diagnostic context.
439 * @return bool True if table exists post-CREATE, false otherwise.
440 */
441 private function verifyTableMaterialized(string $tableName, string $placeholder): bool {
442 global $wpdb;
443 if (!isset($wpdb)) {
444 return false;
445 }
446 // @utf8-audit: opt-out - $tableName is fully-qualified plugin table
447 // name from doTableNameReplacements / $wpdb->prefix; never user input.
448 // DAO-bypass-approved: Schema-bootstrap inside verifyTableMaterialized(). Verifies CREATE TABLE actually materialized; DAO timeout wrapper is irrelevant for DDL existence probe.
449 $found = $wpdb->get_var("SHOW TABLES LIKE '" . esc_sql($tableName) . "'");
450 if ($found === $tableName) {
451 return true;
452 }
453 $this->logger->errorMessage(
454 "CREATE TABLE did not materialize '" . $tableName . "' "
455 . "(placeholder " . $placeholder . "). "
456 . "Table is still missing on disk after CREATE TABLE IF NOT EXISTS ran. "
457 . "Likely causes: concurrent DROP from a parallel request, "
458 . "SQL parse error suppressed by queryAndGetResults, "
459 . "prefix mismatch between request and wp-config table_prefix, "
460 . "or insufficient CREATE TABLE privileges on the DB user."
461 );
462 return false;
463 }
464
465 /**
466 * @param string $createTableSql
467 * @return string
468 */
469 function applyPluginTableCharsetCollate($createTableSql) {
470 global $wpdb;
471 if (!is_string($createTableSql) || $createTableSql === '') {
472 return $createTableSql;
473 }
474
475 // Always prefer utf8mb4 for plugin tables, regardless of site defaults.
476 $collate = 'utf8mb4_unicode_ci';
477 if (!empty($wpdb->collate) && stripos($wpdb->collate, 'utf8mb4') !== false) {
478 $collate = $wpdb->collate;
479 }
480
481 $createTableSql = str_replace('{COLLATION}', $collate, $createTableSql);
482 // If the statement already specifies charset/collation, don't override.
483 if (preg_match('/\b(?:default\s+)?(?:character\s+set|charset|collate)\b/i', $createTableSql)) {
484 return $createTableSql;
485 }
486
487 return rtrim($createTableSql) . " DEFAULT CHARACTER SET utf8mb4 COLLATE {$collate}";
488 }
489 }
490