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 / diagnostics / StrandedRequestLedger.php

StrandedRequestLedger.php in 404 Solution trunk, at includes/diagnostics/StrandedRequestLedger.php

232 lines 9.5 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 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