| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Typed value object for the `abj404_suggest_<md5(url)>` WP transient. |
| 9 |
* |
| 10 |
* Boundary normalizer (task: type-pressure at module boundaries). |
| 11 |
* The transient is the message bus between two producers and three |
| 12 |
* consumers: |
| 13 |
* |
| 14 |
* Producers (writers): |
| 15 |
* 1. ABJ_404_Solution_SpellChecker::triggerAsyncSuggestionComputation |
| 16 |
* (creates 'pending', started=0, with a fresh token) |
| 17 |
* 2. ABJ_404_Solution_SpellChecker::cacheComputedSuggestionsForShortcode |
| 18 |
* (creates 'complete' directly, no token, when synchronous spell-check |
| 19 |
* beat the async worker) |
| 20 |
* 3. ABJ_404_Solution_Ajax_SuggestionCompute::computeSuggestions |
| 21 |
* (transitions 'pending' to 'pending+started' on claim, then to |
| 22 |
* 'complete' on finish, or to 'error' on shutdown crash) |
| 23 |
* |
| 24 |
* Consumers (readers): |
| 25 |
* 1. ABJ_404_Solution_Ajax_SuggestionPolling::pollSuggestions |
| 26 |
* (branches on status; checks worker-stuck / dispatch-stuck windows) |
| 27 |
* 2. ABJ_404_Solution_ShortCode::renderSuggestionsShortcode |
| 28 |
* (renders 'complete' results directly, falls back for 'pending') |
| 29 |
* 3. ABJ_404_Solution_Ajax_SuggestionCompute (re-reads its own transient |
| 30 |
* to check the token gate and the worker-claim state) |
| 31 |
* |
| 32 |
* Without this normalizer, each consumer reinvented its own inline |
| 33 |
* defensive parsing (`isset && is_scalar && (int)` chains, `is_array && |
| 34 |
* isset && is_string` chains for every field). That meant any new field |
| 35 |
* had to be defended five places, and a malformed-payload case in one |
| 36 |
* consumer could not catch a sibling regression in another. The |
| 37 |
* normalizer pulls every shape probe into one place. All consumers |
| 38 |
* branch on `fromRaw()` returning null vs. a typed VO and read fields |
| 39 |
* via accessors that already enforce the contract. |
| 40 |
* |
| 41 |
* Schema (after normalization): |
| 42 |
* |
| 43 |
* - status : 'pending' | 'complete' | 'error' (always present) |
| 44 |
* - url : string ('' if absent) |
| 45 |
* - token : string ('' if absent) |
| 46 |
* - started : int >= 0 (0 = no worker yet) |
| 47 |
* - created : int >= 0 (0 if absent) |
| 48 |
* - completed : int >= 0 (0 if absent) |
| 49 |
* - suggestionsPacket : list, two-tuple [permalinks, rowType] |
| 50 |
* ([] if absent) |
| 51 |
* |
| 52 |
* Construct via fromRaw() (consumer side) or the pendingArray() / |
| 53 |
* completeArray() / errorArray() factories (producer side). The factory |
| 54 |
* methods return associative arrays ready to feed to set_transient(), |
| 55 |
* so producers and consumers cannot drift on field names or types. |
| 56 |
*/ |
| 57 |
final class ABJ_404_Solution_SuggestionTransient { |
| 58 |
|
| 59 |
public const STATUS_PENDING = 'pending'; |
| 60 |
public const STATUS_COMPLETE = 'complete'; |
| 61 |
public const STATUS_ERROR = 'error'; |
| 62 |
|
| 63 |
/** |
| 64 |
* Worker is presumed dead after this many seconds since claim |
| 65 |
* (started > 0). Matches the recovery window in |
| 66 |
* Ajax_SuggestionCompute::computeSuggestions; if changed there, change |
| 67 |
* here as well (the constant is the single source of truth post-VO). |
| 68 |
*/ |
| 69 |
public const WORKER_STUCK_SECONDS = 90; |
| 70 |
|
| 71 |
/** |
| 72 |
* Dispatch is presumed dead after this many seconds since transient |
| 73 |
* creation when no worker has claimed (started == 0). Mirrors the |
| 74 |
* dispatch-no-show window in Ajax_SuggestionPolling. |
| 75 |
*/ |
| 76 |
public const DISPATCH_STUCK_SECONDS = 15; |
| 77 |
|
| 78 |
/** @var string */ |
| 79 |
private $status; |
| 80 |
|
| 81 |
/** @var string */ |
| 82 |
private $url; |
| 83 |
|
| 84 |
/** @var string */ |
| 85 |
private $token; |
| 86 |
|
| 87 |
/** @var int */ |
| 88 |
private $startedAt; |
| 89 |
|
| 90 |
/** @var int */ |
| 91 |
private $createdAt; |
| 92 |
|
| 93 |
/** @var int */ |
| 94 |
private $completedAt; |
| 95 |
|
| 96 |
/** @var array<int, mixed> */ |
| 97 |
private $suggestionsPacket; |
| 98 |
|
| 99 |
/** |
| 100 |
* @param array<int, mixed> $suggestionsPacket |
| 101 |
*/ |
| 102 |
private function __construct( |
| 103 |
string $status, |
| 104 |
string $url, |
| 105 |
string $token, |
| 106 |
int $startedAt, |
| 107 |
int $createdAt, |
| 108 |
int $completedAt, |
| 109 |
array $suggestionsPacket |
| 110 |
) { |
| 111 |
$this->status = $status; |
| 112 |
$this->url = $url; |
| 113 |
$this->token = $token; |
| 114 |
$this->startedAt = $startedAt; |
| 115 |
$this->createdAt = $createdAt; |
| 116 |
$this->completedAt = $completedAt; |
| 117 |
$this->suggestionsPacket = $suggestionsPacket; |
| 118 |
} |
| 119 |
|
| 120 |
/** |
| 121 |
* Normalize a raw get_transient() return into a typed VO, or null |
| 122 |
* when the payload is unrecoverably malformed (not an array, missing |
| 123 |
* status, status not in the documented enum). |
| 124 |
* |
| 125 |
* Callers branch on null vs. VO; they MUST NOT shape-probe the raw |
| 126 |
* payload themselves. |
| 127 |
* |
| 128 |
* @param mixed $raw |
| 129 |
*/ |
| 130 |
public static function fromRaw($raw): ?self { |
| 131 |
if (!is_array($raw)) { |
| 132 |
return null; |
| 133 |
} |
| 134 |
if (!isset($raw['status']) || !is_string($raw['status'])) { |
| 135 |
return null; |
| 136 |
} |
| 137 |
$status = $raw['status']; |
| 138 |
if ($status !== self::STATUS_PENDING |
| 139 |
&& $status !== self::STATUS_COMPLETE |
| 140 |
&& $status !== self::STATUS_ERROR |
| 141 |
) { |
| 142 |
return null; |
| 143 |
} |
| 144 |
|
| 145 |
$url = self::coerceString($raw, 'url'); |
| 146 |
$token = self::coerceString($raw, 'token'); |
| 147 |
$startedAt = self::coerceNonNegativeInt($raw, 'started'); |
| 148 |
$createdAt = self::coerceNonNegativeInt($raw, 'created'); |
| 149 |
$completedAt = self::coerceNonNegativeInt($raw, 'completed'); |
| 150 |
$packet = self::coerceSuggestionsPacket($raw); |
| 151 |
|
| 152 |
return new self($status, $url, $token, $startedAt, $createdAt, $completedAt, $packet); |
| 153 |
} |
| 154 |
|
| 155 |
/** |
| 156 |
* Build the array shape for a freshly-triggered pending transient |
| 157 |
* (before any worker has claimed). Producer side of the boundary. |
| 158 |
* |
| 159 |
* @return array{status: string, url: string, started: int, created: int, token: string} |
| 160 |
*/ |
| 161 |
public static function pendingArray(string $url, string $token, int $startedAt, int $createdAt): array { |
| 162 |
return [ |
| 163 |
'status' => self::STATUS_PENDING, |
| 164 |
'url' => $url, |
| 165 |
'started' => max(0, $startedAt), |
| 166 |
'created' => max(0, $createdAt), |
| 167 |
'token' => $token, |
| 168 |
]; |
| 169 |
} |
| 170 |
|
| 171 |
/** |
| 172 |
* Build the array shape for a completed computation. Producer side |
| 173 |
* of the boundary. |
| 174 |
* |
| 175 |
* @param array<int, mixed> $suggestionsPacket Two-tuple from spell-checker. |
| 176 |
* @return array{status: string, url: string, suggestions: array<int, mixed>, completed: int, token: string} |
| 177 |
*/ |
| 178 |
public static function completeArray(string $url, array $suggestionsPacket, int $completedAt, string $token): array { |
| 179 |
return [ |
| 180 |
'status' => self::STATUS_COMPLETE, |
| 181 |
'url' => $url, |
| 182 |
'suggestions' => $suggestionsPacket, |
| 183 |
'completed' => max(0, $completedAt), |
| 184 |
'token' => $token, |
| 185 |
]; |
| 186 |
} |
| 187 |
|
| 188 |
/** |
| 189 |
* Build the array shape for the shutdown-handler crash marker. |
| 190 |
* Producer side of the boundary. |
| 191 |
* |
| 192 |
* @return array{status: string, token: string} |
| 193 |
*/ |
| 194 |
public static function errorArray(string $token): array { |
| 195 |
return [ |
| 196 |
'status' => self::STATUS_ERROR, |
| 197 |
'token' => $token, |
| 198 |
]; |
| 199 |
} |
| 200 |
|
| 201 |
public function getStatus(): string { |
| 202 |
return $this->status; |
| 203 |
} |
| 204 |
|
| 205 |
public function isPending(): bool { |
| 206 |
return $this->status === self::STATUS_PENDING; |
| 207 |
} |
| 208 |
|
| 209 |
public function isComplete(): bool { |
| 210 |
return $this->status === self::STATUS_COMPLETE; |
| 211 |
} |
| 212 |
|
| 213 |
public function isError(): bool { |
| 214 |
return $this->status === self::STATUS_ERROR; |
| 215 |
} |
| 216 |
|
| 217 |
public function getUrl(): string { |
| 218 |
return $this->url; |
| 219 |
} |
| 220 |
|
| 221 |
public function getToken(): string { |
| 222 |
return $this->token; |
| 223 |
} |
| 224 |
|
| 225 |
public function getStartedAt(): int { |
| 226 |
return $this->startedAt; |
| 227 |
} |
| 228 |
|
| 229 |
public function getCreatedAt(): int { |
| 230 |
return $this->createdAt; |
| 231 |
} |
| 232 |
|
| 233 |
public function getCompletedAt(): int { |
| 234 |
return $this->completedAt; |
| 235 |
} |
| 236 |
|
| 237 |
/** |
| 238 |
* True iff a worker has set started > 0 (claimed the work). |
| 239 |
*/ |
| 240 |
public function isClaimed(): bool { |
| 241 |
return $this->startedAt > 0; |
| 242 |
} |
| 243 |
|
| 244 |
/** |
| 245 |
* @return array<int, mixed> |
| 246 |
*/ |
| 247 |
public function getSuggestionsPacket(): array { |
| 248 |
return $this->suggestionsPacket; |
| 249 |
} |
| 250 |
|
| 251 |
/** |
| 252 |
* True iff a worker claimed the job but didn't finish within |
| 253 |
* WORKER_STUCK_SECONDS. Only meaningful for status=pending. When |
| 254 |
* unclaimed (started=0), always returns false. |
| 255 |
*/ |
| 256 |
public function isWorkerStuck(int $now): bool { |
| 257 |
if ($this->startedAt <= 0) { |
| 258 |
return false; |
| 259 |
} |
| 260 |
return ($now - $this->startedAt) > self::WORKER_STUCK_SECONDS; |
| 261 |
} |
| 262 |
|
| 263 |
/** |
| 264 |
* True iff no worker has claimed the job and the dispatch window |
| 265 |
* has expired since creation. Only meaningful for status=pending. |
| 266 |
*/ |
| 267 |
public function isDispatchStuck(int $now): bool { |
| 268 |
if ($this->startedAt > 0) { |
| 269 |
return false; |
| 270 |
} |
| 271 |
if ($this->createdAt <= 0) { |
| 272 |
return false; |
| 273 |
} |
| 274 |
return ($now - $this->createdAt) > self::DISPATCH_STUCK_SECONDS; |
| 275 |
} |
| 276 |
|
| 277 |
/** |
| 278 |
* @param array<mixed, mixed> $raw |
| 279 |
*/ |
| 280 |
private static function coerceString(array $raw, string $key): string { |
| 281 |
if (!isset($raw[$key])) { |
| 282 |
return ''; |
| 283 |
} |
| 284 |
$v = $raw[$key]; |
| 285 |
return is_string($v) ? $v : ''; |
| 286 |
} |
| 287 |
|
| 288 |
/** |
| 289 |
* @param array<mixed, mixed> $raw |
| 290 |
*/ |
| 291 |
private static function coerceNonNegativeInt(array $raw, string $key): int { |
| 292 |
if (!isset($raw[$key])) { |
| 293 |
return 0; |
| 294 |
} |
| 295 |
$v = $raw[$key]; |
| 296 |
if (is_int($v)) { |
| 297 |
return $v < 0 ? 0 : $v; |
| 298 |
} |
| 299 |
// PHP's serialize/unserialize is type-preserving, but some |
| 300 |
// object-cache plugins re-encode through JSON, which makes |
| 301 |
// ints come back as floats or numeric strings. Accept those. |
| 302 |
if (is_float($v)) { |
| 303 |
$i = (int)$v; |
| 304 |
return $i < 0 ? 0 : $i; |
| 305 |
} |
| 306 |
if (is_string($v) && is_numeric($v)) { |
| 307 |
$i = (int)$v; |
| 308 |
return $i < 0 ? 0 : $i; |
| 309 |
} |
| 310 |
return 0; |
| 311 |
} |
| 312 |
|
| 313 |
/** |
| 314 |
* @param array<mixed, mixed> $raw |
| 315 |
* @return array<int, mixed> |
| 316 |
*/ |
| 317 |
private static function coerceSuggestionsPacket(array $raw): array { |
| 318 |
if (!isset($raw['suggestions']) || !is_array($raw['suggestions'])) { |
| 319 |
return []; |
| 320 |
} |
| 321 |
return array_values($raw['suggestions']); |
| 322 |
} |
| 323 |
} |
| 324 |
|