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 / redirects / RedirectDeadDestinationChecker.php

RedirectDeadDestinationChecker.php in 404 Solution trunk, at includes/redirects/RedirectDeadDestinationChecker.php

266 lines 10.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 * Checks whether specific redirects point at a dead destination, i.e. a
9 * destination URL that shows recent failed hits in the logs_hits rollup.
10 *
11 * Unlike the former global precompute (which scanned every redirect into a
12 * transient), this checker is bounded by the caller's id list: it only ever
13 * inspects the redirect ids it is handed, so the read never loads more than
14 * the caller's working set.
15 */
16 class ABJ_404_Solution_RedirectDeadDestinationChecker {
17
18 /** @var ABJ_404_Solution_DatabaseCore */
19 private $dbCore;
20
21 /** @var ABJ_404_Solution_Logging */
22 private $logger;
23
24 /**
25 * Per-request memo of rollup readiness (table present AND failed_hits column).
26 * Invariant within a request, so the two schema probes run at most once even
27 * when matching evaluates a redirect across several URL-normalization passes.
28 *
29 * @var bool|null
30 */
31 private $rollupReady = null;
32
33 /**
34 * Per-request memo of resolved ids: id => is its destination dead. Lets the
35 * repeated matching passes for the same redirect avoid re-querying.
36 *
37 * @var array<int, bool>
38 */
39 private $deadByIdMemo = array();
40
41 /**
42 * @param ABJ_404_Solution_DatabaseCore $dbCore
43 * @param ABJ_404_Solution_Logging $logger
44 */
45 public function __construct(
46 ABJ_404_Solution_DatabaseCore $dbCore,
47 ABJ_404_Solution_Logging $logger
48 ) {
49 $this->dbCore = $dbCore;
50 $this->logger = $logger;
51 }
52
53 /**
54 * Returns the subset of the given redirect ids whose destination URL shows
55 * recent failed hits (i.e. the redirect points at a dead page). Bounded by
56 * the caller's id list: the Page Redirects tab passes the ids it is
57 * rendering (~25), and live redirect matching passes the single redirect
58 * being evaluated, so the read never loads more than the caller's working
59 * set. Fails open (returns empty) when the logs_hits rollup is absent or the
60 * query times out, so a redirect is never blocked by an infrastructure gap.
61 *
62 * @param array<int, int|string> $redirectIds
63 * @return array<int, string> the subset of $redirectIds that are dead, as strings
64 */
65 public function findDeadDestinationIds(array $redirectIds): array {
66 $ids = array();
67 foreach ($redirectIds as $candidate) {
68 if (is_scalar($candidate)) {
69 $intId = (int) $candidate;
70 if ($intId > 0) {
71 $ids[$intId] = $intId;
72 }
73 }
74 }
75 if (empty($ids)) {
76 return array();
77 }
78
79 // Serve ids already resolved this request from the memo; only query the rest.
80 $deadIds = array();
81 $toQuery = array();
82 foreach ($ids as $intId) {
83 if (array_key_exists($intId, $this->deadByIdMemo)) {
84 if ($this->deadByIdMemo[$intId]) {
85 $deadIds[] = (string) $intId;
86 }
87 } else {
88 $toQuery[$intId] = $intId;
89 }
90 }
91 if (empty($toQuery)) {
92 return $deadIds;
93 }
94
95 if (!$this->rollupReady()) {
96 // Rollup absent: cannot determine. Do NOT memo as "not dead" -- the
97 // rollup may be rebuilt later in the request lifecycle.
98 return $deadIds;
99 }
100
101 $freshDead = $this->queryDeadDestinationIds($toQuery);
102 if ($freshDead === null) {
103 // Fail open (timeout/error/throwable): never block a redirect, and do
104 // NOT memo -- the answer is unknown, not "not dead".
105 return $deadIds;
106 }
107
108 // Resolve every queried id (dead or not) so repeated matching passes in
109 // the same request never re-query.
110 $freshDeadSet = array();
111 foreach ($freshDead as $deadIdStr) {
112 $freshDeadSet[(int) $deadIdStr] = true;
113 }
114 foreach ($toQuery as $intId) {
115 $isDead = isset($freshDeadSet[$intId]);
116 $this->deadByIdMemo[$intId] = $isDead;
117 if ($isDead) {
118 $deadIds[] = (string) $intId;
119 }
120 }
121 return $deadIds;
122 }
123
124 /**
125 * Runs the bounded dead-destination read for the given (already-sanitized,
126 * positive-int) redirect ids. Returns the dead-id strings on success, or null
127 * when the read could not complete (timeout, DAO error, or a rethrown
128 * Throwable) -- this method moved from daily cron to request time, so a DB
129 * fault must fail open here and never propagate into frontend redirect
130 * matching. queryAndGetResults already logs the underlying error.
131 *
132 * @param array<int, int> $ids
133 * @return array<int, string>|null
134 */
135 private function queryDeadDestinationIds(array $ids): ?array {
136 $idList = implode(',', array_map('intval', array_values($ids)));
137 $logsHitsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}');
138 $destinationUrl = "CONCAT('/', TRIM(BOTH '/' FROM r.final_dest))";
139 $comparableDestinationUrl = $this->dbCore->collationHelper()->coerceExpressionToColumnCollation(
140 $destinationUrl,
141 array('table' => $logsHitsTable, 'column' => 'requested_url')
142 );
143 // allow-unbounded-select: bounded by the caller's r.id IN (...) working set -- the Page Redirects tab passes the page's row ids, live matching passes the single matched redirect id
144 $sql = "SELECT DISTINCT r.id
145 FROM {wp_abj404_redirects} r
146 INNER JOIN {wp_abj404_logs_hits} h
147 ON h.requested_url = " . $comparableDestinationUrl . "
148 AND BINARY h.requested_url = BINARY CONCAT('/', TRIM(BOTH '/' FROM r.final_dest))
149 WHERE r.id IN (" . $idList . ")
150 AND h.last_used > %d
151 AND h.failed_hits > 0
152 AND r.disabled = 0
153 AND r.final_dest != ''
154 AND r.final_dest != '0'";
155 try {
156 $sql = $this->dbCore->doTableNameReplacements($sql);
157 $result = $this->dbCore->queryAndGetResults($sql, array(
158 'query_params' => array(abj_clock()->now() - 7 * 86400),
159 'timeout' => 5,
160 ));
161 } catch (Throwable $e) {
162 $this->logger->debugMessage(__CLASS__ . '/' . __FUNCTION__
163 . ': dead-destination read failed open (' . $e->getMessage() . '); no redirect suspended.');
164 return null;
165 }
166
167 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
168 $this->logger->debugMessage(__CLASS__ . '/' . __FUNCTION__
169 . ': dead-destination read timed out or errored; failing open, no redirect suspended.');
170 return null;
171 }
172
173 $deadIds = array();
174 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
175 foreach ($rows as $row) {
176 $value = $this->extractIdValue($row);
177 if ($value !== null) {
178 $deadIds[] = $value;
179 }
180 }
181 return $deadIds;
182 }
183
184 /**
185 * Is the logs_hits rollup usable (table present AND failed_hits column)?
186 * Memoized for the request. When not ready, schedules a rebuild once (the
187 * rollup's own lifecycle in LogsHitsRollupService also schedules rebuilds
188 * from the logging and admin paths, so this is an opportunistic top-up, not
189 * the primary maintainer). The probes are wrapped so a schema-query throwable
190 * fails open rather than breaking a frontend redirect.
191 *
192 * @return bool
193 */
194 private function rollupReady(): bool {
195 if ($this->rollupReady !== null) {
196 return $this->rollupReady;
197 }
198
199 try {
200 $hitsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}');
201 $ready = $this->dbCore->tableNameResolver()->tableExists($hitsTable)
202 && $this->logsHitsHasFailedHitsColumn();
203 } catch (Throwable $e) {
204 $this->logger->debugMessage(__CLASS__ . '/' . __FUNCTION__
205 . ': rollup readiness probe failed open (' . $e->getMessage() . ').');
206 $ready = false;
207 }
208
209 if (!$ready) {
210 // Scheduling a rebuild is opportunistic and must also fail open: a
211 // throwable from service resolution or scheduling must never escape
212 // into frontend redirect matching.
213 try {
214 /** @var ABJ_404_Solution_LogsRepository|null $logsRepo */
215 $logsRepo = abj_service('logs_repository');
216 if ($logsRepo !== null) {
217 $logsRepo->scheduleHitsTableRebuild();
218 }
219 } catch (Throwable $e) {
220 $this->logger->debugMessage(__CLASS__ . '/' . __FUNCTION__
221 . ': rollup rebuild scheduling failed open (' . $e->getMessage() . ').');
222 }
223 }
224
225 $this->rollupReady = $ready;
226 return $ready;
227 }
228
229 /**
230 * @param mixed $row
231 * @return string|null
232 */
233 private function extractIdValue($row): ?string {
234 if (is_array($row)) {
235 $value = $row['id'] ?? reset($row);
236 } elseif (is_object($row)) {
237 $value = $row->id ?? null;
238 } else {
239 $value = $row;
240 }
241
242 if (is_scalar($value) && (string)$value !== '') {
243 return (string)$value;
244 }
245
246 return null;
247 }
248
249 private function logsHitsHasFailedHitsColumn(): bool {
250 $tableName = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}');
251 $sql = "SELECT 1 FROM information_schema.columns "
252 . "WHERE table_schema = DATABASE() "
253 . "AND table_name = %s "
254 . "AND column_name = 'failed_hits' LIMIT 1";
255 $result = $this->dbCore->queryAndGetResults($sql, array(
256 'query_params' => array($tableName),
257 'log_errors' => false,
258 ));
259 if (!empty($result['last_error'])) {
260 return false;
261 }
262 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
263 return !empty($rows);
264 }
265 }
266