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

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

320 lines 10.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 work before the first per-query SQL probe.
9 *
10 * DatabaseQueryExecutor opens this boundary before connection inspection and
11 * closes it only after result-shape detection. Nested operations identify the
12 * exact preflight call that failed or never returned. The tracer never stores
13 * SQL, parameters, cache values, exception messages, or anonymous-class source
14 * paths; every field is a controlled enum, bounded source label, count, or
15 * privacy-safe class identity.
16 *
17 * Calls are no-ops outside the AJAX request ledger. Diagnostic persistence is
18 * failure-safe, while exceptions from traced database work are recorded and
19 * rethrown unchanged.
20 *
21 * allow-no-test-found: exercised through both real table AJAX entry points in tests/AjaxQueryPreflightAttributionTest.php
22 */
23 final class ABJ_404_Solution_DatabaseQueryPreflightTracer {
24
25 const CONNECTION_CHECK = 'connection_check';
26 const CONNECTION_RECONNECT = 'connection_reconnect';
27 const PARAMETER_PREPARATION = 'parameter_preparation';
28 const ENGINE_DETECTION = 'engine_detection';
29 const TIMEOUT_CAPABILITY_CACHE = 'timeout_capability_cache';
30 const TIMEOUT_POLICY = 'timeout_policy';
31 const DIAGNOSTIC_LATENCY = 'diagnostic_latency';
32 const RESULT_SHAPE_DETECTION = 'result_shape_detection';
33
34 /** @var string */
35 private static $sequenceRequestId = '';
36
37 /** @var int */
38 private static $preflightSequence = 0;
39
40 /** @var string */
41 private $requestId;
42
43 /** @var string */
44 private $preflightId;
45
46 /** @var string */
47 private $source;
48
49 /** @var string */
50 private $stage;
51
52 /** @var int */
53 private $operationSequence = 0;
54
55 /** @var bool */
56 private $completed = false;
57
58 /**
59 * Begin the query-wide preflight before any database boundary is touched.
60 *
61 * @param mixed $wpdb
62 */
63 public static function begin(string $source, $wpdb): self {
64 $requestId = self::armedRequestId();
65 if ($requestId !== self::$sequenceRequestId) {
66 self::$sequenceRequestId = $requestId;
67 self::$preflightSequence = 0;
68 }
69 if ($requestId !== '') {
70 self::$preflightSequence++;
71 }
72 $preflightId = $requestId === ''
73 ? ''
74 : substr(hash('sha256', $requestId . '|' . self::$preflightSequence . '|' . $source), 0, 12);
75 $tracer = new self(
76 $requestId,
77 $preflightId,
78 self::safeLabel($source, 200),
79 self::currentStage()
80 );
81 $tracer->write('query_preflight_start', array_merge(
82 $tracer->baseFields($preflightId),
83 self::connectionIdentity($wpdb)
84 ));
85 return $tracer;
86 }
87
88 private function __construct(
89 string $requestId,
90 string $preflightId,
91 string $source,
92 string $stage
93 ) {
94 $this->requestId = $requestId;
95 $this->preflightId = $preflightId;
96 $this->source = $source;
97 $this->stage = $stage;
98 }
99
100 /** Correlation ID added to the first SQL probe after preflight succeeds. */
101 public function preflightId(): string {
102 return $this->preflightId;
103 }
104
105 /**
106 * Run one nested preflight operation with a durable start and end.
107 *
108 * Options:
109 * - fields: scalar privacy-safe identity added to both edges.
110 * - result_fields: callable that maps the result to scalar end fields.
111 *
112 * @template T
113 * @param callable():T $work
114 * @param array{
115 * fields?: array<string, scalar|null>,
116 * result_fields?: callable(T):array<string, scalar|null>
117 * } $options
118 * @return T
119 */
120 public function trace(string $operation, callable $work, array $options = array()) {
121 if ($this->requestId === '') {
122 return $work();
123 }
124 $this->operationSequence++;
125 $operation = self::safeOperation($operation);
126 $operationId = substr(hash(
127 'sha256',
128 $this->preflightId . '|' . $this->operationSequence . '|' . $operation
129 ), 0, 12);
130 $identity = array_merge(
131 $this->baseFields($operationId),
132 array('operation' => $operation),
133 self::safeScalarFields($options['fields'] ?? array())
134 );
135 $this->write('query_preflight_operation_start', $identity);
136 try {
137 $result = $work();
138 } catch (Throwable $e) {
139 $this->write('query_preflight_operation_end', array_merge($identity, array(
140 'status' => 'failed',
141 'failure_class' => self::safeClassName(get_class($e)),
142 )));
143 throw $e;
144 }
145
146 $resultFields = array();
147 if (is_callable($options['result_fields'] ?? null)) {
148 try {
149 $described = call_user_func($options['result_fields'], $result);
150 $resultFields = is_array($described) ? self::safeScalarFields($described) : array();
151 } catch (Throwable $e) {
152 self::reportFailure('result-description', $e);
153 }
154 }
155 $this->write('query_preflight_operation_end', array_merge(
156 $identity,
157 array('status' => 'complete'),
158 $resultFields
159 ));
160 return $result;
161 }
162
163 /** Close the query-wide preflight once, preserving only failure class. */
164 public function complete(string $status = 'complete', ?Throwable $failure = null): void {
165 if ($this->completed) {
166 return;
167 }
168 $this->completed = true;
169 $fields = array_merge(
170 $this->baseFields($this->preflightId),
171 array('status' => $status === 'complete' ? 'complete' : 'failed')
172 );
173 if ($failure !== null) {
174 $fields['failure_class'] = self::safeClassName(get_class($failure));
175 }
176 $this->write('query_preflight_end', $fields);
177 }
178
179 /** @return array<string, string> */
180 private function baseFields(string $operationId): array {
181 return array(
182 'operation_id' => $operationId,
183 'preflight_id' => $this->preflightId,
184 'src' => $this->source,
185 'stage' => $this->stage,
186 );
187 }
188
189 /**
190 * @param array<string, mixed> $fields
191 * @return array<string, scalar|null>
192 */
193 private static function safeScalarFields(array $fields): array {
194 $safe = array();
195 foreach ($fields as $name => $value) {
196 if (is_string($name) && preg_match('/^[a-z][a-z0-9_]{0,47}$/', $name) === 1
197 && (is_scalar($value) || $value === null)) {
198 $safe[$name] = is_string($value) ? self::safeLabel($value, 96) : $value;
199 }
200 }
201 return $safe;
202 }
203
204 /**
205 * @param mixed $wpdb
206 * @return array<string, string>
207 */
208 private static function connectionIdentity($wpdb): array {
209 $hasProbe = is_object($wpdb)
210 && (method_exists($wpdb, 'check_connection')
211 || is_callable(array($wpdb, 'check_connection')));
212 $hasReconnect = is_object($wpdb) && method_exists($wpdb, 'db_connect');
213 if (!$hasProbe) {
214 $policy = 'no_connection_probe';
215 } elseif ($hasReconnect) {
216 $policy = 'db_connect_then_check_connection_false';
217 } else {
218 $policy = 'check_only_no_db_connect';
219 }
220 return array(
221 'wpdb_class' => self::wpdbClassName($wpdb),
222 'wpdb_kind' => self::wpdbKind($wpdb),
223 'reconnect_policy' => $policy,
224 );
225 }
226
227 /** @param mixed $wpdb */
228 private static function wpdbClassName($wpdb): string {
229 if (!is_object($wpdb)) {
230 return 'unavailable';
231 }
232 $className = get_class($wpdb);
233 if (strpos($className, '@anonymous') !== false) {
234 return 'anonymous-wpdb-compatible';
235 }
236 return self::safeClassName($className);
237 }
238
239 /** @param mixed $wpdb */
240 private static function wpdbKind($wpdb): string {
241 if (!is_object($wpdb)) {
242 return 'unavailable';
243 }
244 if (strcasecmp(get_class($wpdb), 'wpdb') === 0) {
245 return 'core_wpdb';
246 }
247 return is_a($wpdb, 'wpdb') ? 'wpdb_subclass' : 'wpdb_compatible';
248 }
249
250 private static function safeClassName(string $className): string {
251 return self::safeLabel($className, 96);
252 }
253
254 private static function safeOperation(string $operation): string {
255 $allowed = array(
256 self::CONNECTION_CHECK,
257 self::CONNECTION_RECONNECT,
258 self::PARAMETER_PREPARATION,
259 self::ENGINE_DETECTION,
260 self::TIMEOUT_CAPABILITY_CACHE,
261 self::TIMEOUT_POLICY,
262 self::DIAGNOSTIC_LATENCY,
263 self::RESULT_SHAPE_DETECTION,
264 );
265 return in_array($operation, $allowed, true) ? $operation : 'unknown';
266 }
267
268 private static function safeLabel(string $value, int $length): string {
269 $normalized = preg_replace('/[^A-Za-z0-9_:#.\\\\-]/', '_', $value);
270 return substr(is_string($normalized) ? $normalized : 'unknown', 0, $length);
271 }
272
273 private static function currentStage(): string {
274 $context = $GLOBALS['abj404_ajax_context'] ?? null;
275 $stage = is_array($context) && is_scalar($context['stage'] ?? null)
276 ? (string)$context['stage']
277 : '';
278 return self::safeLabel($stage, 64);
279 }
280
281 private static function armedRequestId(): string {
282 if (!class_exists('ABJ_404_Solution_AjaxQueryTimeline')
283 || !ABJ_404_Solution_AjaxQueryTimeline::isArmed()) {
284 return '';
285 }
286 return ABJ_404_Solution_AjaxQueryTimeline::armedRequestId();
287 }
288
289 /** @param array<string, mixed> $fields */
290 private function write(string $event, array $fields): void {
291 if ($this->requestId === '') {
292 return;
293 }
294 try {
295 ABJ_404_Solution_AjaxCheckpointBoundaryWriter::record(
296 $this->requestId,
297 $event,
298 $fields
299 );
300 } catch (Throwable $e) {
301 self::reportFailure('checkpoint-write:' . $event, $e);
302 }
303 }
304
305 private static function reportFailure(string $context, Throwable $failure): void {
306 abj404_logPhpFallback(
307 'database-query-preflight',
308 self::safeLabel($context, 96)
309 . ' failed; exception=' . self::safeClassName(get_class($failure))
310 . '; code=' . (string)$failure->getCode()
311 );
312 }
313
314 /** Reset request sequence state. Test-only; production never calls this. */
315 public static function resetForTests(): void {
316 self::$sequenceRequestId = '';
317 self::$preflightSequence = 0;
318 }
319 }
320