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 / stats / StatusCountsRepository.php

StatusCountsRepository.php in 404 Solution trunk, at includes/stats/StatusCountsRepository.php

471 lines 18.3 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 * Aggregate "redirects grouped by status" tallies with transient caching.
9 *
10 * Owns the SUM(CASE WHEN ...) aggregation queries that previously lived
11 * inline in ViewReadService:
12 * - Active/manual/auto/regex/trash counts (admin redirects list badges)
13 * - Captured/ignored/later/trash counts (admin captures list badges)
14 * - High-impact captured count (logs-joined; gated by hits table presence)
15 *
16 * Extracted in the i805 ViewReadService decomposition. The cache TTLs and
17 * key names are reused verbatim from ViewReadRuntimeState so existing
18 * invalidation paths continue to delete the same keys.
19 */
20 class ABJ_404_Solution_StatusCountsRepository {
21
22 const CACHE_KEY_REDIRECT_STATUS = ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_REDIRECT_STATUS;
23 const CACHE_KEY_CAPTURED_STATUS = ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_CAPTURED_STATUS;
24 const CACHE_KEY_REDIRECT_STATUS_LAST_KNOWN = ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_REDIRECT_STATUS_LAST_KNOWN;
25 const CACHE_KEY_CAPTURED_STATUS_LAST_KNOWN = ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_CAPTURED_STATUS_LAST_KNOWN;
26 const CACHE_KEY_HIGH_IMPACT_CAPTURED = ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_HIGH_IMPACT_CAPTURED;
27 const CACHE_KEY_HIGH_IMPACT_CAPTURED_LAST_KNOWN = ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_HIGH_IMPACT_CAPTURED_LAST_KNOWN;
28 const STATUS_CACHE_TTL = ABJ_404_Solution_ViewReadRuntimeState::STATUS_CACHE_TTL;
29 const STATUS_LAST_KNOWN_CACHE_TTL = ABJ_404_Solution_ViewReadRuntimeState::STATUS_LAST_KNOWN_CACHE_TTL;
30
31 /**
32 * Query budget for an unattended redirect/captured status recompute. The
33 * deferred foreground backstop passes a smaller one -- see
34 * ABJ_404_Solution_StatusCountsRefreshCoordinator.
35 */
36 const STATUS_QUERY_TIMEOUT_SECONDS = 20;
37
38 /** Query budget for an unattended high-impact recompute (joins the logs rollup). */
39 const HIGH_IMPACT_QUERY_TIMEOUT_SECONDS = 60;
40
41 /** @var callable(string,array<string,mixed>,callable):mixed|null */
42 private static $operationTracer = null;
43
44 /** @var ABJ_404_Solution_DatabaseQueryInterface */
45 private $dbCore;
46
47 /** @var ABJ_404_Solution_LogsRepository */
48 private $logsRepo;
49
50 /** @var ABJ_404_Solution_ViewQueryBuilder */
51 private $queryBuilder;
52
53 /** @var ABJ_404_Solution_TableReadinessGate */
54 private $readiness;
55
56 /**
57 * @param ABJ_404_Solution_DatabaseQueryInterface $dbCore
58 * @param ABJ_404_Solution_LogsRepository $logsRepo
59 * @param ABJ_404_Solution_ViewQueryBuilder $queryBuilder
60 * @param ABJ_404_Solution_TableReadinessGate $readiness
61 */
62 public function __construct(
63 ABJ_404_Solution_DatabaseQueryInterface $dbCore,
64 ABJ_404_Solution_LogsRepository $logsRepo,
65 ABJ_404_Solution_ViewQueryBuilder $queryBuilder,
66 ABJ_404_Solution_TableReadinessGate $readiness
67 ) {
68 $this->dbCore = $dbCore;
69 $this->logsRepo = $logsRepo;
70 $this->queryBuilder = $queryBuilder;
71 $this->readiness = $readiness;
72 }
73
74 /** @param callable(string,array<string,mixed>,callable):mixed|null $tracer */
75 public static function setOperationTracer($tracer): void {
76 self::$operationTracer = $tracer;
77 }
78
79 /**
80 * Read redirect counts without touching the database.
81 *
82 * @return array{counts: array<string, int>, needs_refresh: bool, incomplete: bool}
83 */
84 public function readRedirectStatusCountsCache(): array {
85 return self::trace(
86 'status_cache_read',
87 array('scope' => 'redirects'),
88 function (): array {
89 return $this->readStatusCountsCache(
90 self::CACHE_KEY_REDIRECT_STATUS,
91 self::CACHE_KEY_REDIRECT_STATUS_LAST_KNOWN,
92 'redirect_current',
93 'redirect_last_known'
94 );
95 }
96 );
97 }
98
99 /**
100 * Recompute redirect counts. Called from the cron listener and from the
101 * coordinator's shutdown backstop, which passes a smaller query budget.
102 *
103 * @param int|null $timeoutSeconds Null uses the unattended budget.
104 */
105 public function recomputeRedirectStatusCounts(?int $timeoutSeconds = null): bool {
106 return $this->recomputeScope(
107 ABJ_404_Solution_StatusCountBuckets::SCOPE_REDIRECTS,
108 self::CACHE_KEY_REDIRECT_STATUS,
109 self::CACHE_KEY_REDIRECT_STATUS_LAST_KNOWN,
110 $timeoutSeconds
111 );
112 }
113
114 /**
115 * Read captured counts without touching the database.
116 *
117 * @return array{counts: array<string, int>, needs_refresh: bool, incomplete: bool}
118 */
119 public function readCapturedStatusCountsCache(): array {
120 return self::trace(
121 'status_cache_read',
122 array('scope' => 'captured'),
123 function (): array {
124 return $this->readStatusCountsCache(
125 self::CACHE_KEY_CAPTURED_STATUS,
126 self::CACHE_KEY_CAPTURED_STATUS_LAST_KNOWN,
127 'captured_current',
128 'captured_last_known'
129 );
130 }
131 );
132 }
133
134 /**
135 * Recompute captured counts. Called from the cron listener and from the
136 * coordinator's shutdown backstop, which passes a smaller query budget.
137 *
138 * @param int|null $timeoutSeconds Null uses the unattended budget.
139 */
140 public function recomputeCapturedStatusCounts(?int $timeoutSeconds = null): bool {
141 return $this->recomputeScope(
142 ABJ_404_Solution_StatusCountBuckets::SCOPE_CAPTURED,
143 self::CACHE_KEY_CAPTURED_STATUS,
144 self::CACHE_KEY_CAPTURED_STATUS_LAST_KNOWN,
145 $timeoutSeconds
146 );
147 }
148
149 /**
150 * Run one scope's bucket aggregate and cache it.
151 *
152 * The redirect and captured scopes differ only in which statuses they
153 * cover and what the per-status buckets are called, so both the SELECT
154 * list and the result mapping are generated from
155 * ABJ_404_Solution_StatusCountBuckets. That is deliberate rather than
156 * merely tidy: the incremental delta path applied at mutation time reads
157 * the same definition, and a second hand-written copy of the bucket rules
158 * here could disagree with it without any test noticing (the aggregate
159 * would just quietly overwrite the delta on the next cron tick).
160 *
161 * @param string $scope One of the StatusCountBuckets SCOPE_* constants.
162 * @param string $cacheKey
163 * @param string $lastKnownKey
164 * @param int|null $timeoutSeconds Null uses the unattended budget.
165 */
166 private function recomputeScope(
167 string $scope,
168 string $cacheKey,
169 string $lastKnownKey,
170 ?int $timeoutSeconds
171 ): bool {
172 if ($this->readiness->isKnownAbsent('{wp_abj404_redirects}')) {
173 return false;
174 }
175
176 $query = $this->dbCore->doTableNameReplacements(self::buildScopeAggregateQuery($scope));
177 $result = $this->dbCore->queryAndGetResults(
178 $query,
179 array('timeout' => self::resolveTimeout($timeoutSeconds, self::STATUS_QUERY_TIMEOUT_SECONDS))
180 );
181 $hadError = !empty($result['last_error']) || !empty($result['timed_out']);
182 if ($hadError) {
183 return false;
184 }
185
186 $rows = is_array($result['rows']) ? $result['rows'] : array();
187 $counts = ABJ_404_Solution_StatusCountBuckets::zeroCounts($scope);
188 if (!empty($rows)) {
189 $row = is_array($rows[0] ?? null) ? $rows[0] : array();
190 foreach (array_keys($counts) as $bucket) {
191 $counts[$bucket] = self::scalarToInt($row[$bucket] ?? 0);
192 }
193 }
194
195 set_transient($cacheKey, $counts, self::STATUS_CACHE_TTL);
196 set_transient($lastKnownKey, $counts, self::STATUS_LAST_KNOWN_CACHE_TTL);
197 return true;
198 }
199
200 /**
201 * The SUM(CASE WHEN ...) aggregate for one scope. Every column is aliased
202 * to its bucket name so the result maps straight onto the cached shape.
203 *
204 * @param string $scope One of the StatusCountBuckets SCOPE_* constants.
205 */
206 private static function buildScopeAggregateQuery(string $scope): string {
207 $statusBuckets = ABJ_404_Solution_StatusCountBuckets::bucketsByStatus($scope);
208 $selects = array(
209 "SUM(CASE WHEN disabled = 0 THEN 1 ELSE 0 END) as `"
210 . ABJ_404_Solution_StatusCountBuckets::BUCKET_ALL . "`",
211 );
212 foreach ($statusBuckets as $status => $bucket) {
213 $selects[] = "SUM(CASE WHEN disabled = 0 AND status = " . intval($status)
214 . " THEN 1 ELSE 0 END) as `" . $bucket . "`";
215 }
216 $selects[] = "SUM(CASE WHEN disabled = 1 THEN 1 ELSE 0 END) as `"
217 . ABJ_404_Solution_StatusCountBuckets::BUCKET_TRASH . "`";
218
219 return "SELECT\n " . implode(",\n ", $selects)
220 . "\n FROM {wp_abj404_redirects}"
221 . "\n WHERE status IN ("
222 . implode(', ', array_map('intval', array_keys($statusBuckets))) . ")";
223 }
224
225 /**
226 * Adjust the cached bucket counts by a signed delta instead of recomputing
227 * them.
228 *
229 * Foreground status-count reads are cache-only (the aggregate is a full
230 * scan of the redirects table and is deferred to cron), so without this a
231 * user who trashes a row watches the Trash tab keep its old number until
232 * a background recompute lands minutes later. A mutation knows exactly
233 * which rows it moved between buckets, so it can keep the cache correct
234 * for free.
235 *
236 * Both the current and the last-known keys are adjusted: an invalidation
237 * has usually just demoted the current value to last-known, and the
238 * last-known copy is what a stale read actually serves. Buckets are
239 * clamped at zero, since a negative tally is never a truthful answer even
240 * if a concurrent writer made the delta double-count. Any residual drift
241 * is corrected by the next full recompute.
242 *
243 * Static and dependency-free (pure transient reads and writes, no DB) for
244 * the same reason ViewCacheInvalidator's debounced captured invalidation
245 * is: the mutation paths that need it, including the frontend capture hot
246 * path, must be able to call it without wiring up a repository.
247 *
248 * @param array<string, array<string, int>> $delta scope => bucket => signed delta.
249 * @return void
250 */
251 public static function applyDelta(array $delta): void {
252 foreach ($delta as $scope => $buckets) {
253 if (!is_array($buckets) || count(array_filter($buckets)) === 0) {
254 continue;
255 }
256 foreach (self::cacheKeysForScope((string)$scope) as $key => $ttl) {
257 self::applyDeltaToTransient($key, $ttl, $buckets);
258 }
259 }
260 }
261
262 /**
263 * Where one scope's counts are cached, and for how long.
264 *
265 * @param string $scope
266 * @return array<string, int> cache key => TTL seconds.
267 */
268 private static function cacheKeysForScope(string $scope): array {
269 if ($scope === ABJ_404_Solution_StatusCountBuckets::SCOPE_REDIRECTS) {
270 return array(
271 self::CACHE_KEY_REDIRECT_STATUS => self::STATUS_CACHE_TTL,
272 self::CACHE_KEY_REDIRECT_STATUS_LAST_KNOWN => self::STATUS_LAST_KNOWN_CACHE_TTL,
273 );
274 }
275 if ($scope === ABJ_404_Solution_StatusCountBuckets::SCOPE_CAPTURED) {
276 return array(
277 self::CACHE_KEY_CAPTURED_STATUS => self::STATUS_CACHE_TTL,
278 self::CACHE_KEY_CAPTURED_STATUS_LAST_KNOWN => self::STATUS_LAST_KNOWN_CACHE_TTL,
279 );
280 }
281 return array();
282 }
283
284 /**
285 * Apply bucket deltas to one cached count array, leaving a missing or
286 * non-array cache alone (there is nothing to keep consistent, and
287 * inventing counts from a delta would report a total that was never
288 * measured).
289 *
290 * @param string $key
291 * @param int $ttl
292 * @param array<string, int> $buckets
293 */
294 private static function applyDeltaToTransient(string $key, int $ttl, array $buckets): void {
295 $cached = get_transient($key);
296 if (!is_array($cached)) {
297 return;
298 }
299 foreach ($buckets as $bucket => $change) {
300 if (!is_scalar($change) || (int)$change === 0) {
301 continue;
302 }
303 $current = isset($cached[$bucket]) && is_scalar($cached[$bucket]) ? (int)$cached[$bucket] : 0;
304 $cached[$bucket] = max(0, $current + (int)$change);
305 }
306 // allow-cache-empty: an all-zero-but-shaped count array is a real measured result, not an empty cache.
307 set_transient($key, $cached, $ttl);
308 }
309
310 /**
311 * @return array{counts: array<string, int>, needs_refresh: bool, incomplete: bool}
312 */
313 private function readStatusCountsCache(
314 string $currentKey,
315 string $lastKnownKey,
316 string $currentFamily,
317 string $lastKnownFamily
318 ): array {
319 $current = self::trace(
320 'transient_read',
321 array('family' => $currentFamily, 'expected' => 'array'),
322 static fn() => get_transient($currentKey)
323 );
324 if (is_array($current)) {
325 /** @var array<string, int> $current */
326 return array('counts' => $current, 'needs_refresh' => false, 'incomplete' => false);
327 }
328
329 $lastKnown = self::trace(
330 'transient_read',
331 array('family' => $lastKnownFamily, 'expected' => 'array'),
332 static fn() => get_transient($lastKnownKey)
333 );
334 if (is_array($lastKnown)) {
335 /** @var array<string, int> $lastKnown */
336 return array('counts' => $lastKnown, 'needs_refresh' => true, 'incomplete' => false);
337 }
338
339 return array('counts' => array(), 'needs_refresh' => true, 'incomplete' => true);
340 }
341
342 /**
343 * Read the high-impact count without touching the database.
344 *
345 * @return array{count:?int,needs_refresh:bool}
346 */
347 public function readHighImpactCapturedCountCache(): array {
348 return self::trace(
349 'status_cache_read',
350 array('scope' => 'high_impact'),
351 static function (): array {
352 $current = self::trace(
353 'transient_read',
354 array('family' => 'high_impact_current', 'expected' => 'numeric'),
355 static fn() => get_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED)
356 );
357 if (is_numeric($current)) {
358 return array('count' => intval($current), 'needs_refresh' => false);
359 }
360
361 $lastKnown = self::trace(
362 'transient_read',
363 array('family' => 'high_impact_last_known', 'expected' => 'numeric'),
364 static fn() => get_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED_LAST_KNOWN)
365 );
366 if (is_numeric($lastKnown)) {
367 return array('count' => intval($lastKnown), 'needs_refresh' => true);
368 }
369
370 return array('count' => null, 'needs_refresh' => true);
371 }
372 );
373 }
374
375 /**
376 * Recompute the high-impact count. Called from the cron listener and from
377 * the coordinator's shutdown backstop, which passes a smaller query budget.
378 *
379 * @param int|null $timeoutSeconds Null uses the unattended budget.
380 */
381 public function recomputeHighImpactCapturedCount(?int $timeoutSeconds = null): bool {
382 if ($this->readiness->isKnownAbsent('{wp_abj404_redirects}')) {
383 return false;
384 }
385 if (!$this->logsRepo->logsHitsTableExists()) {
386 $this->logsRepo->scheduleHitsTableRebuild();
387 return false;
388 }
389
390 $query = $this->queryBuilder->buildHighImpactCapturedCountQuery();
391
392 $result = $this->dbCore->queryAndGetResults(
393 $query,
394 array('timeout' => self::resolveTimeout($timeoutSeconds, self::HIGH_IMPACT_QUERY_TIMEOUT_SECONDS))
395 );
396 $timedOut = !empty($result['timed_out']);
397 $hadError = !empty($result['last_error']) || $timedOut;
398 $rows = is_array($result['rows']) ? $result['rows'] : array();
399 $firstRow = (!empty($rows) && is_array($rows[0] ?? null)) ? $rows[0] : array();
400 $count = self::scalarToInt($firstRow['cnt'] ?? 0);
401
402 if ($timedOut) {
403 $this->logsRepo->scheduleHitsTableRebuild();
404 return false;
405 }
406
407 if ($hadError) {
408 return false;
409 }
410
411 if ($count === 0 && $this->isHitsTableEmpty()) {
412 $this->logsRepo->scheduleHitsTableRebuild();
413 return false;
414 }
415
416 set_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED, $count, self::STATUS_CACHE_TTL);
417 set_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED_LAST_KNOWN, $count, self::STATUS_LAST_KNOWN_CACHE_TTL);
418
419 return true;
420 }
421
422 /**
423 * Probe the hits table for "is this rollup empty?" to distinguish a real
424 * zero from a not-yet-rebuilt state.
425 *
426 * @return bool
427 */
428 private function isHitsTableEmpty(): bool {
429 $check = "SELECT 1 FROM {wp_abj404_logs_hits} LIMIT 1";
430 $check = $this->dbCore->doTableNameReplacements($check);
431 $result = $this->dbCore->queryAndGetResults($check);
432 if (!empty($result['last_error']) || !empty($result['timed_out'])) {
433 return false;
434 }
435 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
436 return empty($rows);
437 }
438
439 /**
440 * A caller-supplied budget never exceeds the unattended one: the deferred
441 * foreground path may only ask for LESS time, never more.
442 */
443 private static function resolveTimeout(?int $requested, int $unattended): int {
444 if ($requested === null || $requested < 1) {
445 return $unattended;
446 }
447 return min($requested, $unattended);
448 }
449
450 /**
451 * @param mixed $value
452 * @return int
453 */
454 private static function scalarToInt($value): int {
455 return is_scalar($value) ? intval($value) : 0;
456 }
457
458 /**
459 * @template T
460 * @param array<string,mixed> $fields
461 * @param callable():T $work
462 * @return T
463 */
464 private static function trace(string $operation, array $fields, callable $work) {
465 if (self::$operationTracer === null) {
466 return $work();
467 }
468 return (self::$operationTracer)($operation, $fields, $work);
469 }
470 }
471