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 / SortReadinessTracer.php

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

312 lines 11.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 * Request-scoped attribution for pre-query sort-readiness evaluation.
9 *
10 * The readiness authority probes live schema and reads two migration latches
11 * before every redirects/captured query. During a latch read this tracer also
12 * decorates the relevant WordPress option callbacks and object-cache object,
13 * restoring both even when platform code throws. Option and cache values are
14 * never recorded.
15 */
16 final class ABJ_404_Solution_SortReadinessTracer
17 implements ABJ_404_Solution_CacheOperationTraceSink {
18
19 /** @var int */
20 private static $operationSequence = 0;
21 /** @var bool */
22 private static $recording = false;
23 /** @var array<int,string> */
24 private static $operationStack = array();
25
26 /**
27 * @template T
28 * @param array<string,mixed> $fields
29 * @param callable():T $work
30 * @return T
31 */
32 public static function trace(string $operation, array $fields, callable $work) {
33 $requestId = self::requestId();
34 if ($requestId === '' || self::$recording) {
35 return $work();
36 }
37 $tracer = new self($requestId);
38 return $operation === 'latch_option_read'
39 ? $tracer->traceLatchRead($operation, $fields, $work)
40 : $tracer->traceOperation($operation, $fields, $work);
41 }
42
43 /** @var string */
44 private $requestId;
45 /** @var ABJ_404_Solution_HookInstrumentationLifecycleTracer */
46 private $lifecycleTracer;
47 /** @var ABJ_404_Solution_HookCallbackInstrumenter<array<string,mixed>> */
48 private $hookInstrumenter;
49 /** @var object|null */
50 private $originalCache;
51 /** @var ABJ_404_Solution_InstrumentedObjectCache|null */
52 private $cacheProxy;
53
54 private function __construct(string $requestId) {
55 $this->requestId = $requestId;
56 $this->lifecycleTracer = new ABJ_404_Solution_HookInstrumentationLifecycleTracer(
57 $requestId,
58 'sort_readiness'
59 );
60 $this->hookInstrumenter = new ABJ_404_Solution_HookCallbackInstrumenter(
61 function (
62 string $registeredHook,
63 string $actualHook,
64 int $priority,
65 array $identity
66 ) {
67 return $this->beginHookCallback($actualHook, $priority, $identity);
68 },
69 function ($token): void {
70 $this->finishToken($token);
71 },
72 $this->lifecycleTracer
73 );
74 }
75
76 /**
77 * @template T
78 * @param array<string,mixed> $fields
79 * @param callable():T $work
80 * @return T
81 */
82 private function traceLatchRead(string $operation, array $fields, callable $work) {
83 $option = isset($fields['option']) && is_string($fields['option'])
84 ? $fields['option'] : '';
85 $hooks = array(
86 'pre_option_' . $option,
87 'pre_option',
88 'default_option_' . $option,
89 'option_' . $option,
90 );
91 $counts = array('wrapped' => 0, 'marked' => 0, 'unavailable' => 0);
92 foreach ($hooks as $hook) {
93 $result = $this->hookInstrumenter->instrument($hook);
94 $counts['wrapped'] += $result['callbacks_wrapped'];
95 $counts['marked'] += $result['callbacks_marked'];
96 $counts['unavailable'] += $result['callbacks_unavailable'];
97 }
98 $this->installCacheProxy();
99 $this->write('sort_readiness_instrumentation', array(
100 'hooks_scanned' => count($hooks),
101 'callbacks_wrapped' => $counts['wrapped'],
102 'callbacks_marked' => $counts['marked'],
103 'callbacks_attributed' => $counts['wrapped'] + $counts['marked'],
104 'callbacks_unavailable' => $counts['unavailable'],
105 'cache_boundary' => $this->cacheProxy === null ? 'unavailable' : 'ready',
106 ));
107 try {
108 $result = $this->traceOperation($operation, $fields, $work);
109 } catch (Throwable $error) {
110 $this->restore(false);
111 throw $error;
112 }
113 $this->restore(true);
114 return $result;
115 }
116
117 /**
118 * @template T
119 * @param array<string,mixed> $fields
120 * @param callable():T $work
121 * @return T
122 */
123 private function traceOperation(string $operation, array $fields, callable $work) {
124 $identity = $this->identity($operation, $fields);
125 $this->write('sort_readiness_operation_start', $identity);
126 self::$operationStack[] = $identity['operation_id'];
127 $startedAt = self::nowFloat();
128 try {
129 $result = $work();
130 } catch (Throwable $error) {
131 array_pop(self::$operationStack);
132 $this->write('sort_readiness_operation_end', array_merge($identity, array(
133 'status' => 'error',
134 'elapsed_ms' => self::elapsedMilliseconds($startedAt),
135 'result' => 'error',
136 'error' => self::errorSummary($error),
137 )));
138 throw $error;
139 }
140 array_pop(self::$operationStack);
141 $this->write('sort_readiness_operation_end', array_merge($identity, array(
142 'status' => 'complete',
143 'elapsed_ms' => self::elapsedMilliseconds($startedAt),
144 'result' => is_bool($result) ? ($result ? 'true' : 'false') : gettype($result),
145 )));
146 return $result;
147 }
148
149 /**
150 * Called by ABJ_404_Solution_InstrumentedObjectCache.
151 *
152 * @template T
153 * @param mixed $key
154 * @param mixed $group
155 * @param callable():T $work
156 * @return T
157 */
158 public function traceCache(string $operation, $key, $group, callable $work) {
159 return $this->traceOperation('cache_' . $operation, array(
160 'kind' => 'cache',
161 'cache_key' => self::hashIdentity($key, 'key'),
162 'cache_group' => self::hashIdentity($group, 'group'),
163 ), $work);
164 }
165
166 /**
167 * @param array{callback:string,source:string,has_reference:bool} $identity
168 * @return array<string,mixed>
169 */
170 private function beginHookCallback(string $hook, int $priority, array $identity): array {
171 $fields = array(
172 'kind' => 'hook',
173 'hook' => ABJ_404_Solution_HookCallbackIdentity::hookName($hook),
174 'callback' => $identity['callback'],
175 'source' => $identity['source'],
176 'priority' => ABJ_404_Solution_HookCallbackIdentity::jsonSafePriority($priority),
177 );
178 $token = $this->identity('option_callback', $fields);
179 $this->write('sort_readiness_operation_start', $token);
180 return array('identity' => $token, 'started_at' => self::nowFloat());
181 }
182
183 /** @param array<string,mixed>|null $token */
184 private function finishToken($token): void {
185 if (!is_array($token) || !isset($token['identity']) || !is_array($token['identity'])) {
186 return;
187 }
188 $startedAt = $token['started_at'] ?? null;
189 $this->write('sort_readiness_operation_end', array_merge($token['identity'], array(
190 'status' => 'complete',
191 'elapsed_ms' => is_float($startedAt)
192 ? self::elapsedMilliseconds($startedAt) : null,
193 'result' => 'callback_returned',
194 )));
195 }
196
197 private function installCacheProxy(): void {
198 $cache = $GLOBALS['wp_object_cache'] ?? null;
199 if (!is_object($cache) || $cache instanceof ABJ_404_Solution_InstrumentedObjectCache) {
200 return;
201 }
202 $this->originalCache = $cache;
203 $this->cacheProxy = new ABJ_404_Solution_InstrumentedObjectCache($cache, $this);
204 $GLOBALS['wp_object_cache'] = $this->cacheProxy;
205 }
206
207 private function restore(bool $scopeCompleted): void {
208 $this->hookInstrumenter->restore($scopeCompleted);
209 if ($this->cacheProxy !== null && ($GLOBALS['wp_object_cache'] ?? null) === $this->cacheProxy) {
210 $GLOBALS['wp_object_cache'] = $this->originalCache;
211 }
212 }
213
214 /**
215 * @param array<string,mixed> $fields
216 * @return array{
217 * operation_id:string,
218 * operation:string,
219 * parent_operation_id?:string,
220 * column?:string,
221 * kind?:string,
222 * cache_key?:mixed,
223 * cache_group?:mixed,
224 * hook?:mixed,
225 * callback?:mixed,
226 * source?:mixed,
227 * priority?:mixed,
228 * option_id?:string
229 * }
230 */
231 private function identity(string $operation, array $fields): array {
232 $identity = array(
233 'operation_id' => substr(hash(
234 'sha256',
235 $this->requestId . '|' . (++self::$operationSequence) . '|' . $operation
236 ), 0, 12),
237 'operation' => self::safeToken($operation, 'operation'),
238 );
239 $parent = end(self::$operationStack);
240 if (is_string($parent) && $parent !== '') {
241 $identity['parent_operation_id'] = $parent;
242 }
243 foreach (array('column', 'kind') as $field) {
244 if (isset($fields[$field]) && is_string($fields[$field])) {
245 $identity[$field] = self::safeToken($fields[$field], $field);
246 }
247 }
248 foreach (array('cache_key', 'cache_group', 'hook', 'callback', 'source', 'priority') as $field) {
249 if (array_key_exists($field, $fields)) {
250 $identity[$field] = $fields[$field];
251 }
252 }
253 if (isset($fields['option']) && is_string($fields['option'])) {
254 $identity['option_id'] = self::hashIdentity($fields['option'], 'option');
255 }
256 return $identity;
257 }
258
259 /** @param array<string,mixed> $fields */
260 private function write(string $event, array $fields): void {
261 self::$recording = true;
262 try {
263 ABJ_404_Solution_AjaxCheckpointLogger::recordFrequent($this->requestId, $event, $fields);
264 } catch (Throwable $error) {
265 abj404_logPhpFallback('sort-readiness-tracer', $error->getMessage());
266 } finally {
267 self::$recording = false;
268 }
269 }
270
271 private static function requestId(): string {
272 return class_exists('ABJ_404_Solution_AjaxDiagnosticRequestPolicy')
273 ? ABJ_404_Solution_AjaxDiagnosticRequestPolicy::instrumentedRequestIdFromGlobalContext()
274 : '';
275 }
276
277 private static function safeToken(string $value, string $fallback): string {
278 return preg_match('/^[a-z][a-z0-9_]{0,63}$/', $value) === 1
279 ? $value : $fallback . '#' . substr(hash('sha256', $value), 0, 12);
280 }
281
282 /** @param mixed $value */
283 private static function hashIdentity($value, string $prefix): string {
284 $serialized = is_scalar($value) || $value === null
285 ? (string)$value : serialize($value);
286 return $prefix . '#' . substr(hash('sha256', $serialized), 0, 12);
287 }
288
289 private static function nowFloat(): float {
290 if (function_exists('abj_clock')) {
291 return abj_clock()->nowFloat();
292 }
293 return class_exists('ABJ_404_Solution_SystemClock')
294 ? (new ABJ_404_Solution_SystemClock())->nowFloat()
295 : 0.0;
296 }
297
298 private static function elapsedMilliseconds(float $startedAt): int {
299 $now = self::nowFloat();
300 return max(0, (int)round(($now - $startedAt) * 1000));
301 }
302
303 /** @return array{class:string,code:int,message:string} */
304 private static function errorSummary(Throwable $error): array {
305 return array(
306 'class' => get_class($error),
307 'code' => (int)$error->getCode(),
308 'message' => 'message#' . substr(hash('sha256', $error->getMessage()), 0, 12),
309 );
310 }
311 }
312