PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / SuggestionTransient.php

SuggestionTransient.php in 404 Solution 4.2.0, at includes/SuggestionTransient.php

324 lines 10.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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