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 / diagnostics / StatusCountOperationJournal.php

StatusCountOperationJournal.php in 404 Solution trunk, at includes/diagnostics/StatusCountOperationJournal.php

414 lines 14.3 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 * Durable, privacy-safe operation protocol for foreground status-count work.
9 *
10 * Owns reserved start/end records, parent correlation, result classification,
11 * error redaction, and the post-cap active-operation fallback. Runtime hook
12 * and cache installation belongs to StatusCountsForegroundTracer.
13 */
14 final class ABJ_404_Solution_StatusCountOperationJournal
15 implements ABJ_404_Solution_CacheOperationTraceSink {
16
17 const MAX_OPERATION_RECORDS = 96;
18
19 /** @var string */
20 private $requestId;
21 /** @var int */
22 private $operationSequence = 0;
23 /** @var int */
24 private $recordCount = 0;
25 /** @var bool */
26 private $cappedRecorded = false;
27 /** @var bool */
28 private $active = false;
29 /** @var bool */
30 private $suspended = false;
31 /** @var bool */
32 private $recording = false;
33 /** @var array<int,string> */
34 private $operationStack = array();
35
36 public function __construct(string $requestId) {
37 $this->requestId = $requestId;
38 }
39
40 public function activate(): void {
41 $this->active = true;
42 }
43
44 public function deactivate(): void {
45 $this->active = false;
46 }
47
48 public function suspend(): void {
49 $this->suspended = true;
50 }
51
52 public function isRecording(): bool {
53 return $this->recording;
54 }
55
56 /** @param array<string,mixed> $fields */
57 public function recordInstrumentation(array $fields): void {
58 $fields['max_records'] = self::MAX_OPERATION_RECORDS;
59 $this->write('status_count_instrumentation', $fields, false);
60 }
61
62 /**
63 * @template T
64 * @param array<string,mixed> $fields
65 * @param callable():T $work
66 * @return T
67 */
68 public function trace(string $operation, array $fields, callable $work) {
69 $token = $this->beginOperation($operation, $fields);
70 if ($token === null) {
71 return $work();
72 }
73 $startedAt = self::nowFloat();
74 try {
75 $result = $work();
76 } catch (Throwable $error) {
77 array_pop($this->operationStack);
78 $this->finishToken($token, 'error', $startedAt, $error);
79 throw $error;
80 }
81 array_pop($this->operationStack);
82 $this->finishToken(
83 $token,
84 $this->resultFor($operation, $fields, $result),
85 $startedAt
86 );
87 return $result;
88 }
89
90 /**
91 * @param mixed $key
92 * @param mixed $group
93 * @template T
94 * @param callable():T $work
95 * @return T
96 */
97 public function traceCache(string $operation, $key, $group, callable $work) {
98 return $this->trace('cache_' . strtolower($operation), array(
99 'kind' => 'cache',
100 'cache_key' => self::hashIdentity($key, 'key'),
101 'cache_group' => self::hashIdentity($group, 'group'),
102 ), $work);
103 }
104
105 /**
106 * @param array{callback:string,source:string,has_reference:bool} $identity
107 * @return array{
108 * mode:string,
109 * identity:array<string,mixed>,
110 * started_at?:float
111 * }|null
112 */
113 public function beginHookCallback(
114 string $hook,
115 int $priority,
116 array $identity
117 ): ?array {
118 $token = $this->beginOperation('hook_callback', array(
119 'kind' => 'hook',
120 'hook' => self::safeHookName($hook),
121 'callback' => $identity['callback'],
122 'source' => $identity['source'],
123 'priority' => ABJ_404_Solution_HookCallbackIdentity::jsonSafePriority($priority),
124 ));
125 if (is_array($token)) {
126 $token['started_at'] = self::nowFloat();
127 }
128 return $token;
129 }
130
131 /**
132 * @param array{
133 * mode:string,
134 * identity:array<string,mixed>,
135 * started_at?:float
136 * }|null $token
137 */
138 public function finishHookCallback($token): void {
139 if (is_array($token)) {
140 array_pop($this->operationStack);
141 }
142 $startedAt = is_array($token) && is_float($token['started_at'] ?? null)
143 ? $token['started_at'] : null;
144 $this->finishToken($token, 'callback_returned', $startedAt);
145 }
146
147 /**
148 * @param array<string,mixed> $fields
149 * @return array{
150 * mode:string,
151 * identity:array<string,mixed>,
152 * started_at?:float
153 * }|null
154 */
155 private function beginOperation(string $operation, array $fields): ?array {
156 if (!$this->active || $this->suspended || $this->recording
157 || (class_exists('ABJ_404_Solution_AjaxCheckpointLogger')
158 && ABJ_404_Solution_AjaxCheckpointLogger::isRecording())) {
159 return null;
160 }
161 $identity = $this->identity($operation, $fields);
162 $parent = end($this->operationStack);
163 if (is_string($parent) && $parent !== '') {
164 $identity['parent_operation_id'] = $parent;
165 }
166 $this->operationStack[] = $identity['operation_id'];
167 if ($this->recordCount + 2 > self::MAX_OPERATION_RECORDS) {
168 $this->recordCappedOnce();
169 ABJ_404_Solution_AjaxCheckpointLogger::recordActiveOperation(
170 $this->requestId,
171 'status_count_operation',
172 'active',
173 $identity
174 );
175 return array('mode' => 'active', 'identity' => $identity);
176 }
177 $this->write('status_count_operation_start', $identity, true);
178 return array('mode' => 'journal', 'identity' => $identity);
179 }
180
181 /**
182 * @param array{
183 * mode:string,
184 * identity:array<string,mixed>,
185 * started_at?:float
186 * }|null $token
187 * @param Throwable|null $error
188 */
189 private function finishToken(
190 $token,
191 string $result,
192 ?float $startedAt = null,
193 $error = null
194 ): void {
195 if (!is_array($token) || !isset($token['identity'])
196 || !is_array($token['identity'])) {
197 return;
198 }
199 if (($token['mode'] ?? '') === 'active') {
200 ABJ_404_Solution_AjaxCheckpointLogger::recordActiveOperation(
201 $this->requestId,
202 'status_count_operation',
203 'complete',
204 $token['identity']
205 );
206 return;
207 }
208 $fields = array_merge($token['identity'], array(
209 'status' => $error instanceof Throwable ? 'error' : 'complete',
210 'elapsed_ms' => is_float($startedAt)
211 ? self::elapsedMilliseconds($startedAt) : null,
212 'result' => $result,
213 ));
214 if ($error instanceof Throwable) {
215 $fields['error'] = self::errorSummary($error);
216 }
217 $this->write('status_count_operation_end', $fields, true);
218 }
219
220 /**
221 * @param array<string,mixed> $fields
222 * @return array{
223 * operation_id:string,
224 * operation:string,
225 * parent_operation_id?:string,
226 * scope?:string,
227 * family?:string,
228 * kind?:string,
229 * hook?:mixed,
230 * callback?:mixed,
231 * source?:mixed,
232 * priority?:mixed,
233 * cache_key?:mixed,
234 * cache_group?:mixed
235 * }
236 */
237 private function identity(string $operation, array $fields): array {
238 $identity = array(
239 'operation_id' => substr(hash(
240 'sha256',
241 $this->requestId . '|' . (++$this->operationSequence) . '|' . $operation
242 ), 0, 12),
243 'operation' => self::safeToken($operation, 'operation'),
244 );
245 foreach (array('scope', 'family', 'kind') as $field) {
246 if (isset($fields[$field]) && is_string($fields[$field])) {
247 $identity[$field] = self::safeToken(
248 str_replace('-', '_', $fields[$field]),
249 $field
250 );
251 }
252 }
253 foreach (array(
254 'hook', 'callback', 'source', 'priority', 'cache_key', 'cache_group',
255 ) as $field) {
256 if (array_key_exists($field, $fields)) {
257 $identity[$field] = $fields[$field];
258 }
259 }
260 return $identity;
261 }
262
263 /**
264 * @param array<string,mixed> $fields
265 * @param mixed $result
266 */
267 private function resultFor(string $operation, array $fields, $result): string {
268 $classifiers = self::resultClassifiers();
269 if (isset($classifiers[$operation])) {
270 return $classifiers[$operation]($fields, $result);
271 }
272 if (strpos($operation, 'cache_') === 0) {
273 return $result === false ? 'miss' : 'hit';
274 }
275 if ($operation === 'status_count_scope' && is_array($result)) {
276 // The redirect/captured scopes resolve to {counts, state}. Keep
277 // that state visible instead of collapsing every result to array.
278 if (isset($result['state']) && is_string($result['state'])
279 && $result['state'] !== '') {
280 return $result['state'];
281 }
282 if (isset($result['_incomplete'])) {
283 return 'missing';
284 }
285 }
286 return is_bool($result) ? ($result ? 'true' : 'false') : gettype($result);
287 }
288
289 /**
290 * @return array<string,callable(array<string,mixed>,mixed):string>
291 */
292 private static function resultClassifiers(): array {
293 return array(
294 'status_cache_read' => static function (array $unusedFields, $value): string {
295 if (!is_array($value) || !empty($value['incomplete'])
296 || (array_key_exists('count', $value) && $value['count'] === null)) {
297 return 'missing';
298 }
299 return !empty($value['needs_refresh']) ? 'stale' : 'hit';
300 },
301 'transient_read' => static function (array $operationFields, $value): string {
302 $expected = $operationFields['expected'] ?? '';
303 if (($expected === 'array' && is_array($value))
304 || ($expected === 'numeric' && is_numeric($value))) {
305 return 'hit';
306 }
307 return $value === false ? 'miss' : 'invalid';
308 },
309 'next_scheduled_check' => static fn(array $unusedFields, $value): string =>
310 $value === false ? 'not_scheduled' : 'already_scheduled',
311 'scheduling_write' => static fn(array $unusedFields, $value): string =>
312 $value === true ? 'newly_scheduled' : 'schedule_failed',
313 'scheduler_resolution' => static fn(array $unusedFields, $value): string =>
314 is_object($value) ? 'resolved' : 'unavailable',
315 'schedule_if_missing' => static fn(array $unusedFields, $value): string =>
316 $value === true ? 'complete' : 'schedule_failed',
317 );
318 }
319
320 private function recordCappedOnce(): void {
321 if ($this->cappedRecorded) {
322 return;
323 }
324 $this->cappedRecorded = true;
325 $this->write('status_count_operation_capped', array(
326 'recorded' => $this->recordCount,
327 'max_records' => self::MAX_OPERATION_RECORDS,
328 ), false);
329 }
330
331 /** @param array<string,mixed> $fields */
332 private function write(string $event, array $fields, bool $countsTowardBudget): void {
333 if ($this->recording) {
334 return;
335 }
336 $this->recording = true;
337 try {
338 ABJ_404_Solution_AjaxCheckpointLogger::recordFrequent(
339 $this->requestId,
340 $event,
341 $fields
342 );
343 if ($countsTowardBudget) {
344 $this->recordCount++;
345 }
346 } catch (Throwable $error) {
347 self::reportFailure('checkpoint failed: ' . $error->getMessage());
348 } finally {
349 $this->recording = false;
350 }
351 }
352
353 private static function safeToken(string $value, string $fallback): string {
354 return preg_match('/^[a-z][a-z0-9_]{0,63}$/', $value) === 1
355 ? $value : $fallback . '#' . substr(hash('sha256', $value), 0, 12);
356 }
357
358 /** @param mixed $value */
359 private static function hashIdentity($value, string $prefix): string {
360 $serialized = is_scalar($value) || $value === null
361 ? (string)$value : serialize($value);
362 return $prefix . '#' . substr(hash('sha256', $serialized), 0, 12);
363 }
364
365 private static function safeHookName(string $hook): string {
366 $families = array(
367 ABJ_404_Solution_StatusCountsRepository::CACHE_KEY_REDIRECT_STATUS =>
368 'redirect_current',
369 ABJ_404_Solution_StatusCountsRepository::CACHE_KEY_REDIRECT_STATUS_LAST_KNOWN =>
370 'redirect_last_known',
371 ABJ_404_Solution_StatusCountsRepository::CACHE_KEY_CAPTURED_STATUS =>
372 'captured_current',
373 ABJ_404_Solution_StatusCountsRepository::CACHE_KEY_CAPTURED_STATUS_LAST_KNOWN =>
374 'captured_last_known',
375 ABJ_404_Solution_StatusCountsRepository::CACHE_KEY_HIGH_IMPACT_CAPTURED =>
376 'high_impact_current',
377 ABJ_404_Solution_StatusCountsRepository::CACHE_KEY_HIGH_IMPACT_CAPTURED_LAST_KNOWN =>
378 'high_impact_last_known',
379 );
380 foreach ($families as $key => $family) {
381 if (strpos($hook, $key) !== false) {
382 $hook = str_replace($key, 'status_count_' . $family, $hook);
383 }
384 }
385 return ABJ_404_Solution_HookCallbackIdentity::hookName($hook);
386 }
387
388 private static function nowFloat(): float {
389 if (function_exists('abj_clock')) {
390 return abj_clock()->nowFloat();
391 }
392 return class_exists('ABJ_404_Solution_SystemClock')
393 ? (new ABJ_404_Solution_SystemClock())->nowFloat()
394 : 0.0;
395 }
396
397 private static function elapsedMilliseconds(float $startedAt): int {
398 return max(0, (int)round((self::nowFloat() - $startedAt) * 1000));
399 }
400
401 /** @return array{class:string,code:int,message:string} */
402 private static function errorSummary(Throwable $error): array {
403 return array(
404 'class' => get_class($error),
405 'code' => (int)$error->getCode(),
406 'message' => 'message#' . substr(hash('sha256', $error->getMessage()), 0, 12),
407 );
408 }
409
410 private static function reportFailure(string $message): void {
411 abj404_logPhpFallback('status-count-operation-journal', $message);
412 }
413 }
414