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

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

273 lines 12.4 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 * Storage for the set of PHP requests this site currently has in flight.
9 *
10 * ONE ROW PER REQUEST, written and deleted by that request alone. A single
11 * shared counter incremented at request start and decremented at shutdown is
12 * corrupted by exactly the event the census exists to investigate: a request
13 * killed mid-flight never runs its decrement, so the counter drifts upward
14 * forever and every later reading is fabricated. A row that only its own
15 * request ever writes has no read-modify-write and therefore no interleaving
16 * to lose, and a row whose request died is simply an old row -- something a
17 * reader can recognise and delete, which a corrupted integer is not.
18 *
19 * This class owns the row and nothing else: how its name is minted, how its
20 * value is encoded and decoded, and the statements that write, read and remove
21 * it. What counts as "old", which requests are allowed to register, and what a
22 * reading reports belong to ABJ_404_Solution_SameSiteRequestCensus. The same
23 * split this subsystem already uses for
24 * ABJ_404_Solution_AjaxCheckpointLogger and its journal writer.
25 */
26 final class ABJ_404_Solution_SameSiteRequestRegistry {
27
28 /**
29 * Option-name prefix for one in-flight request. Alphanumeric on purpose:
30 * it is used as a LIKE prefix, and `_` is a single-character wildcard in
31 * LIKE, so an underscore here would silently widen the match to rows this
32 * class never wrote.
33 */
34 const OPTION_PREFIX = 'abj404inflight';
35
36 /**
37 * Rows read at once. A real reading is a handful; the ceiling exists so a
38 * pathological leak degrades into a truncated (and self-announcing)
39 * reading rather than an unbounded SELECT on the path being measured.
40 */
41 const MAX_ENTRIES_READ = 200;
42
43 /** Rows removed per call. Repeated readings converge; one never stalls on cleanup. */
44 const MAX_REMOVED_PER_CALL = 50;
45
46 /**
47 * Register one in-flight request.
48 *
49 * INSERT IGNORE rather than an upsert: this row belongs to this request
50 * alone and nothing else may ever write it, which is the property that
51 * makes the whole registry race-free.
52 *
53 * @param string $phase the segment the request is in at registration. The
54 * caller names it: which segments exist, and what they mean, belongs to
55 * ABJ_404_Solution_SameSiteRequestCensus. Written with the row rather
56 * than by a follow-up update so registration still costs one query.
57 * @return string the option name the request was registered under, or ''
58 * when it could not be registered at all.
59 */
60 public static function add(int $startedAtMs, string $channel, string $action, ?int $pid,
61 string $phase = '', string $processToken = ''): string {
62 $dbCore = self::dbCore();
63 if ($dbCore === null) {
64 return '';
65 }
66 // Preserve the historical hexadecimal PID name on ordinary hosts;
67 // only the unavailable-PID branch needs the synthetic process token.
68 $identity = $pid !== null ? dechex($pid)
69 : ($processToken !== '' ? $processToken : 'unavailable');
70 $identity = substr((string)preg_replace('/[^A-Za-z0-9-]/', '', $identity), 0, 32);
71 $optionName = self::OPTION_PREFIX . $identity
72 . preg_replace('/[^a-f0-9]/', '', uniqid('', true));
73 $result = $dbCore->queryAndGetResults(
74 "INSERT IGNORE INTO {wp_options} (option_name, option_value, autoload) "
75 . "VALUES (%s, %s, 'no')",
76 array('query_params' => array($optionName,
77 self::encode($startedAtMs, $channel, $action, $pid, $phase)))
78 );
79 if (!empty($result['last_error'])) {
80 // queryAndGetResults already logged it (CLAUDE.md: it is the
81 // centralized error handler). A registry that cannot register is a
82 // missing diagnostic, never a reason to affect the request.
83 return '';
84 }
85 return $optionName;
86 }
87
88 /**
89 * Record which segment of its own lifecycle this request has entered.
90 *
91 * A plain UPDATE of one row by primary key, and the single-writer property
92 * that makes the registry race-free is what makes it safe: the row belongs
93 * to this request alone, so there is no read-modify-write and nothing to
94 * interleave with. The other fields are rewritten from the caller's own
95 * values rather than read back and merged, for the same reason.
96 *
97 * ALWAYS CALLED BEFORE ENTERING THE SEGMENT IT NAMES, never after. A
98 * request that dies inside a segment cannot write anything afterwards, so
99 * a phase recorded on the way out would be exactly the one missing from
100 * every row worth reading. Recorded on the way in, an abandoned row's
101 * phase names the segment the worker was inside when it stopped -- which
102 * is the entire question a stranded worker poses.
103 *
104 * @return bool whether the row was updated.
105 */
106 public static function advance(string $optionName, int $startedAtMs, string $channel,
107 string $action, ?int $pid, string $phase): bool {
108 $dbCore = self::dbCore();
109 if ($dbCore === null || $optionName === '') {
110 return false;
111 }
112 $result = $dbCore->queryAndGetResults(
113 "UPDATE {wp_options} SET option_value = %s WHERE option_name = %s",
114 array('query_params' => array(
115 self::encode($startedAtMs, $channel, $action, $pid, $phase), $optionName))
116 );
117 // queryAndGetResults is the centralized error handler (CLAUDE.md #11).
118 // A phase that cannot be recorded is a coarser reading, never a reason
119 // to affect the request being measured.
120 return empty($result['last_error']);
121 }
122
123 /**
124 * Every registered request, decoded, oldest option name first.
125 *
126 * `truncated` says the read ceiling was reached, so a reader can tell a
127 * bounded reading from a complete one instead of quietly believing the
128 * smaller number.
129 *
130 * @return array{status: string, reason: string, entries: array<int, array{option_name: string, started_at_ms: int, channel: string, action: string, pid: int|null, phase: string}>, truncated: bool}
131 */
132 public static function readAll(): array {
133 $dbCore = self::dbCore();
134 if ($dbCore === null) {
135 return self::unreadable('dao_unavailable');
136 }
137 $result = $dbCore->queryAndGetResults(
138 "SELECT option_name, option_value FROM {wp_options} "
139 . "WHERE option_name LIKE %s ORDER BY option_name LIMIT " . (self::MAX_ENTRIES_READ + 1),
140 array('query_params' => array(self::OPTION_PREFIX . '%'))
141 );
142 if (!empty($result['last_error'])) {
143 return self::unreadable('read_failed');
144 }
145 $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array();
146 $truncated = count($rows) > self::MAX_ENTRIES_READ;
147 if ($truncated) {
148 $rows = array_slice($rows, 0, self::MAX_ENTRIES_READ);
149 }
150 $entries = array();
151 foreach ($rows as $row) {
152 $entry = self::decode($row);
153 if ($entry !== null) {
154 $entries[] = $entry;
155 }
156 }
157 return array('status' => 'available', 'reason' => '', 'entries' => $entries,
158 'truncated' => $truncated);
159 }
160
161 /**
162 * Remove registrations by option name. Idempotent, bounded per call, and
163 * safe when two readers remove the same name at once.
164 *
165 * @param array<int, string> $optionNames
166 * @return int how many rows the delete claimed.
167 */
168 public static function remove(array $optionNames): int {
169 $optionNames = array_values($optionNames);
170 $dbCore = self::dbCore();
171 if ($dbCore === null || $optionNames === array()) {
172 return 0;
173 }
174 $optionNames = array_slice($optionNames, 0, self::MAX_REMOVED_PER_CALL);
175 $placeholders = implode(', ', array_fill(0, count($optionNames), '%s'));
176 $result = $dbCore->queryAndGetResults(
177 "DELETE FROM {wp_options} WHERE option_name IN (" . $placeholders . ")",
178 array('query_params' => $optionNames)
179 );
180 if (!empty($result['last_error'])) {
181 return 0;
182 }
183 return isset($result['rows_affected']) && is_numeric($result['rows_affected'])
184 ? (int)$result['rows_affected'] : count($optionNames);
185 }
186
187 /**
188 * The stored value: start time, channel, WordPress action, PID, phase.
189 *
190 * A flat delimited string rather than JSON because every field is a
191 * bounded scalar and the row is written on a path whose cost is being
192 * measured; the decoder below is the only reader.
193 *
194 * Phase is last so a row written by an older build -- four fields, no
195 * trailing delimiter -- still decodes completely, with an empty phase
196 * rather than a rejected row. The delimiter is stripped from the phase
197 * for the same reason the decoder bounds every field: a value that could
198 * introduce a sixth part would shift the meaning of the parts after it.
199 */
200 private static function encode(int $startedAtMs, string $channel, string $action, ?int $pid,
201 string $phase = ''): string {
202 return $startedAtMs . '|' . $channel . '|' . $action . '|'
203 . ($pid !== null ? (string)$pid : '')
204 . '|' . str_replace('|', '', $phase);
205 }
206
207 /**
208 * One row as a structured entry, or null when the row is not one this
209 * class wrote in a format it understands.
210 *
211 * @param mixed $row
212 * @return array{option_name: string, started_at_ms: int, channel: string, action: string, pid: int|null, phase: string}|null
213 */
214 private static function decode($row): ?array {
215 if (!is_array($row)) {
216 return null;
217 }
218 // Case-insensitive: MySQL drivers vary the case of returned column
219 // names, and a registry that silently reads nothing is the failure
220 // mode the whole census exists to avoid.
221 $row = array_change_key_case($row, CASE_LOWER);
222 $name = isset($row['option_name']) && is_scalar($row['option_name'])
223 ? (string)$row['option_name'] : '';
224 $raw = isset($row['option_value']) && is_scalar($row['option_value'])
225 ? (string)$row['option_value'] : '';
226 if ($name === '' || $raw === '') {
227 return null;
228 }
229 $parts = explode('|', $raw, 5);
230 if (!isset($parts[0]) || !ctype_digit($parts[0])) {
231 return null;
232 }
233 return array(
234 'option_name' => $name,
235 'started_at_ms' => (int)$parts[0],
236 'channel' => isset($parts[1]) ? substr($parts[1], 0, 16) : '',
237 'action' => isset($parts[2]) ? substr($parts[2], 0, 64) : '',
238 'pid' => isset($parts[3]) && ctype_digit($parts[3]) ? (int)$parts[3] : null,
239 // A row from a build that predates phases has four parts. Reported
240 // as an empty phase, which the census names explicitly, rather than
241 // being confused with a request that reached no phase at all.
242 'phase' => isset($parts[4]) ? substr($parts[4], 0, 32) : '',
243 );
244 }
245
246 /**
247 * @return array{status: string, reason: string, entries: array<int, array{option_name: string, started_at_ms: int, channel: string, action: string, pid: int|null, phase: string}>, truncated: bool}
248 */
249 private static function unreadable(string $reason): array {
250 return array('status' => 'unavailable', 'reason' => $reason, 'entries' => array(),
251 'truncated' => false);
252 }
253
254 /**
255 * Read straight from the container rather than through
256 * ABJ_404_Solution_Ajax_ServiceResolver, which is a two-line pass-through
257 * to this same call that exists to serve the AJAX endpoint adapters. The
258 * census runs on every in-scope request, not only AJAX ones, so borrowing
259 * the endpoint layer's accessor was both an indirection with nothing in it
260 * and a dependency pointing the wrong way: instrumentation must not need
261 * the presentation surface to be loaded in order to read a service.
262 *
263 * @return ABJ_404_Solution_DatabaseQueryInterface|null
264 */
265 private static function dbCore() {
266 if (!class_exists('ABJ_404_Solution_ServiceContainer')) {
267 return null;
268 }
269 $dbCore = ABJ_404_Solution_ServiceContainer::safeGet('db_core');
270 return ($dbCore instanceof ABJ_404_Solution_DatabaseQueryInterface) ? $dbCore : null;
271 }
272 }
273