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

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

180 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 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Daily-cron entry point that fans out to every database-maintenance sub-task.
9 *
10 * Called by abj404_dailyMaintenanceCronJobListener() in 404-solution.php.
11 * Coordinates (in order): self-heal prologue, ngram cache sync/cleanup, expired
12 * transient cleanup, auto-redirect retention,
13 * canonical_url backfill (redirects + logsv2), redirects denorm backfill (Step
14 * 3a) + nightly full reconcile (Step 3d), internal-link scan, and an
15 * inline view_done snapshot refresh. The orchestrator owns the small in-house
16 * tasks that only the daily cron triggers (transient cleanup, view-done
17 * refresh); everything else routes through the coordinator delegate map.
18 */
19 class ABJ_404_Solution_DatabaseUpgradeDailyMaintenance extends ABJ_404_Solution_DatabaseUpgradeComponent {
20
21 /**
22 * Run all database maintenance tasks.
23 *
24 * This is the main orchestrator method called by the daily maintenance cron job.
25 * It coordinates all database-related maintenance tasks in the proper order.
26 *
27 * Called by: abj404_dailyMaintenanceCronJobListener() in 404-solution.php
28 *
29 * @return void
30 */
31 public function runDatabaseMaintenanceTasks() {
32 // Insurance: Verify tables exist (per-site or network-wide based on activation mode)
33 // This catches failed activations, database corruption, and edge cases.
34 // Routed through runSelfHealPrologue() so the SelfHealingPrologueReachabilityTest
35 // can confirm the daily cron reaches the canonical prologue token.
36 $this->upgrades()->selfHealUpgrade()->runSelfHealPrologue();
37
38 // Ngram cache maintenance: sync missing entries and cleanup orphaned ones
39 $this->upgrades()->nGramUpgrade()->syncMissingNGrams();
40 $this->upgrades()->nGramUpgrade()->cleanupOrphanedNGrams();
41
42 // Clean up expired rate limit transients to prevent wp_options bloat
43 $this->cleanupExpiredRateLimitTransients();
44
45 // Expire auto-created redirects that exceed the configured age threshold
46 abj_service('redirects_retention_service')->expireOldAutoRedirects();
47
48 // Backfill canonical_url on legacy redirect rows so the captured-page
49 // JOIN to logs_hits.requested_url stays index-friendly. Chunked + rate-
50 // limited so the daily cron continues progress without blocking large
51 // sites; converges on its own across successive runs.
52 $this->upgrades()->canonicalUrlBackfillUpgrade()->backfillRedirectsCanonicalUrl();
53
54 // Same idea for logsv2: legacy rows (pre-4.1.x) lack canonical_url, so
55 // the hits-rebuild JOIN falls back to CONCAT/TRIM and can't use
56 // idx_canonical_url. Chunked + rate-limited so even a multi-hundred-K
57 // logsv2 backlog converges across successive cron ticks. Tighter
58 // 15-second budget (vs redirects' 25) because this same function is
59 // also reachable from the browser-triggered lazy-backfill AJAX endpoint.
60 $this->upgrades()->canonicalUrlBackfillUpgrade()->backfillLogsv2CanonicalUrl();
61
62 // Denorm Step 3a (i459): populate the four derived columns (logshits,
63 // last_used, dest_for_view, published_status) on legacy redirect rows
64 // that pre-date the column add. Chunked + wall-clock-bounded so even a
65 // large redirects table converges across successive daily ticks without
66 // blocking activation. Runs after the canonical_url backfills so the
67 // hits rollup join matches on the freshly populated canonical_url column.
68 $this->upgrades()->redirectsDenormBackfillUpgrade()->backfillRedirectsDenormColumns();
69
70 // report5.md Finding 1: an install upgraded ACROSS the dest_sort_key
71 // column add already has dest_for_view populated, so the main backfill's
72 // dest_for_view IS NULL sentinel skips it and the converged-row live
73 // write-back never fires; the indexable Destination sort key would stay
74 // NULL until the nightly reconcile below happened to walk that row. This
75 // cheap narrow drain (no per-type joins) closes that window directly,
76 // keyed on the self-clearing dest_sort_key IS NULL AND dest_for_view IS
77 // NOT NULL sentinel. Runs after the main backfill so freshly-resolved rows
78 // (which already got their sort key) are skipped, and converges to a no-op.
79 $this->upgrades()->redirectsSortKeyBackfillUpgrade()->backfillRedirectsDestSortKey();
80
81 // report6.md: same one-time drain for url_sort_key (the indexable URL sort
82 // key, LEFT(url, 191)). New/edited rows get it in real time via the Step 3c
83 // recompute; this converges legacy rows that pre-date the column add so the
84 // URL sort is index-ordered on the captured tab too, without waiting for the
85 // nightly reconcile below.
86 $this->upgrades()->redirectsSortKeyBackfillUpgrade()->backfillRedirectsUrlSortKey();
87
88 // Denorm Step 3d (i462): nightly full reconcile of the same four derived
89 // columns for ALL redirect rows. The Tier-3 floor / backstop: a
90 // brute-force cursor-walked recompute that catches drift from raw-SQL
91 // writers that bypassed the Step 3c real-time hooks (no hook fired, no
92 // timestamp bumped). Chunked + wall-clock-bounded with a resumable id
93 // cursor so a large table converges across successive nightly ticks.
94 // Background-only: never on the read path, never blocks the table view;
95 // a stale value can only delay a refresh by one pass, never blank rows.
96 // Runs after the backfill so a fresh install's one-time population goes
97 // first and the reconcile then keeps the populated columns honest.
98 $this->upgrades()->redirectsDenormReconcileUpgrade()->reconcileRedirectsDenormColumns();
99
100 // Nightly internal-link scan: find broken internal links in published content.
101 if (class_exists('ABJ_404_Solution_InternalLinkScanner')) {
102 $scanner = new ABJ_404_Solution_InternalLinkScanner();
103 $scanner->runNightlyScan();
104 }
105 }
106
107 /**
108 * Clean up expired rate limit transients from wp_options table.
109 *
110 * WordPress transients are supposed to auto-delete when they expire, but in practice
111 * they can accumulate over time. This maintenance task removes expired rate limit
112 * transients to prevent wp_options table bloat.
113 *
114 * Called during daily maintenance cron job.
115 *
116 * @return array<string, mixed> Statistics: ['deleted' => int, 'errors' => int]
117 */
118 public function cleanupExpiredRateLimitTransients() {
119 global $wpdb;
120
121 $this->logger->debugMessage("Cleaning up expired rate limit transients...");
122
123 $stats = ['deleted' => 0, 'errors' => 0];
124
125 // Delete expired rate limit transients
126 // WordPress stores transients as two rows: _transient_* and _transient_timeout_*
127 // The timeout row contains the expiration timestamp
128 // We delete both the value and timeout rows for expired transients
129
130 $currentTime = abj_clock()->now();
131
132 // Find all expired rate limit timeout keys
133 // DAO-bypass-approved: WP-core wp_options probe; $wpdb->prepare is read-only string formatting, executed via $wpdb->get_col below
134 $query = $wpdb->prepare(
135 "SELECT option_name FROM {$wpdb->options}
136 WHERE option_name LIKE %s
137 AND option_value < %d",
138 $wpdb->esc_like('_transient_timeout_abj404_rate_limit_') . '%',
139 $currentTime
140 );
141
142 // DAO-bypass-approved: Outside-plugin-tables wp_options cleanup probe (parallels DataAccessTrait_ViewQueries:478 transient clear)
143 $expiredTimeouts = $wpdb->get_col($query);
144
145 $lastError = (string)($wpdb->last_error ?? '');
146 if ($lastError !== '') {
147 if (!$this->dbCore->errorClassifier()->classifyAndHandleInfrastructureError($lastError)) {
148 $this->logger->errorMessage("Failed to query for expired rate limit transients: " . $lastError);
149 }
150 return ['deleted' => 0, 'errors' => 1, 'error' => $lastError];
151 }
152
153 if (!empty($expiredTimeouts)) {
154 $this->logger->debugMessage("Found " . count($expiredTimeouts) . " expired rate limit transients to delete.");
155
156 foreach ($expiredTimeouts as $timeoutKey) {
157 // Get the corresponding value key (remove '_timeout' from the name)
158 $valueKey = str_replace('_transient_timeout_', '_transient_', $timeoutKey);
159
160 // Delete both the timeout and value rows
161 $timeoutDeleted = delete_option($timeoutKey);
162 $valueDeleted = delete_option($valueKey);
163
164 if ($timeoutDeleted || $valueDeleted) {
165 $stats['deleted']++;
166 } else {
167 $stats['errors']++;
168 }
169 }
170
171 $this->logger->debugMessage("Deleted {$stats['deleted']} expired rate limit transients, {$stats['errors']} errors.");
172 } else {
173 $this->logger->debugMessage("No expired rate limit transients found.");
174 }
175
176 return $stats;
177 }
178
179 }
180