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