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

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

319 lines 15.6 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 entry point sub-component of DatabaseUpgradesEtc.
9 *
10 * Owns the CREATE TABLE / first-activation flow:
11 * - Synchronized createDatabaseTables() entry point (the FULL bootstrap:
12 * unbounded on a large site, always lock-serialized, never inline on a
13 * user-facing request).
14 * - repairMissingTables(), the bounded counterpart used by the per-query
15 * missing-table auto-repair: materializes only the tables that are actually
16 * missing, does no schema-wide drift correction or data backfill, needs no
17 * lock, and queues a one-off daily-maintenance tick for the rest.
18 * - reallyCreateDatabaseTables() orchestrator that walks the per-site path
19 * (single site vs network activation vs network upgrade), runs the
20 * permanent-DDL bootstrap, ensures collations / engine / indexes, and
21 * schedules the canonical_url backfill. One-time n-gram cache rebuild
22 * scheduling is delegated to
23 * ABJ_404_Solution_DatabaseUpgradeNGramCacheInitializer.
24 *
25 * Permanent-DDL file discovery/execution/verification and charset/collation
26 * rewriting are delegated to ABJ_404_Solution_DatabaseTableDdlExecutor
27 * (discoverPermanentDDLFiles, runInitialCreateTables and
28 * applyPluginTableCharsetCollate remain here only as thin delegating facades
29 * so the ~50+ existing call sites keep working). Column-triggered data
30 * backfills are NOT routed through here: schema-diff reaches
31 * ABJ_404_Solution_DatabaseUpgradeAddedColumnBackfill directly.
32 * The lowercase-table-rename / orphan-adoption-trigger pass is delegated to
33 * ABJ_404_Solution_DatabaseTableLowercaseRenamer (renameAbj404TablesToLowerCase
34 * is likewise kept here as a delegating facade). Both collaborators are
35 * constructed fresh on every call, never cached, so they always observe
36 * whichever dbCore/logger are current (these can be swapped at runtime via
37 * replaceDatabaseUpgradeDependencies()).
38 *
39 * Reached through the explicit createDatabaseTables() facade method on
40 * ABJ_404_Solution_DatabaseUpgradesEtc.
41 */
42 class ABJ_404_Solution_DatabaseUpgradeBootstrap extends ABJ_404_Solution_DatabaseUpgradeComponent {
43
44 /** Create the tables when the plugin is first activated.
45 *
46 * Always serialized on the `create_db_tables` lock. There is deliberately
47 * no `$force` / bypass parameter: this entry point runs the FULL bootstrap
48 * (schema-wide collation sweep, engine conversion, index pass, canonical_url
49 * and denorm backfills, orphan adoption, permalink-cache rebuild, one-time
50 * URL migration), which is unbounded on a large site, so N callers running
51 * it concurrently is never correct. The one caller that used to bypass the
52 * lock -- the per-query missing-table auto-repair -- now calls
53 * repairMissingTables() instead, whose work is bounded by construction and
54 * therefore does not need (or contend for) this lock at all.
55 *
56 * @param bool $updatingToNewVersion
57 * @return void
58 */
59 function createDatabaseTables($updatingToNewVersion = false) {
60
61 $synchronizedKeyFromUser = "create_db_tables";
62 $uniqueID = $this->syncUtils->synchronizerAcquireLockTry($synchronizedKeyFromUser);
63
64 if ($uniqueID == '' || $uniqueID == null) {
65 $this->logger->debugMessage("Avoiding multiple calls for creating database tables.");
66 return;
67 }
68
69 // Fixed: Use finally block to ensure lock is ALWAYS released, even on fatal errors
70 try {
71 $this->reallyCreateDatabaseTables($updatingToNewVersion);
72
73 } catch (\Exception $e) {
74 $this->logger->errorMessage("Error creating database tables. ", $e);
75 throw $e; // Re-throw to propagate the error
76 } finally {
77 $this->syncUtils->synchronizerReleaseLock($uniqueID, $synchronizedKeyFromUser);
78 }
79 }
80
81 /**
82 * Bounded missing-table repair, for the per-query auto-repair path only.
83 *
84 * Materializes the permanent plugin tables that are currently missing and
85 * nothing else (see
86 * DatabaseTableDdlExecutor::createMissingPermanentTables() for exactly what
87 * is and is not done, and why). Safe to call inline from a user-facing
88 * request: the cost is one SHOW TABLES probe per DDL file plus one CREATE
89 * TABLE IF NOT EXISTS per genuinely-missing table, with no data backfill and
90 * no schema-wide ALTER pass.
91 *
92 * Concurrency: no lock. Every statement is idempotent (CREATE TABLE IF NOT
93 * EXISTS) and the pre-probe means the common case -- a concurrent request
94 * that lost the race and finds the table already created -- issues no DDL at
95 * all. Acquiring the `create_db_tables` lock here would be actively wrong:
96 * a bootstrap holding it can run for minutes, and a repair that gave up
97 * because the lock was busy would leave the caller's query failing.
98 *
99 * Whatever schema-wide drift correction the table also wants (collations,
100 * engine, indexes on OTHER tables, backfills, orphan adoption) converges
101 * out-of-band on the daily maintenance tick, which this method schedules a
102 * one-off of when it actually creates something so convergence happens in
103 * about a minute rather than up to 24 hours.
104 *
105 * @return array<int, string> Fully-qualified names of the tables created.
106 */
107 function repairMissingTables(): array {
108 $created = $this->ddlExecutor()->createMissingPermanentTables();
109 if (!empty($created)) {
110 $this->logger->infoMessage(
111 'Missing-table repair materialized ' . count($created) . ' table(s): '
112 . implode(', ', $created) . '. Scheduling a deferred maintenance tick so the '
113 . 'schema-wide passes (collations, indexes, engine, orphan adoption, backfills) '
114 . 'converge out of band.'
115 );
116 $this->scheduleDeferredSchemaConvergence();
117 }
118 return $created;
119 }
120
121 /**
122 * Queue a single out-of-band run of the daily-maintenance cron so the
123 * schema-wide work the bounded repair deliberately skipped still happens
124 * promptly. Uses the existing, already-registered daily hook rather than a
125 * new one, so there is no extra event to register at activation or clear at
126 * uninstall. wp_schedule_single_event() de-duplicates identical hook+args
127 * within a 10-minute window, so a burst of concurrent repairs queues one
128 * tick, not one per request.
129 *
130 * @return void
131 */
132 private function scheduleDeferredSchemaConvergence(): void {
133 try {
134 abj_cron_scheduler()->scheduleSingleAt(
135 ABJ_404_Solution_CronScheduler::HOOK_CLEANUP,
136 abj_clock()->now() + 60
137 );
138 } catch (\Throwable $e) {
139 // A cron-scheduling failure must never turn a successful table
140 // repair into a failed one: the caller's query has already been
141 // made serviceable, and the same convergence runs on the next
142 // regular daily tick regardless.
143 $this->logger->warn(
144 'Could not schedule deferred schema convergence after a missing-table repair: '
145 . $e->getMessage() . '. The regular daily maintenance tick will still converge.'
146 );
147 }
148 }
149
150 /**
151 * @param bool $updatingToNewVersion
152 * @return void
153 */
154 private function reallyCreateDatabaseTables($updatingToNewVersion = false) {
155 if ($updatingToNewVersion) {
156 $this->upgrades()->tableRepairUpgrade()->correctIssuesBefore();
157 }
158
159 // MULTISITE: Process current site immediately, schedule background task for remaining sites
160 if ($this->upgrades()->nGramUpgrade()->isNetworkActivated() && !$updatingToNewVersion) {
161 // Activation path: create tables for current site + schedule background for others.
162 $currentBlogId = get_current_blog_id();
163 $this->runInitialCreateTables();
164 $this->upgrades()->collationDriftUpgrade()->correctCollations();
165 $this->upgrades()->engineNormalizationUpgrade()->updateTableEngineToInnoDB();
166 $this->upgrades()->indexesUpgrade()->createIndexes();
167
168 // First chunk of the canonical_url backfill runs in-band so newly
169 // upgraded small sites finish in one shot. Larger sites converge
170 // over subsequent daily-maintenance cron ticks (same method).
171 $this->upgrades()->canonicalUrlBackfillUpgrade()->backfillRedirectsCanonicalUrl();
172
173 $this->logger->infoMessage(sprintf(
174 "Network activation: Created tables for current site (ID %d). Scheduling background task for remaining sites.",
175 $currentBlogId
176 ));
177
178 $this->upgrades()->multiSiteUpgrade()->scheduleBackgroundMultisiteActivation($currentBlogId);
179
180 } else if ($this->upgrades()->nGramUpgrade()->isNetworkActivated() && $updatingToNewVersion) {
181 // Upgrade path on a network install: update tables for current site + schedule
182 // background upgrade for other sites (so sub-site tables are also updated).
183 $currentBlogId = get_current_blog_id();
184 $this->runInitialCreateTables();
185 $this->upgrades()->collationDriftUpgrade()->correctCollations();
186 $this->upgrades()->engineNormalizationUpgrade()->updateTableEngineToInnoDB();
187 $this->upgrades()->indexesUpgrade()->createIndexes();
188
189 // First chunk of the canonical_url backfill runs in-band so newly
190 // upgraded small sites finish in one shot. Larger sites converge
191 // over subsequent daily-maintenance cron ticks (same method).
192 $this->upgrades()->canonicalUrlBackfillUpgrade()->backfillRedirectsCanonicalUrl();
193
194 $this->logger->infoMessage(sprintf(
195 "Network upgrade: Updated tables for current site (ID %d). Scheduling background upgrade for remaining sites.",
196 $currentBlogId
197 ));
198
199 $this->upgrades()->multiSiteUpgrade()->scheduleBackgroundMultisiteUpgrade($currentBlogId);
200
201 } else {
202 // Single site (or non-network-activated): create/update tables for current site only.
203 $this->runInitialCreateTables();
204 $this->upgrades()->collationDriftUpgrade()->correctCollations();
205 $this->upgrades()->engineNormalizationUpgrade()->updateTableEngineToInnoDB();
206 $this->upgrades()->indexesUpgrade()->createIndexes();
207
208 // First chunk of the canonical_url backfill runs in-band so newly
209 // upgraded small sites finish in one shot. Larger sites converge
210 // over subsequent daily-maintenance cron ticks (same method).
211 $this->upgrades()->canonicalUrlBackfillUpgrade()->backfillRedirectsCanonicalUrl();
212 }
213
214 // Open the narrow-sort-key read gate immediately for installs that are
215 // already fully populated (a fresh activation has no legacy rows; an
216 // upgrade from a build that already carried the column has its keys set).
217 // Activation-safe: this only flips the latch when no NULL key remains, it
218 // never runs the time-budgeted drain (that stays on the daily cron), so a
219 // large fresh-upgrade table never blocks activation. Until the cron drain
220 // converges on such a table the admin read falls back to the wide source
221 // column (correct order, filesort bounded to the Page Redirects minority).
222 $this->upgrades()->redirectsSortKeyBackfillUpgrade()->refreshSortKeyBackfillLatches();
223
224 // Adopt orphaned tables AFTER target tables exist (rename handles prefix mismatches).
225 $this->renameAbj404TablesToLowerCase();
226
227 // we could do this only when a table is created or when the "meta" column is created
228 // but it doesn't take long anyway so we do it every night.
229 $this->permalinkCache->updatePermalinkCache(1);
230
231 // One-time N-gram cache initialization (async via WP-Cron to prevent
232 // blocking). Owned by the dedicated initializer collaborator.
233 (new ABJ_404_Solution_DatabaseUpgradeNGramCacheInitializer($this->upgrades(), $this->logger))
234 ->scheduleRebuildIfUninitialized($updatingToNewVersion);
235
236 // Run one-time migration to relative paths (Issue #24)
237 if (get_option('abj404_migrated_to_relative_paths') !== '1') {
238 $migrationResults = $this->upgrades()->pluginUpdateUpgrade()->migrateURLsToRelativePaths();
239
240 // Show admin notice if migration occurred
241 if ($updatingToNewVersion && is_array($migrationResults) && !empty($migrationResults['redirects_updated'])) {
242 $rawRedirectsUpdated = $migrationResults['redirects_updated'];
243 $redirectsUpdated = is_scalar($rawRedirectsUpdated) ? (int)$rawRedirectsUpdated : 0;
244 $message = sprintf(
245 _n(
246 '404 Solution: Migrated %d redirect to subdirectory-independent format.',
247 '404 Solution: Migrated %d redirects to subdirectory-independent format.',
248 $redirectsUpdated,
249 '404-solution'
250 ),
251 $redirectsUpdated
252 );
253 if (function_exists('add_settings_error')) {
254 add_settings_error('abj404_settings', 'migration_success', $message, 'updated');
255 }
256 }
257 }
258
259 if ($updatingToNewVersion) {
260 $this->upgrades()->tableRepairUpgrade()->correctIssuesAfter();
261 }
262 }
263
264 /**
265 * Makes all plugin table names lowercase, in case someone thought it was funny to use
266 * the lower_case_table_names=0 setting. Also detects and adopts orphaned plugin tables
267 * under old prefixes (from site migrations or the rename bug in v2.35.16 through v3.x).
268 *
269 * Delegates to a freshly-constructed ABJ_404_Solution_DatabaseTableLowercaseRenamer
270 * (never cached: dbCore/logger can be swapped at runtime via
271 * replaceDatabaseUpgradeDependencies(), so a cached collaborator could go stale).
272 * @return void
273 */
274 function renameAbj404TablesToLowerCase() {
275 $this->lowercaseRenamer()->rename();
276 }
277
278 private function lowercaseRenamer(): ABJ_404_Solution_DatabaseTableLowercaseRenamer {
279 return new ABJ_404_Solution_DatabaseTableLowercaseRenamer(
280 $this->upgrades(), $this->dbCore, $this->logger, $this->getActiveBlogPrefixesLowercase()
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 return $this->ddlExecutor()->discoverPermanentDDLFiles();
291 }
292
293 /** @return void */
294 function runInitialCreateTables() {
295 $this->ddlExecutor()->runInitialCreateTables();
296 }
297
298 /**
299 * @param string $createTableSql
300 * @return string
301 */
302 function applyPluginTableCharsetCollate($createTableSql) {
303 return $this->ddlExecutor()->applyPluginTableCharsetCollate($createTableSql);
304 }
305
306 /**
307 * Delegates permanent-DDL discovery/execution/verification and
308 * charset/collation rewriting to a freshly-constructed
309 * ABJ_404_Solution_DatabaseTableDdlExecutor (never cached: dbCore/logger
310 * can be swapped at runtime via replaceDatabaseUpgradeDependencies(), so a cached
311 * collaborator could go stale).
312 */
313 private function ddlExecutor(): ABJ_404_Solution_DatabaseTableDdlExecutor {
314 return new ABJ_404_Solution_DatabaseTableDdlExecutor(
315 $this->upgrades(), $this->dbCore, $this->logger
316 );
317 }
318 }
319