` WP transient. * * Boundary normalizer (task: type-pressure at module boundaries). * The transient is the message bus between two producers and three * consumers: * * Producers (writers): * 1. ABJ_404_Solution_SuggestionPublisher::openPendingJob * (creates 'pending', started=0, with a fresh token, when the 404 * page renders its placeholder) * 2. ABJ_404_Solution_SuggestionPublisher::cacheComputedSuggestionsForShortcode * (creates 'complete' directly, no token, when synchronous spell-check * beat the async worker) * 3. ABJ_404_Solution_SuggestionComputeJob::run, driven by the page's * first poll (transitions 'pending' to 'pending+started' on claim, * then to 'complete' on finish, or to 'error' on shutdown crash) * * Consumers (readers): * 1. ABJ_404_Solution_Ajax_SuggestionPolling::pollSuggestions * (branches on status; runs an unclaimed job; checks the * worker-stuck window) * 2. ABJ_404_Solution_ShortCode::shortcodePageSuggestions * (renders 'complete' results directly, a placeholder for 'pending') * 3. ABJ_404_Solution_SuggestionWorkerStateStore (re-reads the * transient inside each claim / publish / crash critical section) * * Without this normalizer, each consumer reinvented its own inline * defensive parsing (`isset && is_scalar && (int)` chains, `is_array && * isset && is_string` chains for every field). That meant any new field * had to be defended five places, and a malformed-payload case in one * consumer could not catch a sibling regression in another. The * normalizer pulls every shape probe into one place. All consumers * branch on `fromRaw()` returning null vs. a typed VO and read fields * via accessors that already enforce the contract. * * Schema (after normalization): * * - status : 'pending' | 'complete' | 'error' (always present) * - url : string ('' if absent) * - token : string ('' if absent) * - started : int >= 0 (0 = no worker yet) * - created : int >= 0 (0 if absent) * - completed : int >= 0 (0 if absent) * - suggestionsPacket : list, two-tuple [permalinks, rowType] * ([] if absent) * * Construct via fromRaw() (consumer side) or the pendingArray() / * completeArray() / errorArray() factories (producer side). The factory * methods return associative arrays ready to feed to set_transient(), * so producers and consumers cannot drift on field names or types. */ final class ABJ_404_Solution_SuggestionTransient { public const STATUS_PENDING = 'pending'; public const STATUS_COMPLETE = 'complete'; public const STATUS_ERROR = 'error'; /** Transient lifetimes for the three producer states. */ public const PENDING_TTL_SECONDS = 120; public const COMPLETE_TTL_SECONDS = 300; public const ERROR_TTL_SECONDS = 120; /** Prefix shared by every producer and consumer of suggestion state. */ private const TRANSIENT_PREFIX = 'abj404_suggest_'; /** * Worker is presumed dead after this many seconds since claim * (started > 0). Read by SuggestionWorkerStateStore::claimWorker (a * stuck claim may be re-claimed) and by the polling endpoint (a stuck * claim answers 'timeout'); this constant is the single source of truth. */ public const WORKER_STUCK_SECONDS = 90; /** * Normalize a requested URL once for both storage and key derivation. */ public static function normalizedUrl(string $requestedUrl): string { return abj_service('url_encoder')->normalizeURLForCacheKey($requestedUrl); } /** * Return the one transient key used by all suggestion-state participants. */ public static function transientKeyForNormalizedUrl(string $normalizedUrl): string { // allow-url-key: the method accepts only the normalized-URL identity produced by normalizedUrl(). return self::TRANSIENT_PREFIX . md5($normalizedUrl); } /** * Normalize a requested URL and return its suggestion transient key. */ public static function transientKeyForRequestedUrl(string $requestedUrl): string { return self::transientKeyForNormalizedUrl(self::normalizedUrl($requestedUrl)); } /** Shared synchronization key for all writers of one URL's state. */ public static function lockKeyForNormalizedUrl(string $normalizedUrl): string { // allow-url-key: the method accepts only the normalized-URL identity produced by normalizedUrl(). return 'suggestion-state-' . md5($normalizedUrl); } /** @var string */ private $status; /** @var string */ private $url; /** @var string */ private $token; /** @var int */ private $startedAt; /** @var int */ private $createdAt; /** @var int */ private $completedAt; /** @var array */ private $suggestionsPacket; /** * @param array $suggestionsPacket */ private function __construct( string $status, string $url, string $token, int $startedAt, int $createdAt, int $completedAt, array $suggestionsPacket ) { $this->status = $status; $this->url = $url; $this->token = $token; $this->startedAt = $startedAt; $this->createdAt = $createdAt; $this->completedAt = $completedAt; $this->suggestionsPacket = $suggestionsPacket; } /** * Normalize a raw get_transient() return into a typed VO, or null * when the payload is unrecoverably malformed (not an array, missing * status, status not in the documented enum). * * Callers branch on null vs. VO; they MUST NOT shape-probe the raw * payload themselves. * * @param mixed $raw */ public static function fromRaw($raw): ?self { if (!is_array($raw)) { return null; } if (!isset($raw['status']) || !is_string($raw['status'])) { return null; } $status = $raw['status']; if ($status !== self::STATUS_PENDING && $status !== self::STATUS_COMPLETE && $status !== self::STATUS_ERROR ) { return null; } $url = self::coerceString($raw, 'url'); $token = self::coerceString($raw, 'token'); $startedAt = self::coerceNonNegativeInt($raw, 'started'); $createdAt = self::coerceNonNegativeInt($raw, 'created'); $completedAt = self::coerceNonNegativeInt($raw, 'completed'); $packet = self::coerceSuggestionsPacket($raw); return new self($status, $url, $token, $startedAt, $createdAt, $completedAt, $packet); } /** * Build the array shape for a freshly-triggered pending transient * (before any worker has claimed). Producer side of the boundary. * * @return array{status: string, url: string, started: int, created: int, token: string} */ public static function pendingArray(string $url, string $token, int $startedAt, int $createdAt): array { return [ 'status' => self::STATUS_PENDING, 'url' => $url, 'started' => max(0, $startedAt), 'created' => max(0, $createdAt), 'token' => $token, ]; } /** * Build the array shape for a completed computation. Producer side * of the boundary. * * @param array $suggestionsPacket Two-tuple from spell-checker. * @return array{status: string, url: string, suggestions: array, completed: int, token: string} */ public static function completeArray(string $url, array $suggestionsPacket, int $completedAt, string $token): array { return [ 'status' => self::STATUS_COMPLETE, 'url' => $url, 'suggestions' => $suggestionsPacket, 'completed' => max(0, $completedAt), 'token' => $token, ]; } /** * Build the array shape for the shutdown-handler crash marker. * Producer side of the boundary. * * @return array{status: string, token: string} */ public static function errorArray(string $token): array { return [ 'status' => self::STATUS_ERROR, 'token' => $token, ]; } public function getStatus(): string { return $this->status; } public function isPending(): bool { return $this->status === self::STATUS_PENDING; } public function isComplete(): bool { return $this->status === self::STATUS_COMPLETE; } public function isError(): bool { return $this->status === self::STATUS_ERROR; } public function getUrl(): string { return $this->url; } public function getToken(): string { return $this->token; } public function getStartedAt(): int { return $this->startedAt; } public function getCreatedAt(): int { return $this->createdAt; } public function getCompletedAt(): int { return $this->completedAt; } /** * True iff a worker has set started > 0 (claimed the work). */ public function isClaimed(): bool { return $this->startedAt > 0; } /** * @return array */ public function getSuggestionsPacket(): array { return $this->suggestionsPacket; } /** * True iff a worker claimed the job but didn't finish within * WORKER_STUCK_SECONDS. Only meaningful for status=pending. When * unclaimed (started=0), always returns false. */ public function isWorkerStuck(int $now): bool { if ($this->startedAt <= 0) { return false; } return ($now - $this->startedAt) > self::WORKER_STUCK_SECONDS; } /** * @param array $raw */ private static function coerceString(array $raw, string $key): string { if (!isset($raw[$key])) { return ''; } $v = $raw[$key]; return is_string($v) ? $v : ''; } /** * @param array $raw */ private static function coerceNonNegativeInt(array $raw, string $key): int { if (!isset($raw[$key])) { return 0; } // JSON may decode a whole number as 1.0. ExactInteger accepts that // representation but refuses a genuinely fractional value. return ABJ_404_Solution_ExactInteger::readOr($raw[$key], 0, 0); } /** * @param array $raw * @return array */ private static function coerceSuggestionsPacket(array $raw): array { if (!isset($raw['suggestions']) || !is_array($raw['suggestions'])) { return []; } return array_values($raw['suggestions']); } }