| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* The durable account of requests that were never deregistered by their own |
| 9 |
* process -- the workers that died or stalled past any plausible lifetime. |
| 10 |
* |
| 11 |
* ABJ_404_Solution_SameSiteRequestCensus reaps those leftover rows so a dead |
| 12 |
* request stops being counted as a live competitor. Reaping is correct and has |
| 13 |
* to keep happening, but it used to be a silent DELETE that kept only a count, |
| 14 |
* which threw away precisely the evidence the census exists to produce: the |
| 15 |
* longest-stranded worker is both the most diagnostic one and the first one |
| 16 |
* erased. Report 193 survived only because its strands (121-198s) happened to |
| 17 |
* sit UNDER the reap threshold and were caught mid-flight; a longer stall -- |
| 18 |
* the worse bug -- would have left nothing but `stale_reaped: 4`. |
| 19 |
* |
| 20 |
* So a row is promoted into this ledger before it is deleted. What is kept is |
| 21 |
* an ACCOUNT, not a record stream: which lifecycle segment the request was |
| 22 |
* inside when it last managed to write one, how old it had got, and which |
| 23 |
* process held it. That is small enough to ship whole in a support payload, |
| 24 |
* which is the property that matters -- the journals it replaces for this |
| 25 |
* question are byte-capped, rotate against site traffic, and get sampled by a |
| 26 |
* priority pass, so on a busy site the answer can be gone before the admin |
| 27 |
* clicks "send". This is written once per reaped row, bounded, and read back |
| 28 |
* verbatim. |
| 29 |
* |
| 30 |
* Storage is a single non-autoloaded option rather than a file: the payload is |
| 31 |
* assembled from the database anyway, and the uploads directory is the one |
| 32 |
* place a shared host is most likely to have made unwritable. |
| 33 |
*/ |
| 34 |
final class ABJ_404_Solution_StrandedRequestLedger { |
| 35 |
|
| 36 |
/** The option holding the whole ledger. Non-autoloaded; read only on demand. */ |
| 37 |
const OPTION_NAME = 'abj404_stranded_requests'; |
| 38 |
|
| 39 |
/** |
| 40 |
* How many accounts are kept. |
| 41 |
* |
| 42 |
* Small on purpose. This has to fit whole inside a support payload without |
| 43 |
* competing with the journal excerpts for their byte budget, and twenty |
| 44 |
* strands is already far past the point where the reader has the pattern. |
| 45 |
*/ |
| 46 |
const MAX_ENTRIES = 20; |
| 47 |
|
| 48 |
/** |
| 49 |
* How many of the EARLIEST accounts are never evicted. |
| 50 |
* |
| 51 |
* A plain ring keeps the newest and loses the first, which is backwards for |
| 52 |
* this evidence: the first strands on an install happened before retries, |
| 53 |
* warmed caches and an already-degraded host could confound them, so they |
| 54 |
* are the cleanest single account of the failure. The newest matter too -- |
| 55 |
* they are contemporaneous with the click that sent the report -- so the |
| 56 |
* ledger keeps both ends and drops the middle, which is the part that only |
| 57 |
* repeats what the two ends already say. |
| 58 |
*/ |
| 59 |
const RETAINED_EARLIEST = 6; |
| 60 |
|
| 61 |
/** |
| 62 |
* Promote reaped registry rows into the ledger, newest last. Never throws: |
| 63 |
* a census reading must not fail because its own bookkeeping could not be |
| 64 |
* written. |
| 65 |
* |
| 66 |
* Takes loosely-typed decoded registry rows on purpose: the caller is |
| 67 |
* handing over whatever came back out of an options row, and account() |
| 68 |
* below is what decides which of it is usable. A stricter parameter type |
| 69 |
* here would only move that validation to a caller that has no better |
| 70 |
* information than this one does. |
| 71 |
* |
| 72 |
* @param array<int, array<string, mixed>> $entries |
| 73 |
* @return int how many accounts were added. |
| 74 |
*/ |
| 75 |
public static function record(array $entries): int { |
| 76 |
if ($entries === array()) { |
| 77 |
return 0; |
| 78 |
} |
| 79 |
try { |
| 80 |
$existing = self::read(); |
| 81 |
$added = array(); |
| 82 |
foreach ($entries as $entry) { |
| 83 |
$account = self::account($entry); |
| 84 |
if ($account !== null) { |
| 85 |
$added[] = $account; |
| 86 |
} |
| 87 |
} |
| 88 |
if ($added === array()) { |
| 89 |
return 0; |
| 90 |
} |
| 91 |
self::write(self::trim(array_merge($existing, $added))); |
| 92 |
return count($added); |
| 93 |
} catch (Throwable $e) { |
| 94 |
abj404_logPhpFallback('stranded-request-ledger', |
| 95 |
'stranded request record failed (code ' . $e->getCode() . '): ' . $e->getMessage()); |
| 96 |
return 0; |
| 97 |
} |
| 98 |
} |
| 99 |
|
| 100 |
/** |
| 101 |
* Every retained account, oldest first. Never throws; an unreadable or |
| 102 |
* malformed ledger reports as empty rather than propagating into whatever |
| 103 |
* asked for it. |
| 104 |
* |
| 105 |
* @return array<int, array<string, mixed>> |
| 106 |
*/ |
| 107 |
public static function read(): array { |
| 108 |
try { |
| 109 |
if (!function_exists('get_option')) { |
| 110 |
return array(); |
| 111 |
} |
| 112 |
$raw = get_option(self::OPTION_NAME, ''); |
| 113 |
if (!is_string($raw) || $raw === '') { |
| 114 |
return array(); |
| 115 |
} |
| 116 |
$decoded = json_decode($raw, true); |
| 117 |
if (!is_array($decoded)) { |
| 118 |
return array(); |
| 119 |
} |
| 120 |
$entries = array(); |
| 121 |
foreach ($decoded as $entry) { |
| 122 |
if (is_array($entry)) { |
| 123 |
$entries[] = $entry; |
| 124 |
} |
| 125 |
} |
| 126 |
return $entries; |
| 127 |
} catch (Throwable $e) { |
| 128 |
abj404_logPhpFallback('stranded-request-ledger', |
| 129 |
'stranded request read failed (code ' . $e->getCode() . '): ' . $e->getMessage()); |
| 130 |
return array(); |
| 131 |
} |
| 132 |
} |
| 133 |
|
| 134 |
/** Forget every account. For uninstall and for tests that need a clean slate. */ |
| 135 |
public static function clear(): void { |
| 136 |
if (function_exists('delete_option')) { |
| 137 |
delete_option(self::OPTION_NAME); |
| 138 |
} |
| 139 |
} |
| 140 |
|
| 141 |
/** |
| 142 |
* One reaped row as a bounded account, or null when the row carries nothing |
| 143 |
* worth keeping. |
| 144 |
* |
| 145 |
* @param array<string, mixed> $entry |
| 146 |
* @return array<string, mixed>|null |
| 147 |
*/ |
| 148 |
private static function account(array $entry): ?array { |
| 149 |
$pid = isset($entry['pid']) && is_numeric($entry['pid']) ? (int)$entry['pid'] : 0; |
| 150 |
$ageMs = isset($entry['age_ms']) && is_numeric($entry['age_ms']) ? (int)$entry['age_ms'] : 0; |
| 151 |
if ($pid === 0 && $ageMs === 0) { |
| 152 |
return null; |
| 153 |
} |
| 154 |
$phase = isset($entry['phase']) && is_string($entry['phase']) && $entry['phase'] !== '' |
| 155 |
? substr($entry['phase'], 0, 32) |
| 156 |
// A row written before phases existed, or by a request that died |
| 157 |
// before its first transition. Named rather than blank, so it is |
| 158 |
// never read as "reached no phase". |
| 159 |
: 'unrecorded'; |
| 160 |
return array( |
| 161 |
'channel' => isset($entry['channel']) && is_string($entry['channel']) |
| 162 |
? substr($entry['channel'], 0, 16) : '', |
| 163 |
'action' => isset($entry['action']) && is_string($entry['action']) |
| 164 |
? substr($entry['action'], 0, 64) : '', |
| 165 |
'pid' => $pid, |
| 166 |
'phase' => $phase, |
| 167 |
'started_at_ms' => isset($entry['started_at_ms']) && is_numeric($entry['started_at_ms']) |
| 168 |
? (int)$entry['started_at_ms'] : 0, |
| 169 |
'age_ms_at_reap' => $ageMs, |
| 170 |
); |
| 171 |
} |
| 172 |
|
| 173 |
/** |
| 174 |
* Keep both ends and drop the middle. See RETAINED_EARLIEST. |
| 175 |
* |
| 176 |
* @param array<int, array<string, mixed>> $entries |
| 177 |
* @return array<int, array<string, mixed>> |
| 178 |
*/ |
| 179 |
private static function trim(array $entries): array { |
| 180 |
// The gap marker is NOT an account and must never occupy a slot or be |
| 181 |
// re-counted. Folding prior markers back into one running total first |
| 182 |
// is what keeps the ledger at MAX_ENTRIES accounts with exactly one |
| 183 |
// marker, instead of growing by one marker per trim. |
| 184 |
$dropped = 0; |
| 185 |
$accounts = array(); |
| 186 |
foreach ($entries as $entry) { |
| 187 |
if (isset($entry['dropped_middle_accounts'])) { |
| 188 |
// A hand-edited or truncated option can put anything here. A |
| 189 |
// non-numeric marker still means "accounts were dropped", so it |
| 190 |
// is kept as a marker and counted as at least one rather than |
| 191 |
// silently becoming zero. |
| 192 |
$dropped += is_numeric($entry['dropped_middle_accounts']) |
| 193 |
? (int)$entry['dropped_middle_accounts'] : 1; |
| 194 |
continue; |
| 195 |
} |
| 196 |
$accounts[] = $entry; |
| 197 |
} |
| 198 |
|
| 199 |
if (count($accounts) > self::MAX_ENTRIES) { |
| 200 |
$earliest = array_slice($accounts, 0, self::RETAINED_EARLIEST); |
| 201 |
$newest = array_slice($accounts, -(self::MAX_ENTRIES - self::RETAINED_EARLIEST)); |
| 202 |
$dropped += count($accounts) - count($earliest) - count($newest); |
| 203 |
} else { |
| 204 |
$earliest = array_slice($accounts, 0, self::RETAINED_EARLIEST); |
| 205 |
$newest = array_slice($accounts, self::RETAINED_EARLIEST); |
| 206 |
} |
| 207 |
|
| 208 |
if ($dropped === 0) { |
| 209 |
return array_merge($earliest, $newest); |
| 210 |
} |
| 211 |
// The gap is stated in the ledger itself. A reader who cannot see that |
| 212 |
// accounts were dropped would read the two ends as one continuous |
| 213 |
// history, which is the same "looks complete, is 3% of it" failure the |
| 214 |
// journal excerpt summary exists to prevent. |
| 215 |
return array_merge($earliest, array(array('dropped_middle_accounts' => $dropped)), $newest); |
| 216 |
} |
| 217 |
|
| 218 |
/** |
| 219 |
* @param array<int, array<string, mixed>> $entries |
| 220 |
*/ |
| 221 |
private static function write(array $entries): void { |
| 222 |
if (!function_exists('update_option')) { |
| 223 |
return; |
| 224 |
} |
| 225 |
$encoded = json_encode(array_values($entries)); |
| 226 |
if (!is_string($encoded)) { |
| 227 |
return; |
| 228 |
} |
| 229 |
update_option(self::OPTION_NAME, $encoded, false); |
| 230 |
} |
| 231 |
} |
| 232 |
|