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 / SameSiteCensusReading.php

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

276 lines 12.0 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 * One reading of how many PHP requests THIS SITE has in flight, shaped into a
9 * finding small enough to ride every checkpoint record.
10 *
11 * ABJ_404_Solution_SameSiteRequestCensus owns the policy a reading is taken
12 * under -- who is in scope, how long an entry counts as live, and which row
13 * belongs to this request -- and ABJ_404_Solution_SameSiteRequestRegistry owns
14 * the rows. This class owns only what happens between them: splitting the
15 * registry into live requests and leftovers, promoting the leftovers before
16 * they are reaped, and deciding which fields are worth the bytes.
17 *
18 * The dependency runs one way, reading -> policy, and never back. That is what
19 * lets the memo below invalidate itself (see sample()) instead of relying on
20 * every place that changes the census to remember to call a reset -- the
21 * push-based version of that invalidation was a standing invitation to a stale
22 * reading after some future third mutation site forgot.
23 */
24 final class ABJ_404_Solution_SameSiteCensusReading {
25
26 /**
27 * Minimum gap between real readings, in milliseconds.
28 *
29 * Every full checkpoint envelope carries this reading, and a table request
30 * emits roughly 27 of them, so an unconditional query per record would add
31 * ~27 queries to the very request whose worker contention is being
32 * measured -- the observer effect gap G2 raised about the recorder itself.
33 * Consecutive checkpoints during healthy phases are milliseconds apart and
34 * say nothing new; consecutive checkpoints during a STALL are seconds
35 * apart, which is longer than this window, so the resolution that matters
36 * is unaffected. Every reading reports its own age, so a memoized value is
37 * never mistaken for a fresh one.
38 */
39 const SAMPLE_MEMO_MS = 250;
40
41 /** @var array<string, mixed>|null Last reading, reused inside SAMPLE_MEMO_MS. */
42 private static $memoSample = null;
43
44 /** @var int When the memoized reading was taken. */
45 private static $memoTakenAtMs = 0;
46
47 /**
48 * @var string Which census identity the memoized reading was taken under.
49 * A reading describes a population this request is part of, so the moment
50 * this request joins or leaves, an earlier reading is wrong rather than
51 * merely old.
52 */
53 private static $memoOwnEntry = '';
54
55 /**
56 * The current reading.
57 *
58 * Never returns an empty or absent field: an unavailable census reports a
59 * named reason, because "no reading" and "no concurrent requests" are
60 * opposite findings and a blank would let a blind spot read as quiet.
61 *
62 * @return array<string, mixed>
63 */
64 public static function sample(): array {
65 try {
66 $now = ABJ_404_Solution_SameSiteRequestCensus::nowMs();
67 if ($now === null) {
68 return self::unavailable('clock_unavailable', array(
69 'abj_clock()->nowFloat()' => 'unavailable',
70 ));
71 }
72 $ownEntry = ABJ_404_Solution_SameSiteRequestCensus::ownEntryName();
73 if (self::$memoSample !== null && self::$memoOwnEntry === $ownEntry
74 && ($now - self::$memoTakenAtMs) < self::SAMPLE_MEMO_MS
75 && ($now - self::$memoTakenAtMs) >= 0) {
76 $memo = self::$memoSample;
77 $memo['sample_age_ms'] = $now - self::$memoTakenAtMs;
78 return $memo;
79 }
80 $sample = self::readCensus($now, $ownEntry);
81 self::$memoSample = $sample;
82 self::$memoTakenAtMs = $now;
83 self::$memoOwnEntry = $ownEntry;
84 return $sample;
85 } catch (Throwable $e) {
86 self::reportFailure('same-site census sample failed: ' . $e->getMessage());
87 return self::unavailable('sample_exception', array(
88 'SameSiteCensusReading::sample()' => 'exception:' . get_class($e),
89 ));
90 }
91 }
92
93 /**
94 * This reading's contribution to a full checkpoint envelope.
95 *
96 * `same_site_requests` rides EVERY record, because a stall is diagnosed
97 * from where the number was when the record was written; -1 means the
98 * census could not be read, never 0, which would be a finding rather than
99 * an absence. `same_site_census` -- the identities, the scope, the TTL and
100 * the reap counters -- rides only the record whose own write actually took
101 * the reading. Repeating that structure on all ~27 records of a request
102 * would spend the support-excerpt budget that decides how much of a
103 * FAILING session reaches the developer, for bytes that say the same thing
104 * 27 times; the same trade the rusage subset and the reduced high-frequency
105 * envelope were both made for. The two are joined by request id and
106 * timestamp, the keys the rest of the journal is already read by.
107 *
108 * @return array<string, mixed>
109 */
110 public static function checkpointFields(): array {
111 $takenAtBefore = self::$memoTakenAtMs;
112 $sample = self::sample();
113 $fields = array(
114 'same_site_requests' => isset($sample['count']) && is_int($sample['count'])
115 ? $sample['count'] : -1,
116 );
117 if (self::$memoTakenAtMs !== $takenAtBefore) {
118 $fields['same_site_census'] = $sample;
119 }
120 return $fields;
121 }
122
123 /**
124 * Forget the memoized reading, so the next sample() re-reads regardless of
125 * timing.
126 *
127 * Not needed for a census identity change -- sample() detects that itself.
128 * This is the seam for a process that serves several requests in sequence
129 * and has to return this class to the state a freshly started PHP process
130 * is in.
131 */
132 public static function resetSampleMemo(): void {
133 self::$memoSample = null;
134 self::$memoTakenAtMs = 0;
135 self::$memoOwnEntry = '';
136 }
137
138 /**
139 * Split the registry into live requests and leftovers, reap the
140 * leftovers, and report what is left.
141 *
142 * @return array<string, mixed>
143 */
144 private static function readCensus(int $now, string $ownEntry): array {
145 // What this reading costs the request it is measuring, measured
146 // through the same injected clock everything else here uses, so an
147 // observer effect is visible in the evidence rather than argued about.
148 $startedAt = ABJ_404_Solution_SameSiteRequestCensus::nowFloat();
149 $registry = ABJ_404_Solution_SameSiteRequestRegistry::readAll();
150 $finishedAt = ABJ_404_Solution_SameSiteRequestCensus::nowFloat();
151 if ($registry['status'] !== 'available') {
152 return self::unavailable($registry['reason'], array(
153 'SameSiteRequestRegistry::readAll()' => (string)$registry['reason'],
154 ));
155 }
156
157 $others = array();
158 $stale = array();
159 $live = 0;
160 $selfRegistered = false;
161 foreach ($registry['entries'] as $entry) {
162 $ageMs = max(0, $now - $entry['started_at_ms']);
163 if ($ageMs > ABJ_404_Solution_SameSiteRequestCensus::ENTRY_TTL_MS) {
164 // The failure mode a plain counter cannot survive: the request
165 // under investigation is precisely the one killed before it
166 // could deregister. An entry older than any request that could
167 // still be running is a leftover, not a competitor.
168 $entry['age_ms'] = $ageMs;
169 $stale[] = $entry;
170 continue;
171 }
172 $live++;
173 if ($entry['option_name'] === $ownEntry) {
174 $selfRegistered = true;
175 continue;
176 }
177 $others[] = array(
178 'channel' => $entry['channel'],
179 'action' => $entry['action'],
180 'pid' => $entry['pid'],
181 'age_ms' => $ageMs,
182 // Which segment that request was inside when it last managed to
183 // say so. An age alone reports that a worker is stranded; the
184 // phase is what says where, and it is the difference between a
185 // finding and a hunt through a rotated journal.
186 'phase' => $entry['phase'] !== '' ? $entry['phase'] : 'unrecorded',
187 );
188 }
189 return self::report($live, $others, $stale, $selfRegistered, $registry['truncated'],
190 ($startedAt === null || $finishedAt === null)
191 ? -1.0 : round(($finishedAt - $startedAt) * 1000, 3));
192 }
193
194 /**
195 * Four fields unconditionally, the rest only when they carry information.
196 *
197 * Not terseness for its own sake: this reading rides the checkpoint
198 * channel, and the support excerpt's byte budget is the scarce resource
199 * that decides how much of a FAILING session reaches the developer at all
200 * (see CheckpointJournalReader::MAX_SUPPORT_EXCERPT_BYTES and the rusage trim
201 * that preceded it). Everything omitted here is omitted only at its
202 * documented default and reappears the moment it is not: the entries and
203 * the TTL their ages are read against when there IS other traffic,
204 * self_registered when this request did NOT register, the reap counters
205 * when something was reaped, truncated when the read hit its ceiling.
206 *
207 * @param array<int, array<string, mixed>> $others
208 * @param array<int, array<string, mixed>> $stale Whole decoded rows, so the
209 * account of a request that never deregistered survives its own deletion.
210 * @return array<string, mixed>
211 */
212 private static function report(int $live, array $others, array $stale, bool $selfRegistered,
213 bool $truncated, float $readMs): array {
214 $sample = array(
215 'status' => 'available',
216 'count' => $live,
217 'others' => count($others),
218 'sample_age_ms' => 0,
219 );
220 if ($others !== array()) {
221 $sample['scope'] = ABJ_404_Solution_SameSiteRequestCensus::SCOPE;
222 $sample['entries'] = $others;
223 $sample['ttl_ms'] = ABJ_404_Solution_SameSiteRequestCensus::ENTRY_TTL_MS;
224 }
225 if (!$selfRegistered) {
226 $sample['self_registered'] = false;
227 }
228 if ($stale !== array()) {
229 $sample['stale_seen'] = count($stale);
230 // Promote BEFORE deleting. A reaped row is a request that outlived
231 // any plausible lifetime without deregistering -- the worst strand
232 // on the site, and the one a bare DELETE would erase down to a
233 // count. See ABJ_404_Solution_StrandedRequestLedger.
234 $sample['stale_recorded'] = ABJ_404_Solution_StrandedRequestLedger::record($stale);
235 $optionNames = array();
236 foreach ($stale as $entry) {
237 if (isset($entry['option_name']) && is_string($entry['option_name'])) {
238 $optionNames[] = $entry['option_name'];
239 }
240 }
241 $sample['stale_reaped'] = ABJ_404_Solution_SameSiteRequestRegistry::remove($optionNames);
242 }
243 if ($truncated) {
244 $sample['truncated'] = true;
245 }
246 $sample['read_ms'] = $readMs;
247 return $sample;
248 }
249
250 /**
251 * @param array<string, string> $attemptedPaths
252 * @return array<string, mixed>
253 */
254 private static function unavailable(string $reason, array $attemptedPaths): array {
255 return array(
256 'status' => 'unavailable',
257 'reason' => $reason,
258 'attempted_paths' => $attemptedPaths,
259 'scope' => ABJ_404_Solution_SameSiteRequestCensus::SCOPE,
260 // -1, never 0: an unreadable census and a quiet site are opposite
261 // findings, and the per-record number has to stay arithmetically
262 // impossible to confuse.
263 'count' => -1,
264 'others' => -1,
265 'self_registered' => ABJ_404_Solution_SameSiteRequestCensus::ownEntryName() !== '',
266 'sample_age_ms' => 0,
267 );
268 }
269
270 private static function reportFailure(string $message): void {
271 if (function_exists('abj404_logPhpFallback')) {
272 abj404_logPhpFallback('same-site-census', $message);
273 }
274 }
275 }
276