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 / feedback / StrandedRequestSupportSection.php

StrandedRequestSupportSection.php in 404 Solution trunk, at includes/feedback/StrandedRequestSupportSection.php

247 lines 10.6 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 * The support-payload section for stranded requests: workers that are still
9 * in flight far past a plausible lifetime, and workers that already were.
10 *
11 * This section exists because of how report 193 was actually read. Four
12 * pagination workers had been stranded 121-198 seconds while their own
13 * handlers had returned `status: complete` in 1.3-4.7s -- the single most
14 * decisive fact in the payload -- and it reached the developer only as a
15 * side effect: the census rides checkpoint records, and those particular
16 * records happened to survive a 48 KiB excerpt chosen by a priority pass over
17 * a 512 KiB journal that rotates against site traffic. Nothing in the feedback
18 * layer read the census at all. On a busier site, or a report sent a few hours
19 * later, the same evidence would simply have been gone, and the follow-up was
20 * going to be asking the user to fetch journal files off their own server
21 * before they rotated -- a race the user loses on a site with real traffic.
22 *
23 * So the finding is composed here instead, from the registry directly: a
24 * bounded, self-describing block that says which requests are stranded, how
25 * long they have been, and which lifecycle segment each one was inside when it
26 * last managed to say so. It reads no journal, so nothing it reports can be
27 * elided or rotated away, and it is small enough to ship whole every time.
28 *
29 * ABJ_404_Solution_SameSiteRequestCensus owns what counts as stranded and what
30 * a reading contains; ABJ_404_Solution_StrandedRequestLedger owns the durable
31 * account of already-reaped rows. This class owns only the payload job: asking
32 * both, and rendering the answer inside a byte budget.
33 */
34 final class ABJ_404_Solution_StrandedRequestSupportSection {
35
36 /**
37 * Hard cap on the rendered block. A bounded number of accounts, each a
38 * handful of scalars, so it is small by construction; the cap keeps it so
39 * regardless of how many strands a busy site accumulated. Reclaimed from
40 * the checkpoint excerpt budget so the section sum stays inside the report
41 * contract -- see ABJ_404_Solution_CheckpointJournalReader::MAX_SUPPORT_EXCERPT_BYTES
42 * and SupportExcerptBudgetContractTest.
43 */
44 const MAX_STRANDED_DIAG_BYTES = 3072;
45
46 /**
47 * How long a request must have been running before it is reported here.
48 *
49 * Well past any healthy request on the instrumented path (report 193's own
50 * handlers completed in 1.3-4.7 seconds) and well under the census reap
51 * threshold, so a strand is reported while its row is still live rather
52 * than only after it has been reaped into the ledger.
53 */
54 const STRANDED_AFTER_MS = 30000;
55
56 /** The one JSON key the block hangs under, so a reader can grep for it. */
57 const STRANDED_DIAG_KEY = 'abj404_stranded_requests';
58
59 /**
60 * The whole section, ready to join into the support payload. Never throws:
61 * a support request is the last thing that may be blocked by its own
62 * diagnostics, so a missing class or an unreadable option degrades to a
63 * stated reason rather than a fatal in the request the admin is waiting on.
64 */
65 public static function compose(): string {
66 if (!class_exists('ABJ_404_Solution_SameSiteCensusReading')
67 || !class_exists('ABJ_404_Solution_StrandedRequestLedger')) {
68 return 'Stranded-request diagnostics unavailable: the census classes could not be'
69 . ' loaded on this install, so in-flight worker state was not read here.';
70 }
71 try {
72 return self::render(self::record());
73 } catch (Throwable $e) {
74 return 'Stranded-request diagnostics could not be computed: '
75 . substr($e->getMessage(), 0, 200);
76 }
77 }
78
79 /**
80 * The finding: currently-stranded requests from the live census, plus the
81 * durable accounts of ones already reaped.
82 *
83 * `census_status` is carried even when it is fine, because "no strands"
84 * and "could not read the census" are opposite findings and a blank would
85 * let a blind spot read as a healthy site.
86 *
87 * @return array<string, mixed>
88 */
89 private static function record(): array {
90 $sample = ABJ_404_Solution_SameSiteCensusReading::sample();
91 $status = isset($sample['status']) && is_string($sample['status'])
92 ? $sample['status'] : 'unknown';
93 $entries = isset($sample['entries']) && is_array($sample['entries'])
94 ? $sample['entries'] : array();
95
96 $stranded = array();
97 foreach ($entries as $entry) {
98 $account = is_array($entry) ? self::strandedAccount($entry) : null;
99 if ($account !== null) {
100 $stranded[] = $account;
101 }
102 }
103
104 $reaped = ABJ_404_Solution_StrandedRequestLedger::read();
105 return array(
106 'census_status' => $status,
107 'census_reason' => isset($sample['reason']) && is_string($sample['reason'])
108 ? $sample['reason'] : '',
109 'in_flight_total' => isset($sample['count']) && is_int($sample['count'])
110 ? $sample['count'] : -1,
111 'stranded_after_ms' => self::STRANDED_AFTER_MS,
112 'stranded_now' => $stranded,
113 'stranded_previously' => $reaped,
114 'phase_meaning' => 'the lifecycle segment the request had ENTERED when it last'
115 . ' recorded one; a request that died inside a segment never records the next',
116 );
117 }
118
119 /**
120 * One census entry as a stranded-request account, or null when the request
121 * is simply still running.
122 *
123 * A request that is merely in flight is not a finding: reporting every one
124 * would bury the strand in ordinary traffic, which is the same
125 * signal-to-noise failure that made the raw journal excerpt unusable.
126 *
127 * Keyed loosely because that is what a census reading actually is: values
128 * decoded out of options rows, whose keys this class must not assume are
129 * present or well-typed. Every field it reads is validated below.
130 *
131 * @param array<array-key, mixed> $entry
132 * @return array<string, mixed>|null
133 */
134 private static function strandedAccount(array $entry): ?array {
135 $ageMs = isset($entry['age_ms']) && is_numeric($entry['age_ms'])
136 ? (int)$entry['age_ms'] : 0;
137 if ($ageMs < self::STRANDED_AFTER_MS) {
138 return null;
139 }
140 return array(
141 'action' => isset($entry['action']) && is_string($entry['action'])
142 ? $entry['action'] : '',
143 'channel' => isset($entry['channel']) && is_string($entry['channel'])
144 ? $entry['channel'] : '',
145 'pid' => isset($entry['pid']) && is_numeric($entry['pid']) ? (int)$entry['pid'] : 0,
146 'age_ms' => $ageMs,
147 'phase' => isset($entry['phase']) && is_string($entry['phase']) && $entry['phase'] !== ''
148 ? $entry['phase'] : 'unrecorded',
149 );
150 }
151
152 /**
153 * The record as a scannable header line plus one JSON record.
154 *
155 * Over-budget input sheds the historical accounts first -- the reducible
156 * detail, since a currently-stranded worker is contemporaneous with the
157 * click that sent the report -- then falls back to the counts and phases
158 * alone, rather than being cut at a byte offset. A record cut mid-JSON is
159 * unreadable by machine and misleading to a human.
160 *
161 * @param array<string, mixed> $record
162 */
163 private static function render(array $record): string {
164 $header = 'Stranded-request diagnostics -- ' . self::summary($record) . " (JSON):\n";
165
166 $withoutHistory = $record;
167 $withoutHistory['stranded_previously'] = 'over_budget';
168
169 $minimal = array(
170 'census_status' => $record['census_status'],
171 'in_flight_total' => $record['in_flight_total'],
172 'stranded_now_count' => count(self::listOf($record, 'stranded_now')),
173 'stranded_now_phases' => self::phaseTally(self::listOf($record, 'stranded_now')),
174 'reduced' => 'over_budget',
175 );
176
177 foreach (array($record, $withoutHistory, $minimal) as $candidate) {
178 $line = json_encode(array(self::STRANDED_DIAG_KEY => $candidate));
179 if (is_string($line)
180 && strlen($header) + strlen($line) <= self::MAX_STRANDED_DIAG_BYTES) {
181 return $header . $line;
182 }
183 }
184 return $header . 'The stranded-request record could not be encoded for this payload.';
185 }
186
187 /**
188 * The one-line version: how many are stranded right now and where they are
189 * stuck, because the phase tally is the finding and the count alone is not.
190 *
191 * @param array<string, mixed> $record
192 */
193 private static function summary(array $record): string {
194 $now = self::listOf($record, 'stranded_now');
195 $previously = self::listOf($record, 'stranded_previously');
196 if ($now === array() && $previously === array()) {
197 return 'census ' . (is_string($record['census_status'] ?? null)
198 ? $record['census_status'] : 'unknown') . ': no stranded requests';
199 }
200 $tally = self::phaseTally($now);
201 $phases = array();
202 foreach ($tally as $phase => $count) {
203 $phases[] = $phase . ' x' . $count;
204 }
205 return count($now) . ' stranded now'
206 . ($phases !== array() ? ' (' . implode(', ', $phases) . ')' : '')
207 . ', ' . count($previously) . ' recorded previously';
208 }
209
210 /**
211 * How many stranded requests sit in each lifecycle segment.
212 *
213 * @param array<int, array<string, mixed>> $entries
214 * @return array<string, int>
215 */
216 private static function phaseTally(array $entries): array {
217 $tally = array();
218 foreach ($entries as $entry) {
219 $phase = is_array($entry) && isset($entry['phase']) && is_string($entry['phase'])
220 ? $entry['phase'] : 'unrecorded';
221 $tally[$phase] = isset($tally[$phase]) ? $tally[$phase] + 1 : 1;
222 }
223 return $tally;
224 }
225
226 /**
227 * One record field as a list, or an empty list when it is absent or was
228 * already shed to fit the budget.
229 *
230 * @param array<string, mixed> $record
231 * @return array<int, array<string, mixed>>
232 */
233 private static function listOf(array $record, string $field): array {
234 $value = $record[$field] ?? null;
235 if (!is_array($value)) {
236 return array();
237 }
238 $entries = array();
239 foreach ($value as $entry) {
240 if (is_array($entry)) {
241 $entries[] = $entry;
242 }
243 }
244 return $entries;
245 }
246 }
247