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

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

292 lines 9.7 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 evidence for option I/O reached during render scopes.
9 *
10 * The journal owns record budgets and result classification. Runtime object
11 * replacement and WordPress query-filter installation belong to
12 * RenderOptionIoTracer.
13 *
14 * allow-no-test-found: real-entry coverage is in tests/AjaxPaginationOptionAttributionTest.php
15 */
16 final class ABJ_404_Solution_RenderOptionIoOperationJournal
17 implements ABJ_404_Solution_CacheOperationTraceSink {
18
19 const MAX_RECORDS = 48;
20
21 /** @var string */
22 private $requestId;
23 /** @var callable():string */
24 private $phase;
25 /** @var callable():void */
26 private $beforeCache;
27 /** @var array{backend:string,backend_class:string} */
28 private $backend;
29 /** @var int */
30 private $sequence = 0;
31 /** @var int */
32 private $recordCount = 0;
33 /** @var bool */
34 private $recording = false;
35 /** @var bool */
36 private $capped = false;
37
38 /**
39 * @param callable():string $phase
40 * @param callable():void $beforeCache
41 * @param array{backend:string,backend_class:string} $backend
42 */
43 public function __construct(
44 string $requestId,
45 callable $phase,
46 callable $beforeCache,
47 array $backend
48 ) {
49 $this->requestId = $requestId;
50 $this->phase = $phase;
51 $this->beforeCache = $beforeCache;
52 $this->backend = $backend;
53 }
54
55 /** @param array<string,mixed> $fields */
56 public function recordInstrumentation(array $fields): void {
57 $this->write('render_option_io_instrumentation', array_merge(
58 array(
59 'phase' => $this->currentPhase(),
60 'backend' => $this->backend['backend'],
61 'backend_class' => $this->backend['backend_class'],
62 'max_records' => self::MAX_RECORDS,
63 ),
64 $fields
65 ), false);
66 }
67
68 /**
69 * @param mixed $key
70 * @param mixed $group
71 * @template T
72 * @param callable():T $work
73 * @return T
74 */
75 public function traceCache(string $operation, $key, $group, callable $work) {
76 call_user_func($this->beforeCache);
77 if ($this->recording) {
78 return $work();
79 }
80 $identity = array_merge(
81 $this->newIdentity(strtolower($operation)),
82 self::cacheIdentity($key, $group),
83 $this->backend
84 );
85 $token = $this->start('render_option_cache_start', $identity);
86 if ($token === null) {
87 return $work();
88 }
89 $startedAt = self::nowFloat();
90 try {
91 $result = $work();
92 } catch (Throwable $error) {
93 $this->leaveUnmatched($token);
94 throw $error;
95 }
96 $this->finish(
97 'render_option_cache_end',
98 $token,
99 array_merge(
100 self::resultFields($result),
101 array('elapsed_ms' => self::elapsedMilliseconds($startedAt))
102 )
103 );
104 return $result;
105 }
106
107 /**
108 * Announce a direct WordPress option-table query immediately before wpdb
109 * enters its driver path. SQL text is represented only by a short hash.
110 *
111 * @return array{mode:string,identity:array<string,mixed>}|null
112 */
113 public function beginOptionQuery(string $operation, string $query): ?array {
114 $identity = array_merge(
115 $this->newIdentity($operation),
116 array('query_id' => 'query#' . substr(hash('sha256', $query), 0, 12))
117 );
118 return $this->start('render_option_query_driver_entry', $identity);
119 }
120
121 /**
122 * @param array{mode:string,identity:array<string,mixed>}|null $token
123 * @param mixed $wpdb
124 */
125 public function completeOptionQuery($token, $wpdb): void {
126 $rows = is_object($wpdb) && isset($wpdb->last_result)
127 && is_array($wpdb->last_result) ? count($wpdb->last_result) : 0;
128 $status = is_object($wpdb) && isset($wpdb->last_error)
129 && is_string($wpdb->last_error) && $wpdb->last_error !== ''
130 ? 'error' : 'complete';
131 $this->finish('render_option_query_driver_return', $token, array(
132 'status' => $status,
133 'result_size' => $rows,
134 'result_size_unit' => 'rows',
135 ));
136 }
137
138 /**
139 * @param array<string,mixed> $identity
140 * @return array{mode:string,identity:array<string,mixed>}|null
141 */
142 private function start(string $event, array $identity): ?array {
143 if ($this->recording) {
144 return null;
145 }
146 if ($this->recordCount + 2 > self::MAX_RECORDS) {
147 $this->recordCapOnce();
148 ABJ_404_Solution_AjaxCheckpointLogger::recordActiveOperation(
149 $this->requestId,
150 'render_option_io',
151 'active',
152 $identity
153 );
154 return array('mode' => 'active', 'identity' => $identity);
155 }
156 $this->write($event, $identity, true);
157 return array('mode' => 'journal', 'identity' => $identity);
158 }
159
160 /**
161 * @param array{mode:string,identity:array<string,mixed>}|null $token
162 * @param array<string,mixed> $fields
163 */
164 private function finish(string $event, $token, array $fields): void {
165 if (!is_array($token)) {
166 return;
167 }
168 if (($token['mode'] ?? '') === 'active') {
169 ABJ_404_Solution_AjaxCheckpointLogger::recordActiveOperation(
170 $this->requestId,
171 'render_option_io',
172 'complete',
173 $token['identity']
174 );
175 return;
176 }
177 $this->write($event, array_merge($token['identity'], $fields), true);
178 }
179
180 /** @param array{mode:string,identity:array<string,mixed>} $token */
181 private function leaveUnmatched(array $token): void {
182 if (($token['mode'] ?? '') !== 'active') {
183 return;
184 }
185 ABJ_404_Solution_AjaxCheckpointLogger::recordActiveOperation(
186 $this->requestId,
187 'render_option_io',
188 'active',
189 $token['identity']
190 );
191 }
192
193 /** @return array{operation_id:string,phase:string,operation:string} */
194 private function newIdentity(string $operation): array {
195 $safeOperation = preg_match('/^[a-z][a-z0-9_]{0,47}$/', $operation) === 1
196 ? $operation : 'operation#' . substr(hash('sha256', $operation), 0, 12);
197 return array(
198 'operation_id' => substr(hash(
199 'sha256',
200 $this->requestId . '|' . (++$this->sequence) . '|' . $safeOperation
201 ), 0, 12),
202 'phase' => $this->currentPhase(),
203 'operation' => $safeOperation,
204 );
205 }
206
207 /**
208 * @param mixed $key
209 * @param mixed $group
210 * @return array{cache_key:string,cache_group:string,key_family:string,group_family:string}
211 */
212 private static function cacheIdentity($key, $group): array {
213 $keyText = is_scalar($key) || $key === null ? (string)$key : serialize($key);
214 $groupText = is_scalar($group) || $group === null ? (string)$group : serialize($group);
215 return array(
216 'cache_key' => 'key#' . substr(hash('sha256', $keyText), 0, 12),
217 'cache_group' => 'group#' . substr(hash('sha256', $groupText), 0, 12),
218 'key_family' => in_array($keyText, array('alloptions', 'notoptions', 'siteurl'), true)
219 ? $keyText : 'other',
220 'group_family' => $groupText === 'options' ? 'options' : 'other',
221 );
222 }
223
224 /**
225 * @param mixed $result
226 * @return array{result:string,result_size:int,result_size_unit:string}
227 */
228 private static function resultFields($result): array {
229 if ($result === false) {
230 return array('result' => 'miss', 'result_size' => 0, 'result_size_unit' => 'items');
231 }
232 if (is_array($result)) {
233 return array('result' => 'hit', 'result_size' => count($result), 'result_size_unit' => 'items');
234 }
235 if (is_string($result)) {
236 return array('result' => 'hit', 'result_size' => strlen($result), 'result_size_unit' => 'bytes');
237 }
238 return array('result' => 'hit', 'result_size' => 1, 'result_size_unit' => 'value');
239 }
240
241 private function recordCapOnce(): void {
242 if ($this->capped) {
243 return;
244 }
245 $this->capped = true;
246 $this->write('render_option_io_capped', array(
247 'phase' => $this->currentPhase(),
248 'recorded' => $this->recordCount,
249 'max_records' => self::MAX_RECORDS,
250 ), false);
251 }
252
253 /** @param array<string,mixed> $fields */
254 private function write(string $event, array $fields, bool $counts): void {
255 if ($this->recording) {
256 return;
257 }
258 $this->recording = true;
259 try {
260 ABJ_404_Solution_AjaxCheckpointLogger::recordFrequent(
261 $this->requestId,
262 $event,
263 $fields
264 );
265 if ($counts) {
266 $this->recordCount++;
267 }
268 } catch (Throwable $error) {
269 abj404_logPhpFallback('render-option-io-journal', $error->getMessage());
270 } finally {
271 $this->recording = false;
272 }
273 }
274
275 private function currentPhase(): string {
276 return (string)call_user_func($this->phase);
277 }
278
279 private static function nowFloat(): float {
280 if (function_exists('abj_clock')) {
281 return abj_clock()->nowFloat();
282 }
283 return class_exists('ABJ_404_Solution_SystemClock')
284 ? (new ABJ_404_Solution_SystemClock())->nowFloat()
285 : 0.0;
286 }
287
288 private static function elapsedMilliseconds(float $startedAt): int {
289 return max(0, (int)round((self::nowFloat() - $startedAt) * 1000));
290 }
291 }
292