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

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

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