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

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

296 lines 9.6 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 attribution for table-preference option persistence.
9 *
10 * The tracer is active only during ajaxUpdatePaginationLinks rows-per-page or
11 * sort-preference writes. It brackets preference normalization, option reads,
12 * storage-contract normalization, WordPress storage/cache work, repository
13 * cache refresh, and every callback registered on the relevant option
14 * lifecycle hooks. Values and callback arguments are never inspected or
15 * persisted.
16 *
17 * A callback or operation that does not return leaves its start record
18 * unmatched. Every decorated callback is restored in a finally path without
19 * overwriting callbacks another participant changed while the hook ran.
20 */
21 final class ABJ_404_Solution_OptionPersistenceTracer {
22
23 const OPTION_HOOKS = array(
24 'all',
25 'pre_update_option_abj404_settings',
26 'pre_update_option',
27 'update_option',
28 'update_option_abj404_settings',
29 'updated_option',
30 );
31
32 /** @var self|null */
33 private static $active = null;
34 /** @var string */
35 private $requestId;
36 /** @var int */
37 private $operationSequence = 0;
38 /** @var int */
39 private $scopeDepth = 1;
40 /** @var bool */
41 private $recording = false;
42 /** @var ABJ_404_Solution_HookCallbackInstrumenter<array{fields: array<string, mixed>, started_at: float|null}|null> */
43 private $hookInstrumenter;
44 /** @var ABJ_404_Solution_HookInstrumentationLifecycleTracer */
45 private $lifecycleTracer;
46
47 public static function begin(): ?self {
48 $requestId = self::currentRequestId();
49 if ($requestId === '') {
50 return null;
51 }
52 if (self::$active !== null && self::$active->requestId === $requestId) {
53 self::$active->scopeDepth++;
54 return self::$active;
55 }
56 self::$active = new self($requestId);
57 return self::$active;
58 }
59
60 private function __construct(string $requestId) {
61 $this->requestId = $requestId;
62 $this->lifecycleTracer = new ABJ_404_Solution_HookInstrumentationLifecycleTracer(
63 $requestId,
64 'option_persistence'
65 );
66 $this->hookInstrumenter = new ABJ_404_Solution_HookCallbackInstrumenter(
67 function (
68 string $registeredHook,
69 string $actualHook,
70 int $priority,
71 array $identity
72 ) {
73 return $this->beginHookCallback($actualHook, $priority, $identity);
74 },
75 function ($token): void {
76 $this->finishHookCallback($token);
77 },
78 $this->lifecycleTracer
79 );
80 }
81
82 public function finish(): void {
83 $this->scopeDepth--;
84 if ($this->scopeDepth > 0) {
85 return;
86 }
87 $this->restoreHookCallbacks();
88 if (self::$active === $this) {
89 self::$active = null;
90 }
91 }
92
93 /**
94 * @template T
95 * @param callable():T $work
96 * @return T
97 */
98 public static function traceCurrent(string $operation, callable $work) {
99 return self::$active === null
100 ? $work()
101 : self::$active->traceOperation($operation, $work);
102 }
103
104 /**
105 * @template T
106 * @param callable():T $work
107 * @return T
108 */
109 public static function traceCurrentStorageWrite(callable $work) {
110 if (self::$active === null) {
111 return $work();
112 }
113 return self::$active->traceStorageWrite($work);
114 }
115
116 /**
117 * @template T
118 * @param callable():T $work
119 * @return T
120 */
121 public function traceOperation(string $operation, callable $work) {
122 return $this->trace(
123 'option_operation',
124 array('operation' => substr($operation, 0, 64)),
125 $work
126 );
127 }
128
129 /**
130 * @template T
131 * @param callable():T $work
132 * @return T
133 */
134 private function traceStorageWrite(callable $work) {
135 $this->installHookCallbacks();
136 try {
137 $result = $this->traceOperation('storage_write_cache_invalidation', $work);
138 } catch (Throwable $e) {
139 $this->restoreHookCallbacks(false);
140 throw $e;
141 }
142 $this->restoreHookCallbacks(true);
143 return $result;
144 }
145
146 private function installHookCallbacks(): void {
147 $wrappedCount = 0;
148 $markedCount = 0;
149 $unavailableCount = 0;
150 $registryUnavailable = false;
151 $reason = '';
152 foreach (self::OPTION_HOOKS as $hookName) {
153 $counts = $this->hookInstrumenter->instrument($hookName);
154 $wrappedCount += $counts['callbacks_wrapped'];
155 $markedCount += $counts['callbacks_marked'];
156 $unavailableCount += $counts['callbacks_unavailable'];
157 if ($counts['registry_status'] === 'unavailable') {
158 $registryUnavailable = true;
159 $reason = (string)($counts['registry_reason'] ?? 'hook_registry_unavailable');
160 }
161 }
162 $status = array(
163 'status' => $registryUnavailable
164 ? 'unavailable'
165 : ($unavailableCount === 0 ? 'ready' : 'partial'),
166 'hooks_scanned' => count(self::OPTION_HOOKS),
167 'callbacks_wrapped' => $wrappedCount,
168 'callbacks_marked' => $markedCount,
169 'callbacks_attributed' => $wrappedCount + $markedCount,
170 'callbacks_unavailable' => $unavailableCount,
171 );
172 if ($reason !== '') {
173 $status['reason'] = $reason;
174 }
175 $this->write('option_hook_instrumentation', $status);
176 }
177
178 /**
179 * @param array{callback: string, source: string, has_reference: bool} $identity
180 * @return array{fields: array<string, mixed>, started_at: float|null}|null
181 */
182 private function beginHookCallback(
183 string $actualHook,
184 int $priority,
185 array $identity
186 ): ?array {
187 if ($this->recording || $this->lifecycleTracer->isRecording()) {
188 return null;
189 }
190 $fields = array(
191 'hook' => ABJ_404_Solution_HookCallbackIdentity::hookName($actualHook),
192 'callback' => $identity['callback'],
193 'source' => $identity['source'],
194 'priority' => ABJ_404_Solution_HookCallbackIdentity::jsonSafePriority($priority),
195 );
196 $fields['operation_id'] = $this->operationId('option_hook_callback', $fields);
197 $this->write('option_hook_callback_start', $fields);
198 return array('fields' => $fields, 'started_at' => self::nowFloat());
199 }
200
201 /** @param array{fields: array<string, mixed>, started_at: float|null}|null $token */
202 private function finishHookCallback($token): void {
203 if (!is_array($token)) {
204 return;
205 }
206 $this->write('option_hook_callback_end', array_merge($token['fields'], array(
207 'status' => 'complete',
208 'elapsed_ms' => self::elapsedMilliseconds($token['started_at']),
209 )));
210 }
211
212 /**
213 * @template T
214 * @param array<string, mixed> $fields
215 * @param callable():T $work
216 * @return T
217 */
218 private function trace(string $eventPrefix, array $fields, callable $work) {
219 if ($this->recording) {
220 return $work();
221 }
222 $fields['operation_id'] = $this->operationId($eventPrefix, $fields);
223 $this->write($eventPrefix . '_start', $fields);
224 $startedAt = self::nowFloat();
225 try {
226 $result = $work();
227 } catch (Throwable $e) {
228 throw $e;
229 }
230 $this->write($eventPrefix . '_end', array_merge($fields, array(
231 'status' => 'complete',
232 'elapsed_ms' => self::elapsedMilliseconds($startedAt),
233 )));
234 return $result;
235 }
236
237 /** @param array<string, mixed> $fields */
238 private function operationId(string $eventPrefix, array $fields): string {
239 $this->operationSequence++;
240 return substr(hash(
241 'sha256',
242 $this->requestId . '|' . $this->operationSequence . '|' . $eventPrefix . '|' . serialize($fields)
243 ), 0, 12);
244 }
245
246 /** @param array<string, mixed> $fields */
247 private function write(string $event, array $fields): void {
248 if ($this->recording) {
249 return;
250 }
251 $this->recording = true;
252 try {
253 ABJ_404_Solution_AjaxCheckpointLogger::recordFrequent(
254 $this->requestId,
255 $event,
256 $fields
257 );
258 } catch (Throwable $e) {
259 self::reportFailure('checkpoint write failed: ' . $e->getMessage());
260 } finally {
261 $this->recording = false;
262 }
263 }
264
265 private function restoreHookCallbacks(bool $scopeCompleted = true): void {
266 $this->hookInstrumenter->restore($scopeCompleted);
267 }
268
269 private static function currentRequestId(): string {
270 if (!class_exists('ABJ_404_Solution_AjaxDiagnosticRequestPolicy')) {
271 return '';
272 }
273 return ABJ_404_Solution_AjaxDiagnosticRequestPolicy::instrumentedRequestIdFromGlobalContext();
274 }
275
276 private static function nowFloat(): ?float {
277 if (function_exists('abj_clock')) {
278 return abj_clock()->nowFloat();
279 }
280 if (class_exists('ABJ_404_Solution_SystemClock')) {
281 return (new ABJ_404_Solution_SystemClock())->nowFloat();
282 }
283 return null;
284 }
285
286 private static function elapsedMilliseconds(?float $startedAt): ?int {
287 return $startedAt === null
288 ? null
289 : max(0, (int)round((self::nowFloat() - $startedAt) * 1000));
290 }
291
292 private static function reportFailure(string $message): void {
293 abj404_logPhpFallback('option-persistence-tracer', $message);
294 }
295 }
296