PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / settings / SuggestionTransient.php

SuggestionTransient.php in 404 Solution trunk, at includes/settings/SuggestionTransient.php

360 lines 11.9 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_SuggestionPublisher::triggerAsyncSuggestions
16 * (creates 'pending', started=0, with a fresh token)
17 * 2. ABJ_404_Solution_SuggestionPublisher::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 /** Transient lifetimes for the three producer states. */
64 public const PENDING_TTL_SECONDS = 120;
65 public const COMPLETE_TTL_SECONDS = 300;
66 public const ERROR_TTL_SECONDS = 120;
67
68 /** Prefix shared by every producer and consumer of suggestion state. */
69 private const TRANSIENT_PREFIX = 'abj404_suggest_';
70
71 /**
72 * Worker is presumed dead after this many seconds since claim
73 * (started > 0). Matches the recovery window in
74 * Ajax_SuggestionCompute::computeSuggestions; if changed there, change
75 * here as well (the constant is the single source of truth post-VO).
76 */
77 public const WORKER_STUCK_SECONDS = 90;
78
79 /**
80 * Dispatch is presumed dead after this many seconds since transient
81 * creation when no worker has claimed (started == 0). Mirrors the
82 * dispatch-no-show window in Ajax_SuggestionPolling.
83 */
84 public const DISPATCH_STUCK_SECONDS = 15;
85
86 /**
87 * Normalize a requested URL once for both storage and key derivation.
88 */
89 public static function normalizedUrl(string $requestedUrl): string {
90 return abj_service('url_encoder')->normalizeURLForCacheKey($requestedUrl);
91 }
92
93 /**
94 * Return the one transient key used by all suggestion-state participants.
95 */
96 public static function transientKeyForNormalizedUrl(string $normalizedUrl): string {
97 // allow-url-key: the method accepts only the normalized-URL identity produced by normalizedUrl().
98 return self::TRANSIENT_PREFIX . md5($normalizedUrl);
99 }
100
101 /**
102 * Normalize a requested URL and return its suggestion transient key.
103 */
104 public static function transientKeyForRequestedUrl(string $requestedUrl): string {
105 return self::transientKeyForNormalizedUrl(self::normalizedUrl($requestedUrl));
106 }
107
108 /** Shared synchronization key for all writers of one URL's state. */
109 public static function lockKeyForNormalizedUrl(string $normalizedUrl): string {
110 // allow-url-key: the method accepts only the normalized-URL identity produced by normalizedUrl().
111 return 'suggestion-state-' . md5($normalizedUrl);
112 }
113
114 /** @var string */
115 private $status;
116
117 /** @var string */
118 private $url;
119
120 /** @var string */
121 private $token;
122
123 /** @var int */
124 private $startedAt;
125
126 /** @var int */
127 private $createdAt;
128
129 /** @var int */
130 private $completedAt;
131
132 /** @var array<int, mixed> */
133 private $suggestionsPacket;
134
135 /**
136 * @param array<int, mixed> $suggestionsPacket
137 */
138 private function __construct(
139 string $status,
140 string $url,
141 string $token,
142 int $startedAt,
143 int $createdAt,
144 int $completedAt,
145 array $suggestionsPacket
146 ) {
147 $this->status = $status;
148 $this->url = $url;
149 $this->token = $token;
150 $this->startedAt = $startedAt;
151 $this->createdAt = $createdAt;
152 $this->completedAt = $completedAt;
153 $this->suggestionsPacket = $suggestionsPacket;
154 }
155
156 /**
157 * Normalize a raw get_transient() return into a typed VO, or null
158 * when the payload is unrecoverably malformed (not an array, missing
159 * status, status not in the documented enum).
160 *
161 * Callers branch on null vs. VO; they MUST NOT shape-probe the raw
162 * payload themselves.
163 *
164 * @param mixed $raw
165 */
166 public static function fromRaw($raw): ?self {
167 if (!is_array($raw)) {
168 return null;
169 }
170 if (!isset($raw['status']) || !is_string($raw['status'])) {
171 return null;
172 }
173 $status = $raw['status'];
174 if ($status !== self::STATUS_PENDING
175 && $status !== self::STATUS_COMPLETE
176 && $status !== self::STATUS_ERROR
177 ) {
178 return null;
179 }
180
181 $url = self::coerceString($raw, 'url');
182 $token = self::coerceString($raw, 'token');
183 $startedAt = self::coerceNonNegativeInt($raw, 'started');
184 $createdAt = self::coerceNonNegativeInt($raw, 'created');
185 $completedAt = self::coerceNonNegativeInt($raw, 'completed');
186 $packet = self::coerceSuggestionsPacket($raw);
187
188 return new self($status, $url, $token, $startedAt, $createdAt, $completedAt, $packet);
189 }
190
191 /**
192 * Build the array shape for a freshly-triggered pending transient
193 * (before any worker has claimed). Producer side of the boundary.
194 *
195 * @return array{status: string, url: string, started: int, created: int, token: string}
196 */
197 public static function pendingArray(string $url, string $token, int $startedAt, int $createdAt): array {
198 return [
199 'status' => self::STATUS_PENDING,
200 'url' => $url,
201 'started' => max(0, $startedAt),
202 'created' => max(0, $createdAt),
203 'token' => $token,
204 ];
205 }
206
207 /**
208 * Build the array shape for a completed computation. Producer side
209 * of the boundary.
210 *
211 * @param array<int, mixed> $suggestionsPacket Two-tuple from spell-checker.
212 * @return array{status: string, url: string, suggestions: array<int, mixed>, completed: int, token: string}
213 */
214 public static function completeArray(string $url, array $suggestionsPacket, int $completedAt, string $token): array {
215 return [
216 'status' => self::STATUS_COMPLETE,
217 'url' => $url,
218 'suggestions' => $suggestionsPacket,
219 'completed' => max(0, $completedAt),
220 'token' => $token,
221 ];
222 }
223
224 /**
225 * Build the array shape for the shutdown-handler crash marker.
226 * Producer side of the boundary.
227 *
228 * @return array{status: string, token: string}
229 */
230 public static function errorArray(string $token): array {
231 return [
232 'status' => self::STATUS_ERROR,
233 'token' => $token,
234 ];
235 }
236
237 public function getStatus(): string {
238 return $this->status;
239 }
240
241 public function isPending(): bool {
242 return $this->status === self::STATUS_PENDING;
243 }
244
245 public function isComplete(): bool {
246 return $this->status === self::STATUS_COMPLETE;
247 }
248
249 public function isError(): bool {
250 return $this->status === self::STATUS_ERROR;
251 }
252
253 public function getUrl(): string {
254 return $this->url;
255 }
256
257 public function getToken(): string {
258 return $this->token;
259 }
260
261 public function getStartedAt(): int {
262 return $this->startedAt;
263 }
264
265 public function getCreatedAt(): int {
266 return $this->createdAt;
267 }
268
269 public function getCompletedAt(): int {
270 return $this->completedAt;
271 }
272
273 /**
274 * True iff a worker has set started > 0 (claimed the work).
275 */
276 public function isClaimed(): bool {
277 return $this->startedAt > 0;
278 }
279
280 /**
281 * @return array<int, mixed>
282 */
283 public function getSuggestionsPacket(): array {
284 return $this->suggestionsPacket;
285 }
286
287 /**
288 * True iff a worker claimed the job but didn't finish within
289 * WORKER_STUCK_SECONDS. Only meaningful for status=pending. When
290 * unclaimed (started=0), always returns false.
291 */
292 public function isWorkerStuck(int $now): bool {
293 if ($this->startedAt <= 0) {
294 return false;
295 }
296 return ($now - $this->startedAt) > self::WORKER_STUCK_SECONDS;
297 }
298
299 /**
300 * True iff no worker has claimed the job and the dispatch window
301 * has expired since creation. Only meaningful for status=pending.
302 */
303 public function isDispatchStuck(int $now): bool {
304 if ($this->startedAt > 0) {
305 return false;
306 }
307 if ($this->createdAt <= 0) {
308 return false;
309 }
310 return ($now - $this->createdAt) > self::DISPATCH_STUCK_SECONDS;
311 }
312
313 /**
314 * @param array<mixed, mixed> $raw
315 */
316 private static function coerceString(array $raw, string $key): string {
317 if (!isset($raw[$key])) {
318 return '';
319 }
320 $v = $raw[$key];
321 return is_string($v) ? $v : '';
322 }
323
324 /**
325 * @param array<mixed, mixed> $raw
326 */
327 private static function coerceNonNegativeInt(array $raw, string $key): int {
328 if (!isset($raw[$key])) {
329 return 0;
330 }
331 $v = $raw[$key];
332 if (is_int($v)) {
333 return $v < 0 ? 0 : $v;
334 }
335 // PHP's serialize/unserialize is type-preserving, but some
336 // object-cache plugins re-encode through JSON, which makes
337 // ints come back as floats or numeric strings. Accept those.
338 if (is_float($v)) {
339 $i = (int)$v;
340 return $i < 0 ? 0 : $i;
341 }
342 if (is_string($v) && is_numeric($v)) {
343 $i = (int)$v;
344 return $i < 0 ? 0 : $i;
345 }
346 return 0;
347 }
348
349 /**
350 * @param array<mixed, mixed> $raw
351 * @return array<int, mixed>
352 */
353 private static function coerceSuggestionsPacket(array $raw): array {
354 if (!isset($raw['suggestions']) || !is_array($raw['suggestions'])) {
355 return [];
356 }
357 return array_values($raw['suggestions']);
358 }
359 }
360