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

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

298 lines 10.0 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 * Installs option-cache and raw option-query attribution for render scopes.
9 *
10 * One coordinator owns nested scopes so the exact host cache and query hook
11 * are installed once, restored once, and never replaced by a nested render.
12 *
13 * allow-no-test-found: real-entry coverage is in tests/AjaxPaginationOptionAttributionTest.php
14 */
15 final class ABJ_404_Solution_RenderOptionIoTracer
16 implements ABJ_404_Solution_DiagnosticInternalHookObserver {
17
18 /** @var self|null */
19 private static $active;
20
21 /** @var string */
22 private $requestId;
23 /** @var array<int,string> */
24 private $phases = array();
25 /** @var object|null */
26 private $originalCache;
27 /** @var ABJ_404_Solution_InstrumentedObjectCache|null */
28 private $cacheProxy;
29 /** @var bool */
30 private $queryFilterInstalled = false;
31 /** @var bool */
32 private $restoring = false;
33 /** @var bool */
34 private $scopeActive = false;
35 /** @var array{mode:string,identity:array<string,mixed>}|null */
36 private $pendingQuery;
37 /** @var ABJ_404_Solution_RenderOptionIoOperationJournal */
38 private $journal;
39 /** @var ABJ_404_Solution_HookInstrumentationLifecycleTracer */
40 private $lifecycleTracer;
41
42 /**
43 * @template T
44 * @param callable():T $work
45 * @return T
46 */
47 public static function traceScope(
48 string $requestId,
49 string $phase,
50 callable $work
51 ) {
52 if ($requestId === '') {
53 return $work();
54 }
55 if (self::$active !== null) {
56 if (self::$active->requestId !== $requestId) {
57 abj404_logPhpFallback(
58 'render-option-io-tracer',
59 'request changed while a render option I/O scope was active'
60 );
61 return $work();
62 }
63 return self::$active->runNested($phase, $work);
64 }
65
66 $tracer = new self($requestId);
67 self::$active = $tracer;
68 try {
69 return $tracer->runOutermost($phase, $work);
70 } finally {
71 self::$active = null;
72 }
73 }
74
75 private function __construct(string $requestId) {
76 $this->requestId = $requestId;
77 $backend = self::backendIdentity($GLOBALS['wp_object_cache'] ?? null);
78 $resolvedDirectory =
79 ABJ_404_Solution_AjaxFrequentCheckpointWriter::resolvedDirectoryForRequest(
80 $requestId
81 );
82 $this->lifecycleTracer = new ABJ_404_Solution_HookInstrumentationLifecycleTracer(
83 $requestId,
84 'render_option_io',
85 $resolvedDirectory
86 );
87 $this->journal = new ABJ_404_Solution_RenderOptionIoOperationJournal(
88 $requestId,
89 function (): string {
90 return $this->currentPhase();
91 },
92 function (): void {
93 $this->completePendingQuery();
94 },
95 $backend
96 );
97 }
98
99 /**
100 * @template T
101 * @param callable():T $work
102 * @return T
103 */
104 private function runOutermost(string $phase, callable $work) {
105 $this->phases[] = self::safePhase($phase);
106 $status = $this->install();
107 $this->scopeActive = true;
108 $this->journal->recordInstrumentation($status);
109 try {
110 $result = $work();
111 } catch (Throwable $error) {
112 $this->restore(false);
113 array_pop($this->phases);
114 throw $error;
115 }
116 $this->completePendingQuery();
117 $this->restore(true);
118 array_pop($this->phases);
119 return $result;
120 }
121
122 /**
123 * @template T
124 * @param callable():T $work
125 * @return T
126 */
127 private function runNested(string $phase, callable $work) {
128 $this->completePendingQuery();
129 $this->phases[] = self::safePhase($phase);
130 $this->journal->recordInstrumentation(array(
131 'cache_boundary' => $this->cacheProxy === null ? 'unavailable' : 'ready',
132 'query_boundary' => $this->queryFilterInstalled ? 'ready' : 'unavailable',
133 'scope' => 'nested',
134 ));
135 try {
136 $result = $work();
137 } catch (Throwable $error) {
138 // The nested query/cache operation that threw deliberately keeps
139 // its start unmatched. It must not be closed later by outer work.
140 $this->pendingQuery = null;
141 array_pop($this->phases);
142 throw $error;
143 }
144 $this->completePendingQuery();
145 array_pop($this->phases);
146 return $result;
147 }
148
149 /** @return array{cache_boundary:string,query_boundary:string,scope:string} */
150 private function install(): array {
151 $cacheBoundary = 'unavailable';
152 $cache = $GLOBALS['wp_object_cache'] ?? null;
153 if (is_object($cache)
154 && !$cache instanceof ABJ_404_Solution_InstrumentedObjectCache) {
155 $this->originalCache = $cache;
156 $this->cacheProxy = new ABJ_404_Solution_InstrumentedObjectCache(
157 $cache,
158 $this->journal
159 );
160 $GLOBALS['wp_object_cache'] = $this->cacheProxy;
161 $cacheBoundary = 'ready';
162 }
163
164 $queryBoundary = 'unavailable';
165 if (function_exists('add_filter')) {
166 try {
167 $this->queryFilterInstalled = $this->lifecycleTracer->traceBoundary(
168 ABJ_404_Solution_HookInstrumentationLifecycleTracer::PHASE_REGISTRATION,
169 'query',
170 function (): bool {
171 return (bool)add_filter(
172 'query',
173 array($this, 'observeQuery'),
174 PHP_INT_MAX,
175 1
176 );
177 }
178 );
179 $queryBoundary = $this->queryFilterInstalled ? 'ready' : 'unavailable';
180 } catch (Throwable $error) {
181 self::reportFailure('query filter install failed: ' . $error->getMessage());
182 }
183 }
184 return array(
185 'cache_boundary' => $cacheBoundary,
186 'query_boundary' => $queryBoundary,
187 'scope' => 'outer',
188 );
189 }
190
191 /**
192 * WordPress query-filter callback immediately before the raw driver.
193 *
194 * @param mixed $query
195 * @return mixed
196 */
197 public function observeQuery($query) {
198 if (!$this->scopeActive || $this->restoring || !is_string($query)) {
199 return $query;
200 }
201 $this->completePendingQuery();
202 $operation = $this->optionQueryOperation($query);
203 if ($operation !== '') {
204 $this->pendingQuery = $this->journal->beginOptionQuery($operation, $query);
205 }
206 return $query;
207 }
208
209 private function completePendingQuery(): void {
210 if ($this->pendingQuery === null) {
211 return;
212 }
213 $token = $this->pendingQuery;
214 $this->pendingQuery = null;
215 $this->journal->completeOptionQuery($token, $GLOBALS['wpdb'] ?? null);
216 }
217
218 private function restore(bool $scopeCompleted): void {
219 $this->scopeActive = false;
220 $this->restoring = true;
221 if ($scopeCompleted) {
222 $this->completePendingQuery();
223 }
224 if ($this->queryFilterInstalled && function_exists('remove_filter')) {
225 try {
226 $this->lifecycleTracer->traceBoundary(
227 ABJ_404_Solution_HookInstrumentationLifecycleTracer::PHASE_REMOVAL,
228 'query',
229 function (): bool {
230 return (bool)remove_filter(
231 'query',
232 array($this, 'observeQuery'),
233 PHP_INT_MAX
234 );
235 }
236 );
237 } catch (Throwable $error) {
238 self::reportFailure('query filter removal failed: ' . $error->getMessage());
239 }
240 }
241 $this->queryFilterInstalled = false;
242 $this->pendingQuery = null;
243 if ($this->cacheProxy !== null
244 && ($GLOBALS['wp_object_cache'] ?? null) === $this->cacheProxy) {
245 $GLOBALS['wp_object_cache'] = $this->originalCache;
246 }
247 $this->restoring = false;
248 }
249
250 private function optionQueryOperation(string $query): string {
251 $wpdb = $GLOBALS['wpdb'] ?? null;
252 $table = is_object($wpdb) && isset($wpdb->options)
253 && is_string($wpdb->options) ? $wpdb->options : '';
254 if ($table === '') {
255 return '';
256 }
257 $tablePattern = '/(?:^|[^A-Za-z0-9_])`?' . preg_quote($table, '/')
258 . '`?(?:[^A-Za-z0-9_]|$)/i';
259 if (preg_match($tablePattern, $query) !== 1) {
260 return '';
261 }
262 if (preg_match('/^\\s*(select|update|insert|delete|replace)\\b/i', $query, $match) !== 1) {
263 return 'option_query';
264 }
265 return 'option_' . strtolower($match[1]);
266 }
267
268 private function currentPhase(): string {
269 $phase = end($this->phases);
270 return is_string($phase) && $phase !== '' ? $phase : 'unknown';
271 }
272
273 private static function safePhase(string $phase): string {
274 $safe = preg_replace('/[^a-z0-9_]/', '', strtolower($phase));
275 return substr(is_string($safe) && $safe !== '' ? $safe : 'unknown', 0, 48);
276 }
277
278 /**
279 * @param mixed $cache
280 * @return array{backend:string,backend_class:string}
281 */
282 private static function backendIdentity($cache): array {
283 if (!is_object($cache)) {
284 return array('backend' => 'unavailable', 'backend_class' => 'none');
285 }
286 $persistent = function_exists('wp_using_ext_object_cache')
287 && wp_using_ext_object_cache();
288 return array(
289 'backend' => $persistent ? 'persistent_object_cache' : 'wordpress_runtime_cache',
290 'backend_class' => 'class#' . substr(hash('sha256', get_class($cache)), 0, 12),
291 );
292 }
293
294 private static function reportFailure(string $message): void {
295 abj404_logPhpFallback('render-option-io-tracer', $message);
296 }
297 }
298