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

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

130 lines 5.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 * Keeps the cached admin status counts consistent with a redirect mutation.
9 *
10 * Foreground count reads are cache-only: the SUM(CASE) aggregate behind the
11 * tab badges is a full scan of the redirects table, so it was moved to cron.
12 * That left every mutation's own effect invisible to the user who caused it --
13 * trash a captured 404 and the Trash badge kept its old number until a
14 * background recompute landed, which on a host with a cron backlog is minutes.
15 *
16 * The fix is not to re-run the aggregate but to apply the delta the mutation
17 * caused. A mutation is bracketed by two reads of the affected rows'
18 * (status, disabled) distribution; the difference is the exact delta, so
19 * inserts, status changes, trash, restore and deletes all work through the
20 * same path without this class needing to know which one happened. The reads
21 * are scoped to the rows the mutation already touches, never table-wide.
22 *
23 * Any residual drift is corrected by the next full recompute, so a failed
24 * read degrades to the previous invalidate-only behavior rather than to a
25 * wrong number.
26 */
27 class ABJ_404_Solution_StatusCountsMutationSync {
28
29 /** @var ABJ_404_Solution_DatabaseQueryInterface */
30 private $dbCore;
31
32 /** @param ABJ_404_Solution_DatabaseQueryInterface $dbCore */
33 public function __construct($dbCore) {
34 $this->dbCore = $dbCore;
35 }
36
37 /**
38 * The (status, disabled) distribution of the rows a condition selects.
39 * Take this immediately before a mutation and hand it to syncSince().
40 *
41 * @param string $whereClause SQL WHERE body. Plugin-controlled text only:
42 * every caller-supplied value must travel in $params.
43 * @param array<int, mixed> $params Bound parameters for $whereClause.
44 * @return array<int, array{status:int,disabled:int,count:int}>|null Null when the
45 * read failed, which makes the paired sync a no-op.
46 */
47 public function snapshot(string $whereClause, array $params = array()): ?array {
48 $redirectsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_redirects}");
49 $query = "SELECT status, disabled, COUNT(*) as row_count FROM `" . $redirectsTable . "`"
50 . " WHERE " . $whereClause . " GROUP BY status, disabled";
51 $options = array();
52 if ($params !== array()) {
53 $options['query_params'] = $params;
54 }
55 $result = $this->dbCore->queryAndGetResults($query, $options);
56 if (!empty($result['last_error']) || !empty($result['timed_out'])) {
57 return null;
58 }
59
60 $histogram = array();
61 $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array();
62 foreach ($rows as $row) {
63 if (!is_array($row)) {
64 continue;
65 }
66 $histogram[] = array(
67 'status' => isset($row['status']) && is_scalar($row['status']) ? (int)$row['status'] : -1,
68 'disabled' => isset($row['disabled']) && is_scalar($row['disabled']) ? (int)$row['disabled'] : -1,
69 'count' => isset($row['row_count']) && is_scalar($row['row_count']) ? (int)$row['row_count'] : 0,
70 );
71 }
72 return $histogram;
73 }
74
75 /**
76 * Apply the delta between a snapshot and the current state of the same
77 * rows. A no-op when either read failed, so a mutation never publishes a
78 * count it could not derive.
79 *
80 * @param array<int, array{status:int,disabled:int,count:int}>|null $before From snapshot().
81 * @param string $whereClause Same condition the snapshot used.
82 * @param array<int, mixed> $params Same parameters the snapshot used.
83 * @return void
84 */
85 public function syncSince(?array $before, string $whereClause, array $params = array()): void {
86 if ($before === null) {
87 return;
88 }
89 $after = $this->snapshot($whereClause, $params);
90 if ($after === null) {
91 return;
92 }
93 ABJ_404_Solution_StatusCountsRepository::applyDelta(
94 ABJ_404_Solution_StatusCountBuckets::delta($before, $after)
95 );
96 }
97
98 /**
99 * Apply the delta for rows that are simply gone, for a mutation whose
100 * after-state needs no second read (an unconditional DELETE of exactly
101 * the snapshotted set).
102 *
103 * @param array<int, array{status:int,disabled:int,count:int}>|null $removed From snapshot().
104 * @return void
105 */
106 public function syncRemoved(?array $removed): void {
107 if ($removed === null || $removed === array()) {
108 return;
109 }
110 ABJ_404_Solution_StatusCountsRepository::applyDelta(
111 ABJ_404_Solution_StatusCountBuckets::delta($removed, array())
112 );
113 }
114
115 /**
116 * Apply the delta for one freshly inserted row. An insert needs no reads
117 * at all: the row that just landed is the whole delta, which matters on
118 * the frontend capture hot path where an extra query per 404 is not free.
119 *
120 * @param int $status
121 * @param int $disabled
122 * @return void
123 */
124 public function syncInserted(int $status, int $disabled): void {
125 ABJ_404_Solution_StatusCountsRepository::applyDelta(
126 ABJ_404_Solution_StatusCountBuckets::deltaForRows($status, $disabled, 1, 1)
127 );
128 }
129 }
130