| 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 |
|