queryAndGetResults( "INSERT IGNORE INTO {wp_options} (option_name, option_value, autoload) " . "VALUES (%s, %s, 'no')", array('query_params' => array($optionName, ABJ_404_Solution_SameSiteRequestRowCodec::encode($row))) ); if (!empty($result['last_error'])) { // queryAndGetResults already logged it (CLAUDE.md: it is the // centralized error handler). A registry that cannot register is a // missing diagnostic, never a reason to affect the request. return ''; } return $optionName; } /** * Record which segment of its own lifecycle this request has entered. * * A plain UPDATE of one row by primary key, and the single-writer property * that makes the registry race-free is what makes it safe: the row belongs * to this request alone, so there is no read-modify-write and nothing to * interleave with. The other fields are rewritten from the caller's own * values rather than read back and merged, for the same reason. * * ALWAYS CALLED BEFORE ENTERING THE SEGMENT IT NAMES, never after. A * request that dies inside a segment cannot write anything afterwards, so * a phase recorded on the way out would be exactly the one missing from * every row worth reading. Recorded on the way in, an abandoned row's * phase names the segment the worker was inside when it stopped -- which * is the entire question a stranded worker poses. * * @param string $optionName the row this request registered under. * @param Row $row the request's identity with its new `phase` and timeline. * @return bool whether the row was updated. */ public static function advance(string $optionName, array $row): bool { $dbCore = self::dbCore(); if ($dbCore === null || $optionName === '') { return false; } $result = $dbCore->queryAndGetResults( "UPDATE {wp_options} SET option_value = %s WHERE option_name = %s", array('query_params' => array(ABJ_404_Solution_SameSiteRequestRowCodec::encode($row), $optionName)) ); // queryAndGetResults is the centralized error handler (CLAUDE.md #11). // A phase that cannot be recorded is a coarser reading, never a reason // to affect the request being measured. return empty($result['last_error']); } /** * Registered requests, decoded, oldest option name first, at most * MAX_ENTRIES_READ of them. * * `truncated` says the read ceiling was reached, so a reader can tell a * bounded reading from a complete one instead of quietly believing the * smaller number. A reader that must see every row (the nightly sweep) * resumes with the `last_option_name` of the page before: rows are walked * by option name, so a page full of live rows can never hide the leftovers * sorted after it. * * @param string $afterOptionName resume after this row; '' reads from the start. * @return array{status: string, reason: string, entries: array, truncated: bool, last_option_name: string} * `last_option_name` is the last row examined, decodable or not, so a page of * rows this codec cannot read still advances the walk; '' when no row was read. */ public static function readAll(string $afterOptionName = ''): array { return self::readNamespace(self::OPTION_PREFIX, $afterOptionName); } /** * Pending handoffs are intentionally a separate namespace: ordinary * census reads must never count them as live requests, while the bounded * nightly sweep can resume them after a disposer process dies. * * @param string $afterOptionName resume after this pending row. * @return array{status: string, reason: string, entries: array, truncated: bool, last_option_name: string} */ public static function readPending(string $afterOptionName = ''): array { return self::readNamespace(self::PENDING_PREFIX, $afterOptionName); } /** * Read one bounded option namespace by name. The codec retains the exact * raw value so a later destructive step can fence against any update or * retirement that happened after the snapshot. * * @return array{status: string, reason: string, entries: array, truncated: bool, last_option_name: string} */ private static function readNamespace(string $prefix, string $afterOptionName): array { $dbCore = self::dbCore(); if ($dbCore === null) { return self::unreadable('dao_unavailable'); } $limit = " ORDER BY option_name LIMIT " . (self::MAX_ENTRIES_READ + 1); $result = $afterOptionName === '' ? $dbCore->queryAndGetResults( "SELECT option_name, option_value FROM {wp_options} WHERE option_name LIKE %s" . $limit, array('query_params' => array($prefix . '%'))) : $dbCore->queryAndGetResults( "SELECT option_name, option_value FROM {wp_options} WHERE option_name LIKE %s " . "AND option_name > %s" . $limit, array('query_params' => array($prefix . '%', $afterOptionName))); if (!self::querySucceeded($result) || !array_key_exists('rows', $result) || !is_array($result['rows'])) { return self::unreadable('read_failed'); } $rows = $result['rows']; $truncated = count($rows) > self::MAX_ENTRIES_READ; if ($truncated) { $rows = array_slice($rows, 0, self::MAX_ENTRIES_READ); } $entries = array(); foreach ($rows as $row) { $entry = ABJ_404_Solution_SameSiteRequestRowCodec::decodeRow($row); if ($entry !== null) { $entries[] = $entry; } } return array('status' => 'available', 'reason' => '', 'entries' => $entries, 'truncated' => $truncated, 'last_option_name' => self::lastOptionName($rows)); } /** * The option name of the last row, or '' when there is none. Case-insensitive * on the column name for the same reason decodeRow() is. * * @param array $rows */ private static function lastOptionName(array $rows): string { $last = end($rows); if (!is_array($last)) { return ''; } $last = array_change_key_case($last, CASE_LOWER); return isset($last['option_name']) && is_scalar($last['option_name']) ? (string)$last['option_name'] : ''; } /** * Remove registrations by option name. Idempotent, bounded per call, and * safe when two readers remove the same name at once. * * @param array $optionNames * @return int how many rows the delete claimed. */ public static function remove(array $optionNames): int { $optionNames = array_values($optionNames); $dbCore = self::dbCore(); if ($dbCore === null || $optionNames === array()) { return 0; } $optionNames = array_slice($optionNames, 0, self::MAX_REMOVED_PER_CALL); $placeholders = implode(', ', array_fill(0, count($optionNames), '%s')); $result = $dbCore->queryAndGetResults( "DELETE FROM {wp_options} WHERE option_name IN (" . $placeholders . ")", array('query_params' => $optionNames) ); if (!empty($result['last_error'])) { return 0; } return ABJ_404_Solution_ExactInteger::readOr( $result['rows_affected'] ?? null, 0, count($optionNames) ); } /** * Compatibility boundary for the pre-handoff caller. The old generation * may still invoke this method while files are replaced independently; * absence of the new capability must therefore be inert, never a delete. * * @param mixed $optionName * @return bool always false; this API no longer owns a destructive claim. */ public static function claim($optionName): bool { return false; } /** * Retire one owned row: rename it into the retired namespace and rewrite * its value, in one UPDATE by primary key. The single-writer property is * what makes this safe, exactly as for advance(): the row belongs to the * retiring request alone. * * Retiring replaces the shutdown DELETE for the instrumented endpoint, at * the same statement count. The retired name carries the request's start * timestamp so pruning is an index-range DELETE rather than a scan. * * The retired name is built here, from the row's start time and the * request id, so the layout pruneRetired() ranges over and findTimeline() * matches on has exactly one writer. * * @param string $optionName the in-flight row this request owns. * @param Row $row the row's final identity, phase and timeline. * @param string $requestId the request's ledger id. * @return bool whether the row was retired. */ public static function retire(string $optionName, array $row, string $requestId): bool { $dbCore = self::dbCore(); if ($dbCore === null || $optionName === '' || $requestId === '') { return false; } $result = $dbCore->queryAndGetResults( "UPDATE {wp_options} SET option_name = %s, option_value = %s WHERE option_name = %s", array('query_params' => array( self::retiredNameFor($row['started_at_ms'], $requestId), ABJ_404_Solution_SameSiteRequestRowCodec::encode($row), $optionName)) ); return empty($result['last_error']); } /** * Delete retired rows whose start timestamp predates the cutoff, bounded * per call. The retired name sorts by start time under the shared prefix, * so this is one DELETE over the unique index range, never a scan. * * @param int $cutoffMs epoch ms; rows started before this are pruned. * @return int how many rows the delete claimed. */ public static function pruneRetired(int $cutoffMs): int { $dbCore = self::dbCore(); if ($dbCore === null) { return 0; } $result = $dbCore->queryAndGetResults( "DELETE FROM {wp_options} WHERE option_name >= %s AND option_name < %s " . "LIMIT " . self::MAX_PRUNED_PER_CALL, array('query_params' => array( self::RETIRED_PREFIX, self::retiredNameFor($cutoffMs, ''))) ); if (!empty($result['last_error'])) { return 0; } return ABJ_404_Solution_ExactInteger::readOr( $result['rows_affected'] ?? null, 0, 0 ); } /** * The retired-namespace name: prefix, 13-digit zero-padded start ms (so * names sort by start time and pruning is an index range), then the * request id. With an empty id it is the range bound for a cutoff. */ private static function retiredNameFor(int $startedAtMs, string $requestId): string { return self::RETIRED_PREFIX . sprintf('%013d', $startedAtMs) . $requestId; } /** * Find one request's timeline row by its ledger id: first a retired row * carrying that id in its name, then a live row carrying it in its * timeline. A retry or client beacon arriving after its parent finished * (or while it is still running) is how the parent's timeline gets * promoted into the durable ledger. * * @param string $requestId * @return array{state: string, value: string} state is retired, in_flight * or not_seen; value is the row's stored value, or '' when not seen. */ public static function findTimeline(string $requestId): array { $missing = array('state' => 'not_seen', 'value' => ''); if ($requestId === '') { return $missing; } $dbCore = self::dbCore(); if ($dbCore === null) { return $missing; } $retired = $dbCore->queryAndGetResults( "SELECT option_value FROM {wp_options} WHERE option_name LIKE %s LIMIT 1", array('query_params' => array(self::RETIRED_PREFIX . '%' . $requestId)) ); $value = self::firstOptionValue($retired); if ($value !== null) { return array('state' => 'retired', 'value' => $value); } // A live in-flight row's own name carries no rid: it is minted from // the registering process's identity (see add()), before a request's // evidence is known to be worth keying on. The rid can only be // matched inside option_value's timeline field. Rather than a // `option_value LIKE '%rid%'` sweep -- unseekable on any column, and // doubly so on a value column with no index at all -- the in-flight // set is read the same bounded, sargable way readAll() already reads // it (option_name LIKE the literal, non-wildcard OPTION_PREFIX), and // matched by rid in PHP. $needle = '"rid":"' . $requestId . '"'; foreach (self::readAll()['entries'] as $entry) { if (strpos($entry['timeline'], $needle) === false) { continue; } return array('state' => 'in_flight', 'value' => ABJ_404_Solution_SameSiteRequestRowCodec::encode($entry)); } return $missing; } /** * The first row's option value from a single-value SELECT, or null when * the read failed or matched nothing. Case-insensitive on the column * name: MySQL drivers vary the case of returned column names. * * @param array $result * @return string|null */ private static function firstOptionValue(array $result): ?string { if (!empty($result['last_error'])) { return null; } $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array(); if ($rows === array() || !is_array($rows[0])) { return null; } $row = array_change_key_case($rows[0], CASE_LOWER); $value = $row['option_value'] ?? null; return is_scalar($value) ? (string)$value : null; } /** * @return array{status: string, reason: string, entries: array, truncated: bool, last_option_name: string} */ private static function unreadable(string $reason): array { return array('status' => 'unavailable', 'reason' => $reason, 'entries' => array(), 'truncated' => false, 'last_option_name' => ''); } /** A destructive handoff requires a complete, successful SQL envelope. */ /** @param array $result */ private static function querySucceeded(array $result): bool { return array_key_exists('query_succeeded', $result) && $result['query_succeeded'] === true && array_key_exists('last_error', $result) && is_string($result['last_error']) && $result['last_error'] === '' && (!array_key_exists('timed_out', $result) || $result['timed_out'] === false); } /** * Read straight from the container rather than through * ABJ_404_Solution_Ajax_ServiceResolver, which is a two-line pass-through * to this same call that exists to serve the AJAX endpoint adapters. The * census runs on every in-scope request, not only AJAX ones, so borrowing * the endpoint layer's accessor was both an indirection with nothing in it * and a dependency pointing the wrong way: instrumentation must not need * the presentation surface to be loaded in order to read a service. * * @return ABJ_404_Solution_DatabaseQueryInterface|null */ private static function dbCore() { if (!class_exists('ABJ_404_Solution_ServiceContainer')) { return null; } $dbCore = ABJ_404_Solution_ServiceContainer::safeGet('db_core'); return ($dbCore instanceof ABJ_404_Solution_DatabaseQueryInterface) ? $dbCore : null; } }