PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 / DataAccessTrait_ViewQueriesHitsLifecycle.php

DataAccessTrait_ViewQueriesHitsLifecycle.php in 404 Solution 4.1.19, at includes/DataAccessTrait_ViewQueriesHitsLifecycle.php

344 lines 14.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 * Hits-table lifecycle helpers extracted from
9 * ABJ_404_Solution_DataAccess_ViewQueriesTrait to keep the parent trait under
10 * the ModularityTest CLASS_LIMIT. Owns the *scheduling, locking, and
11 * existence-probe* concerns for the `{wp_abj404_logs_hits}` rollup table,
12 * plus the small log-id watermark helpers (getMaxLogId / getMinLogId /
13 * getStoredMaxLogId) the scheduling path consults. The *rebuild* path
14 * itself lives in ABJ_404_Solution_DataAccess_LogsHitsRebuildTrait
15 * (DataAccessTrait_LogsHitsRebuild.php).
16 *
17 * Composed into ABJ_404_Solution_DataAccess alongside the other DAO traits.
18 * Depends on host-class state and methods provided by the rest of DAO:
19 * `queryAndGetResults`, `doTableNameReplacements`, `getRuntimeFlag`,
20 * `setRuntimeFlag`, `getLowercasePrefix`, `shouldSkipNonEssentialDbWrites`,
21 * `recordLogsHitsRollupStalenessSignal`, `hitsTableNeedsRebuild`,
22 * `createRedirectsForViewHitsTable`, `getLogsHitsTableStatusRow`, the
23 * `$logger` property, the `$hitsTableRebuildScheduled` static, and the
24 * HITS_TABLE_* constants on the host class.
25 */
26 trait ABJ_404_Solution_DataAccess_ViewQueriesHitsLifecycleTrait {
27
28 /** @return void */
29 function maybeUpdateRedirectsForViewHitsTable(): void {
30 // Record that we checked during this request (used for admin tooltip UX).
31 $this->setRuntimeFlag(self::HITS_TABLE_LAST_CHECKED_FLAG, time(), 86400);
32
33 // Piggyback on the captured-404s tab render: also schedule a
34 // 15-second logsv2.canonical_url backfill at shutdown if there's
35 // legacy NULL-row backlog. The shutdown handler holds a worker
36 // for the budget but the admin response is already flushed by
37 // fastcgi_finish_request, so the user doesn't perceive the wait.
38 // The function is internally deduped + gated on column existence,
39 // probe results, and the backfill-complete option, so calling it
40 // unconditionally is cheap.
41 if (function_exists('abj_service')) {
42 $upgradesEtc = abj_service('database_upgrades');
43 if (is_object($upgradesEtc) && method_exists($upgradesEtc, 'scheduleLogsv2CanonicalUrlBackfill')) {
44 $upgradesEtc->scheduleLogsv2CanonicalUrlBackfill();
45 }
46 }
47
48 if ($this->shouldSkipNonEssentialDbWrites()) {
49 $this->logger->debugMessage(__FUNCTION__ . " skipped due to temporary DB write cooldown.");
50 $this->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400);
51 return;
52 }
53
54 // Check if the table exists
55 if (!$this->logsHitsTableExists()) {
56 // Defer creation to shutdown hook so the admin page loads immediately.
57 // The view query gracefully falls back to null hits columns when the
58 // table doesn't exist (getRedirectsForViewQuery checks logsHitsTableExists).
59 // On sites with large logsv2 tables the INSERT...SELECT that populates
60 // the hits table can take minutes, which exceeds proxy timeouts (e.g.
61 // Cloudflare's 100-second limit → HTTP 524).
62 $this->logger->debugMessage(__FUNCTION__ . " table doesn't exist, deferring creation to shutdown hook.");
63 $this->scheduleHitsTableRebuild();
64 return;
65 }
66
67 // Diagnostic: track the max_log_id age signal so a stalled rollup
68 // surfaces a broken-cron admin notice instead of silently showing
69 // stale hit-count columns. Self-heals when the gap closes.
70 $this->recordLogsHitsRollupStalenessSignal();
71
72 // Check if rebuild is needed (logs have changed since last build)
73 if (!$this->hitsTableNeedsRebuild()) {
74 // No new log entries - skip rebuild to reduce server load
75 $this->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'not_needed', 86400);
76 return;
77 }
78
79 // Table exists and logs have changed - defer to shutdown hook
80 $this->scheduleHitsTableRebuild();
81 }
82
83 /**
84 * Schedule the hits table to be rebuilt at shutdown.
85 *
86 * Uses a static flag to ensure the hook is only registered once per request,
87 * even if multiple calls to getRedirectsForView with hits sorting occur.
88 *
89 * The shutdown hook runs after the response is sent, so the admin sees the page
90 * immediately with existing data, and fresh data is available on next load.
91 */
92 /** @return void */
93 function scheduleHitsTableRebuild(): void {
94 if ($this->shouldSkipNonEssentialDbWrites()) {
95 $this->logger->debugMessage(__FUNCTION__ . " skipped due to temporary DB write cooldown.");
96 $this->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400);
97 return;
98 }
99 if (!self::$hitsTableRebuildScheduled) {
100 if ($this->isHitsTableRebuildLocked()) {
101 $this->logger->debugMessage(__FUNCTION__ . " skipping scheduling because another rebuild is already running.");
102 $this->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'running', 86400);
103 return;
104 }
105
106 $rawScheduledFlag = $this->getRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG);
107 $lastScheduled = is_scalar($rawScheduledFlag) ? (int)$rawScheduledFlag : 0;
108 if ($lastScheduled > 0 && (time() - $lastScheduled) < self::HITS_TABLE_SCHEDULE_COOLDOWN_SECONDS) {
109 $this->logger->debugMessage(__FUNCTION__ . " skipping scheduling due to cooldown.");
110 $this->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'cooldown', 86400);
111 return;
112 }
113
114 self::$hitsTableRebuildScheduled = true;
115 $this->setRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG, time(), 86400);
116 $this->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'scheduled', 86400);
117 if ($this->shouldScheduleHitsTableRebuildViaCron()) {
118 $this->logger->debugMessage(__FUNCTION__ . " scheduling hits table rebuild via WP-Cron.");
119 if (function_exists('wp_schedule_single_event')) {
120 wp_schedule_single_event(time() + 5, 'abj404_updateLogsHitsTableAction');
121 }
122 return;
123 }
124
125 $this->logger->debugMessage(__FUNCTION__ . " scheduling hits table rebuild for shutdown hook.");
126 add_action('shutdown', function(): void { $this->createRedirectsForViewHitsTable(); });
127 }
128 }
129
130 /** @return bool */
131 private function shouldScheduleHitsTableRebuildViaCron(): bool {
132 if (function_exists('wp_doing_ajax') && wp_doing_ajax()) {
133 return true;
134 }
135 $scriptName = isset($_SERVER['SCRIPT_NAME']) && is_string($_SERVER['SCRIPT_NAME'])
136 ? $_SERVER['SCRIPT_NAME'] : '';
137 if ($scriptName !== '' && basename($scriptName) === 'admin-ajax.php') {
138 return true;
139 }
140 $pagenow = isset($GLOBALS['pagenow']) && is_string($GLOBALS['pagenow'])
141 ? $GLOBALS['pagenow'] : '';
142 return $pagenow === 'admin-ajax.php';
143 }
144
145 private function getHitsTableRebuildLockOptionName(): string {
146 return $this->getLowercasePrefix() . 'abj404_logs_hits_rebuild_lock';
147 }
148
149 /** @return bool */
150 private function isHitsTableRebuildLocked(): bool {
151 if (!function_exists('get_option')) {
152 return false;
153 }
154 $lockValue = get_option($this->getHitsTableRebuildLockOptionName(), false);
155 if ($lockValue === false || $lockValue === null || $lockValue === '') {
156 return false;
157 }
158 // Defensive: if lock is corrupted (non-numeric), clear it so rebuilds can resume.
159 if (!is_numeric($lockValue)) {
160 if (function_exists('delete_option')) {
161 delete_option($this->getHitsTableRebuildLockOptionName());
162 }
163 return false;
164 }
165 $lockTimestamp = (int)$lockValue;
166 if ($lockTimestamp > 0 && (time() - $lockTimestamp) > self::HITS_TABLE_REBUILD_LOCK_TTL_SECONDS) {
167 if (function_exists('delete_option')) {
168 delete_option($this->getHitsTableRebuildLockOptionName());
169 }
170 return false;
171 }
172 return true;
173 }
174
175 /** @return int|null */
176 function getLogsHitsTableLastCheckedAt() {
177 $rawTsFlag = $this->getRuntimeFlag(self::HITS_TABLE_LAST_CHECKED_FLAG);
178 $ts = is_scalar($rawTsFlag) ? (int)$rawTsFlag : 0;
179 return $ts > 0 ? $ts : null;
180 }
181
182 /** @return int|null */
183 function getLogsHitsTableLastScheduledAt() {
184 $rawTsFlag2 = $this->getRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG);
185 $ts = is_scalar($rawTsFlag2) ? (int)$rawTsFlag2 : 0;
186 return $ts > 0 ? $ts : null;
187 }
188
189 /** @return string */
190 function getLogsHitsTableLastDecision(): string {
191 $v = $this->getRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG);
192 return is_string($v) ? $v : '';
193 }
194
195 /** @return bool */
196 private function acquireHitsTableRebuildLock(): bool {
197 if (!function_exists('add_option')) {
198 return true;
199 }
200 if ($this->isHitsTableRebuildLocked()) {
201 return false;
202 }
203 return (bool)add_option(
204 $this->getHitsTableRebuildLockOptionName(),
205 (string)time(),
206 '',
207 false
208 );
209 }
210
211 /** @return void */
212 private function releaseHitsTableRebuildLock(): void {
213 if (function_exists('delete_option')) {
214 delete_option($this->getHitsTableRebuildLockOptionName());
215 }
216 }
217
218 /** @return bool */
219 private function logsHitsTableExistsViaShowTables(): bool {
220 global $wpdb;
221 if (!isset($wpdb) || !method_exists($wpdb, 'prepare')) {
222 return false;
223 }
224 $tableName = $this->doTableNameReplacements('{wp_abj404_logs_hits}');
225 /** @var wpdb $wpdb */
226 // DAO-bypass-approved: $wpdb->prepare is read-only string formatting; the resulting SQL is executed below via queryAndGetResults
227 $showTablesQuery = $wpdb->prepare("SHOW TABLES LIKE %s", $tableName);
228 if ($showTablesQuery === null) {
229 return false;
230 }
231 $fallback = $this->queryAndGetResults($showTablesQuery, array('log_errors' => false));
232 if (empty($fallback['rows'])) {
233 return false;
234 }
235 $fbRows = is_array($fallback['rows']) ? $fallback['rows'] : array();
236 $firstRow = isset($fbRows[0]) ? $fbRows[0] : null;
237 if (!is_array($firstRow)) {
238 return false;
239 }
240 $value = reset($firstRow);
241 return ((string)$value === (string)$tableName);
242 }
243
244 /**
245 * Check if the logs_hits table exists.
246 * Used to verify table was created before using it in queries.
247 * @return bool
248 */
249 function logsHitsTableExists() {
250 $query = "SELECT 1 FROM information_schema.tables WHERE table_name = '{wp_abj404_logs_hits}' AND table_schema = DATABASE() LIMIT 1";
251 $query = $this->doTableNameReplacements($query);
252 $results = $this->queryAndGetResults($query);
253 if ($results['rows'] != null && !empty($results['rows'])) {
254 return true;
255 }
256 if (!empty($results['last_error'])) {
257 // Some hosts restrict information_schema access; fall back to SHOW TABLES.
258 return $this->logsHitsTableExistsViaShowTables();
259 }
260 return false;
261 }
262
263 /**
264 * Get the maximum log ID from the logs table.
265 *
266 * Used to detect if logs have changed since the hits table was last built.
267 * O(1) query using primary key index.
268 *
269 * @return int Maximum log ID, or 0 if table is empty
270 */
271 function getMaxLogId() {
272 $query = "SELECT MAX(id) FROM {wp_abj404_logsv2}";
273 $query = $this->doTableNameReplacements($query);
274 $results = $this->queryAndGetResults($query);
275
276 $resultRows = is_array($results['rows']) ? $results['rows'] : array();
277 if (empty($resultRows)) {
278 return 0;
279 }
280
281 $row = $resultRows[0];
282 // Handle both object and array results
283 $maxId = is_array($row) ? array_values($row)[0] : (array_values((array)$row)[0] ?? 0);
284 return (int)($maxId ?? 0);
285 }
286
287 /** @return int */
288 function getMinLogId() {
289 $query = "SELECT MIN(id) FROM {wp_abj404_logsv2}";
290 $query = $this->doTableNameReplacements($query);
291 $results = $this->queryAndGetResults($query);
292
293 $resultRows = is_array($results['rows']) ? $results['rows'] : array();
294 if (empty($resultRows)) {
295 return 0;
296 }
297
298 $row = $resultRows[0];
299 $minId = is_array($row) ? array_values($row)[0] : (array_values((array)$row)[0] ?? 0);
300 return is_numeric($minId) ? (int)$minId : 0;
301 }
302
303 /**
304 * Get the stored max log ID from the hits table comment.
305 *
306 * Comment format: "elapsed_time|max_log_id" (e.g., "0.35|12345")
307 *
308 * @return int Stored max log ID, or 0 if not found
309 */
310 function getStoredMaxLogId() {
311 $query = "SELECT table_comment FROM information_schema.tables WHERE table_name = '{wp_abj404_logs_hits}' AND table_schema = DATABASE()";
312 $query = $this->doTableNameReplacements($query);
313 $results = $this->queryAndGetResults($query);
314
315 $storedRows = is_array($results['rows']) ? $results['rows'] : array();
316 if (empty($storedRows)) {
317 if (!empty($results['last_error'])) {
318 $statusRow = $this->getLogsHitsTableStatusRow();
319 $commentFromStatus = $statusRow['comment'] ?? '';
320 if ($commentFromStatus !== '') {
321 $parts = explode('|', is_string($commentFromStatus) ? $commentFromStatus : '');
322 if (count($parts) >= 2) {
323 return (int)$parts[1];
324 }
325 }
326 }
327 return 0;
328 }
329
330 $row = is_array($storedRows[0] ?? null) ? $storedRows[0] : array();
331 $row = array_change_key_case($row);
332 $comment = $row['table_comment'] ?? '';
333
334 // Parse comment format: "elapsed_time|max_log_id"
335 $parts = explode('|', is_string($comment) ? $comment : '');
336 if (count($parts) >= 2) {
337 return (int)$parts[1];
338 }
339
340 // Old format (just elapsed time) or empty - treat as needing rebuild
341 return 0;
342 }
343 }
344