> $entries * @return int how many accounts were added. */ public static function record(array $entries): int { if ($entries === array()) { return 0; } try { $existing = self::read(); $added = array(); foreach ($entries as $entry) { $account = self::account($entry); if ($account !== null) { $added[] = $account; } } if ($added === array()) { return 0; } self::write(self::trim(array_merge($existing, $added))); return count($added); } catch (Throwable $e) { abj404_logPhpFallback('stranded-request-ledger', 'stranded request record failed (code ' . $e->getCode() . '): ' . $e->getMessage()); return 0; } } /** * Every retained account, oldest first. Never throws; an unreadable or * malformed ledger reports as empty rather than propagating into whatever * asked for it. * * @return array> */ public static function read(): array { try { if (!function_exists('get_option')) { return array(); } $raw = get_option(self::OPTION_NAME, ''); if (!is_string($raw) || $raw === '') { return array(); } $decoded = json_decode($raw, true); if (!is_array($decoded)) { return array(); } $entries = array(); foreach ($decoded as $entry) { if (is_array($entry)) { $entries[] = $entry; } } return $entries; } catch (Throwable $e) { abj404_logPhpFallback('stranded-request-ledger', 'stranded request read failed (code ' . $e->getCode() . '): ' . $e->getMessage()); return array(); } } /** Forget every account. For uninstall and for tests that need a clean slate. */ public static function clear(): void { if (function_exists('delete_option')) { delete_option(self::OPTION_NAME); } } /** * One reaped row as a bounded account, or null when the row carries nothing * worth keeping. * * @param array $entry * @return array|null */ private static function account(array $entry): ?array { $pid = isset($entry['pid']) && is_numeric($entry['pid']) ? (int)$entry['pid'] : 0; $ageMs = isset($entry['age_ms']) && is_numeric($entry['age_ms']) ? (int)$entry['age_ms'] : 0; if ($pid === 0 && $ageMs === 0) { return null; } $phase = isset($entry['phase']) && is_string($entry['phase']) && $entry['phase'] !== '' ? substr($entry['phase'], 0, 32) // A row written before phases existed, or by a request that died // before its first transition. Named rather than blank, so it is // never read as "reached no phase". : 'unrecorded'; return array( 'channel' => isset($entry['channel']) && is_string($entry['channel']) ? substr($entry['channel'], 0, 16) : '', 'action' => isset($entry['action']) && is_string($entry['action']) ? substr($entry['action'], 0, 64) : '', 'pid' => $pid, 'phase' => $phase, 'started_at_ms' => isset($entry['started_at_ms']) && is_numeric($entry['started_at_ms']) ? (int)$entry['started_at_ms'] : 0, 'age_ms_at_reap' => $ageMs, ); } /** * Keep both ends and drop the middle. See RETAINED_EARLIEST. * * @param array> $entries * @return array> */ private static function trim(array $entries): array { // The gap marker is NOT an account and must never occupy a slot or be // re-counted. Folding prior markers back into one running total first // is what keeps the ledger at MAX_ENTRIES accounts with exactly one // marker, instead of growing by one marker per trim. $dropped = 0; $accounts = array(); foreach ($entries as $entry) { if (isset($entry['dropped_middle_accounts'])) { // A hand-edited or truncated option can put anything here. A // non-numeric marker still means "accounts were dropped", so it // is kept as a marker and counted as at least one rather than // silently becoming zero. $dropped += is_numeric($entry['dropped_middle_accounts']) ? (int)$entry['dropped_middle_accounts'] : 1; continue; } $accounts[] = $entry; } if (count($accounts) > self::MAX_ENTRIES) { $earliest = array_slice($accounts, 0, self::RETAINED_EARLIEST); $newest = array_slice($accounts, -(self::MAX_ENTRIES - self::RETAINED_EARLIEST)); $dropped += count($accounts) - count($earliest) - count($newest); } else { $earliest = array_slice($accounts, 0, self::RETAINED_EARLIEST); $newest = array_slice($accounts, self::RETAINED_EARLIEST); } if ($dropped === 0) { return array_merge($earliest, $newest); } // The gap is stated in the ledger itself. A reader who cannot see that // accounts were dropped would read the two ends as one continuous // history, which is the same "looks complete, is 3% of it" failure the // journal excerpt summary exists to prevent. return array_merge($earliest, array(array('dropped_middle_accounts' => $dropped)), $newest); } /** * @param array> $entries */ private static function write(array $entries): void { if (!function_exists('update_option')) { return; } $encoded = json_encode(array_values($entries)); if (!is_string($encoded)) { return; } update_option(self::OPTION_NAME, $encoded, false); } }