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 / view-build / AdminViewReadCoordinator.php

AdminViewReadCoordinator.php in 404 Solution trunk, at includes/view-build/AdminViewReadCoordinator.php

318 lines 13.7 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 * Coordinates admin redirects/captured table row and count reads.
9 *
10 * Serves the live single-table read off wp_abj404_redirects (Denorm Step 3b):
11 * the ordered/filtered page is fetched via ViewQueryBuilder and the four derived
12 * columns are resolved live per visible row by RedirectsViewLiveResolver. Owns
13 * per-request count memoization, read-outcome classification, and failure
14 * diagnostics for the public getRedirectsForView() / getRedirectsForViewCount()
15 * facade methods. No staged view_done materialization, no snapshot result cache.
16 */
17 class ABJ_404_Solution_AdminViewReadCoordinator {
18
19 /** @var ABJ_404_Solution_DatabaseCore */
20 private $dbCore;
21
22 /** @var ABJ_404_Solution_ViewQueryBuilder */
23 private $queryBuilder;
24
25 /** @var ABJ_404_Solution_ViewDiagnostics */
26 private $diagnostics;
27
28 /** @var ABJ_404_Solution_ViewCacheInvalidator */
29 private $cacheInvalidator;
30
31 /** @var ABJ_404_Solution_RedirectsViewLiveResolver */
32 private $liveResolver;
33
34 /** @var ABJ_404_Solution_Logging */
35 private $logger;
36
37 /** @var array<string, int> */
38 private $redirectsForViewCountRequestCache = array();
39
40 /** @var ABJ_404_Solution_ViewReadOutcome Classifier for the last row-read outcome (i455). */
41 private $lastReadOutcome;
42
43 /**
44 * @param ABJ_404_Solution_DatabaseCore $dbCore
45 * @param ABJ_404_Solution_ViewQueryBuilder $queryBuilder
46 * @param ABJ_404_Solution_ViewDiagnostics $diagnostics
47 * @param ABJ_404_Solution_ViewCacheInvalidator $cacheInvalidator
48 * @param ABJ_404_Solution_RedirectsViewLiveResolver $liveResolver
49 * @param ABJ_404_Solution_Logging $logger
50 */
51 public function __construct(
52 ABJ_404_Solution_DatabaseCore $dbCore,
53 ABJ_404_Solution_ViewQueryBuilder $queryBuilder,
54 ABJ_404_Solution_ViewDiagnostics $diagnostics,
55 ABJ_404_Solution_ViewCacheInvalidator $cacheInvalidator,
56 ABJ_404_Solution_RedirectsViewLiveResolver $liveResolver,
57 $logger
58 ) {
59 $this->dbCore = $dbCore;
60 $this->queryBuilder = $queryBuilder;
61 $this->diagnostics = $diagnostics;
62 $this->cacheInvalidator = $cacheInvalidator;
63 $this->liveResolver = $liveResolver;
64 $this->logger = $logger;
65 $this->lastReadOutcome = new ABJ_404_Solution_ViewReadOutcome();
66 }
67
68 /**
69 * Single-table redirects read for one page (Denorm Step 3b): fetch the
70 * ordered/filtered page off wp_abj404_redirects, then resolve the visible
71 * rows' derived/display values live and write the four denorm columns back.
72 * This is the live read path that replaced the staged view_done read; it is
73 * the single source of truth for the live single-table read (ViewReadService
74 * delegates its readRedirectsSingleTable() here).
75 *
76 * The derived columns are still READ (and live-resolved for display) when
77 * present, but the live write-back can be suppressed per-request via the
78 * `_abj404_suppress_denorm_writeback` tableOptions flag: a change-detection
79 * probe (the detect-only signature poll) resolves values to compute its
80 * signature but must not mutate rows. The full-render path leaves the flag
81 * unset, so it still persists fresh values (the freshness mechanism).
82 *
83 * @param string $sub
84 * @param array<string, mixed> $tableOptions
85 * @return array<int, array<string, mixed>>
86 */
87 public function readRedirectsSingleTable(string $sub, array $tableOptions): array {
88 $readiness = $this->liveResolver->schemaReadiness();
89 $derivedPresent = $readiness->derivedColumnsPresent();
90 // Tell the query builder whether each narrow sort key is SAFE to ORDER BY,
91 // index-ordered. The single authority is RedirectsViewLiveResolver::
92 // sortKeyReadyForColumn (column exists AND its composite indexes exist AND
93 // the legacy-row drain latch is set). The header UI consults the same
94 // predicate via ViewReadService::isSortReadyForOrderby, so the sort the
95 // query refuses to order by is exactly the one the header disables. When a
96 // key is not ready the query builder serves the safe default instead of a
97 // wide-column filesort that, on a large captured table, can exceed a shared
98 // host's max_statement_time. Same _abj404_* option convention as the
99 // timeout / suppress-writeback flags.
100 $tableOptions['_abj404_dest_sort_key_present'] = $readiness->sortKeyReadyForColumn('dest_sort_key');
101 $tableOptions['_abj404_url_sort_key_present'] = $readiness->sortKeyReadyForColumn('url_sort_key');
102 $rows = $this->queryBuilder->readRedirectsSingleTable($sub, $tableOptions, $derivedPresent);
103 $persist = $derivedPresent && empty($tableOptions['_abj404_suppress_denorm_writeback']);
104 return $this->liveResolver->resolveAndPersistVisibleRows($rows, $persist);
105 }
106
107 /**
108 * Single-table filtered count against wp_abj404_redirects (Denorm Step 3b).
109 *
110 * @param string $sub
111 * @param array<string, mixed> $tableOptions
112 * @return int
113 */
114 public function countRedirectsSingleTable(string $sub, array $tableOptions): int {
115 return $this->queryBuilder->countRedirectsSingleTable($sub, $tableOptions,
116 $this->liveResolver->schemaReadiness()->derivedColumnsPresent());
117 }
118
119 /**
120 * @param string $sub
121 * @param array<string, mixed> $tableOptions
122 * @return array<int|string, mixed>
123 */
124 public function getRedirectsForView($sub, $tableOptions) {
125 $tableOptionsArray = is_array($tableOptions) ? $tableOptions : array();
126 $throwOnQueryError = !empty($tableOptionsArray['_abj404_throw_on_view_query_error']);
127
128 try {
129 $rows = $this->readRedirectsSingleTable((string)$sub, $tableOptionsArray);
130 } catch (Throwable $e) {
131 $this->lastReadOutcome->markErrored();
132 if ($throwOnQueryError) {
133 $failureMarker = '/* single-table: ' . $e->getMessage() . ' */';
134 $diagnostics = $this->diagnostics->captureViewQueryFailureDiagnostics(
135 (string)$sub,
136 $failureMarker,
137 $tableOptionsArray,
138 array('last_error' => $e->getMessage(), 'timed_out' => false)
139 );
140 $diagnostics['failed_query_label'] = 'getRedirectsForView';
141 $diagnostics['staged_error'] = $e->getMessage();
142 $message = 'getRedirectsForView failed; last_error=' . $e->getMessage()
143 . '; timed_out=false; sql_source=' . $failureMarker;
144 throw new ABJ_404_Solution_ViewQueryFailureException($message, $diagnostics);
145 }
146 $this->logger->errorMessage('[single-table] getRedirectsForView failed: ' . $e->getMessage(),
147 $e instanceof \Exception ? $e : null);
148 return array();
149 }
150
151 $this->logger->debugMessage(sprintf(
152 '[single-table] getRedirectsForView returned %d rows for page %s',
153 count($rows),
154 (string)$sub
155 ));
156 $this->finalizeReadStatusForRows($rows, (string)$sub, $tableOptionsArray);
157
158 return $rows;
159 }
160
161 /**
162 * Record how to read the last getRedirectsForView() result (i455). The
163 * live source count is probed lazily, only for empty rows.
164 *
165 * @param array<int|string, mixed> $rows
166 * @param string $sub
167 * @param array<string, mixed> $tableOptions
168 * @return void
169 */
170 private function finalizeReadStatusForRows(array $rows, string $sub, array $tableOptions): void {
171 $this->lastReadOutcome->classifyRows($rows, $tableOptions, function () use ($sub, $tableOptions): int {
172 try {
173 return $this->getRedirectsForViewCount($sub, $tableOptions);
174 } catch (Throwable $e) {
175 $this->logger->debugMessage('[single-table] live-count probe for empty read failed: ' . $e->getMessage());
176 return -1;
177 }
178 });
179 }
180
181 /**
182 * Whether the last getRedirectsForView() result is NOT a trustworthy empty
183 * listing (errored/stale-empty). See ABJ_404_Solution_ViewReadOutcome.
184 *
185 * @return bool
186 */
187 public function lastRedirectsViewReadWasIncomplete(): bool {
188 return $this->lastReadOutcome->wasIncomplete();
189 }
190
191 /**
192 * @param string $sub
193 * @param array<string, mixed> $tableOptions
194 * @return int Negative when the count query was incomplete or unavailable.
195 */
196 public function getRedirectsForViewCount(string $sub, array $tableOptions): int {
197 $queryTimeout = isset($tableOptions['_abj404_query_timeout']) && is_numeric($tableOptions['_abj404_query_timeout'])
198 ? max(1, intval($tableOptions['_abj404_query_timeout'])) : 0;
199 $throwOnQueryError = !empty($tableOptions['_abj404_throw_on_view_query_error']);
200 $requestCountCacheKey = (string)$sub . '|' . md5(serialize($tableOptions));
201
202 if (array_key_exists($requestCountCacheKey, $this->redirectsForViewCountRequestCache)) {
203 return intval($this->redirectsForViewCountRequestCache[$requestCountCacheKey]);
204 }
205
206 $rawFilterText = is_string($tableOptions['filterText'] ?? null) ? $tableOptions['filterText'] : '';
207 if ($rawFilterText !== '') {
208 return $this->getFilteredViewCount($sub, $tableOptions, $throwOnQueryError, $requestCountCacheKey);
209 }
210
211 return $this->getUnfilteredViewCount($sub, $tableOptions, $queryTimeout, $throwOnQueryError, $requestCountCacheKey);
212 }
213
214 /**
215 * @param string $sub
216 * @param array<string, mixed> $tableOptions
217 * @param bool $throwOnQueryError
218 * @param string $requestCountCacheKey
219 * @return int
220 */
221 private function getFilteredViewCount(
222 string $sub,
223 array $tableOptions,
224 bool $throwOnQueryError,
225 string $requestCountCacheKey
226 ): int {
227 try {
228 $countValue = $this->countRedirectsSingleTable((string)$sub, $tableOptions);
229 $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = $countValue;
230 return $countValue;
231 } catch (Throwable $e) {
232 return $this->handleFilteredCountFailure($sub, $tableOptions, $throwOnQueryError, $requestCountCacheKey, $e);
233 }
234 }
235
236 /**
237 * @param string $sub
238 * @param array<string, mixed> $tableOptions
239 * @param bool $throwOnQueryError
240 * @param string $requestCountCacheKey
241 * @param Throwable $e
242 * @return int
243 */
244 private function handleFilteredCountFailure(
245 string $sub,
246 array $tableOptions,
247 bool $throwOnQueryError,
248 string $requestCountCacheKey,
249 Throwable $e
250 ): int {
251 if ($throwOnQueryError) {
252 $failureMarker = '/* single-table-count: ' . $e->getMessage() . ' */';
253 $diagnostics = $this->diagnostics->captureViewQueryFailureDiagnostics(
254 (string)$sub,
255 $failureMarker,
256 $tableOptions,
257 array('last_error' => $e->getMessage(), 'timed_out' => false)
258 );
259 $diagnostics['failed_query_label'] = 'getRedirectsForViewCount';
260 $diagnostics['staged_error'] = $e->getMessage();
261 throw new ABJ_404_Solution_ViewQueryFailureException($e->getMessage(), $diagnostics);
262 }
263 $this->logger->errorMessage('[single-table] getRedirectsForViewCount failed: ' . $e->getMessage(),
264 $e instanceof \Exception ? $e : null);
265 $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = -1;
266 return -1;
267 }
268
269 /**
270 * @param string $sub
271 * @param array<string, mixed> $tableOptions
272 * @param int $queryTimeout
273 * @param bool $throwOnQueryError
274 * @param string $requestCountCacheKey
275 * @return int
276 */
277 private function getUnfilteredViewCount(
278 string $sub,
279 array $tableOptions,
280 int $queryTimeout,
281 bool $throwOnQueryError,
282 string $requestCountCacheKey
283 ): int {
284 $query = $this->queryBuilder->getOptimizedRedirectsForViewCountQuery($sub, $tableOptions);
285 $this->cacheInvalidator->setSqlBigSelects();
286 $queryOptions = $queryTimeout > 0 ? array('timeout' => $queryTimeout) : array();
287 $results = $this->dbCore->queryAndGetResults($query, $queryOptions);
288 $lastErrorRaw = $results['last_error'] ?? '';
289 $lastError = is_string($lastErrorRaw) ? $lastErrorRaw : '';
290
291 $queryFailed = !empty($results['timed_out']) || trim($lastError) !== '';
292 if ($queryFailed) {
293 if ($throwOnQueryError) {
294 $message = $this->diagnostics->formatViewQueryFailureMessage('getRedirectsForViewCount', $query, $results);
295 $diagnostics = $this->diagnostics->captureViewQueryFailureDiagnostics($sub, $query, $tableOptions, $results);
296 $diagnostics['failed_query_label'] = 'getRedirectsForViewCount';
297 throw new ABJ_404_Solution_ViewQueryFailureException($message, $diagnostics);
298 }
299 $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = -1;
300 return -1;
301 }
302 $rows = is_array($results['rows']) ? $results['rows'] : array();
303 if (empty($rows) || !is_array($rows[0])) {
304 $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = -1;
305 return -1;
306 }
307 $row = is_array($rows[0] ?? null) ? $rows[0] : array();
308 $rawCount = $row['count'] ?? $row['COUNT(*)'] ?? reset($row);
309 if (!is_numeric($rawCount)) {
310 $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = -1;
311 return -1;
312 }
313 $countValue = intval($rawCount);
314 $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = $countValue;
315 return $countValue;
316 }
317 }
318