PluginProbe
404 Solution / 4.3.3
404 Solution v4.3.3
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 / database / DatabaseQueryDiagnostics.php

DatabaseQueryDiagnostics.php in 404 Solution 4.3.3, at includes/database/DatabaseQueryDiagnostics.php

285 lines 10.5 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 require_once dirname(__DIR__) . '/diagnostics/QueryBudgetInstrumentation.php';
8
9 /**
10 * Query diagnostics for safe source labels, latency simulation, and logging.
11 *
12 * This component owns diagnostic behavior around a query execution without
13 * deciding whether the query should run or how errors recover. The executor
14 * calls it for simulated latency, query-budget instrumentation, safe SQL
15 * source labels, and malformed wpdb result visibility.
16 */
17 class ABJ_404_Solution_DatabaseQueryDiagnostics {
18
19 /** @var ABJ_404_Solution_Logging */
20 private $logger;
21
22 /** @param ABJ_404_Solution_Logging $logger */
23 public function __construct($logger) {
24 $this->logger = $logger;
25 }
26
27 /**
28 * @param string $query
29 * @param float $elapsedMs
30 * @param int $timeoutSeconds
31 * @return void
32 */
33 public function recordQueryBudgetIfEnabled(string $query, float $elapsedMs, int $timeoutSeconds): void {
34 if (function_exists('abj404_benchmark_record_db_query')) {
35 abj404_benchmark_record_db_query($elapsedMs);
36 }
37 if (function_exists('abj404_query_budget_record')
38 && class_exists('ABJ_404_Solution_QueryBudgetInstrumentation', false)
39 && ABJ_404_Solution_QueryBudgetInstrumentation::isEnabled()) {
40 abj404_query_budget_record($this->extractSqlFilename($query), $elapsedMs, $timeoutSeconds);
41 }
42 }
43
44 /**
45 * Open the ledger-scoped boundary covering all work before query_probe.
46 *
47 * @param mixed $wpdb
48 */
49 public function beginQueryPreflight(
50 string $query,
51 $wpdb
52 ): ABJ_404_Solution_DatabaseQueryPreflightTracer {
53 return ABJ_404_Solution_DatabaseQueryPreflightTracer::begin(
54 $this->extractSqlFilename($query),
55 $wpdb
56 );
57 }
58
59 /**
60 * Announce a query to the per-request attribution timeline BEFORE it runs
61 * (Bruno timeout cause matrix, cause class F).
62 *
63 * Emitted ahead of execution on purpose: a query that blocks and never
64 * returns cannot be described by a record written on completion, and
65 * naming the SQL shape that was in flight is what separates "the stage
66 * hung in the database" from "the stage hung in PHP after the database
67 * came back". See ABJ_404_Solution_AjaxQueryTimeline.
68 *
69 * The call-site label is resolved only when the timeline is armed, since
70 * extractSqlFilename() falls back to a debug_backtrace() walk.
71 *
72 * @param string $query The final SQL the server will receive.
73 * @param int $timeoutSeconds
74 * @return array{q:int,sql_id:string}|null
75 */
76 public function recordQueryTimelineStart(
77 string $query,
78 int $timeoutSeconds,
79 string $preflightId = ''
80 ): ?array {
81 if (!class_exists('ABJ_404_Solution_AjaxQueryTimeline')
82 || !ABJ_404_Solution_AjaxQueryTimeline::isArmed()) {
83 return null;
84 }
85 return ABJ_404_Solution_AjaxQueryTimeline::beginQuery(
86 $query,
87 $this->extractSqlFilename($query),
88 $timeoutSeconds,
89 $preflightId
90 );
91 }
92
93 /**
94 * Close the in-flight timeline entry with the duration the executor
95 * measured. In-memory only; the value is carried out by the next probe and
96 * by the request's closing summary.
97 *
98 * Deliberately called from BOTH the normal and the throwing path of
99 * queryAndGetResults: a query that raised is still a query that ended, and
100 * an unclosed entry would make every duration after it unreadable.
101 *
102 * @param float $elapsedMs
103 * @return void
104 */
105 public function recordQueryTimelineEnd(float $elapsedMs): void {
106 if (class_exists('ABJ_404_Solution_AjaxQueryTimeline', false)) {
107 ABJ_404_Solution_AjaxQueryTimeline::endQuery($elapsedMs);
108 }
109 }
110
111 /**
112 * Attach the strongest DB-level timeout mode observed during the active
113 * AJAX stage. MariaDB's persisted wrapper-rejection state is explicitly
114 * recorded as unwrapped because its MAX_EXECUTION_TIME comment is ignored.
115 */
116 public function recordAjaxTimeoutMode(string $query): void {
117 $mode = 'none';
118 if (class_exists('ABJ_404_Solution_DatabaseRuntimeState')
119 && ABJ_404_Solution_DatabaseRuntimeState::isSetStatementWrapperUnsupported()) {
120 $mode = 'unwrapped';
121 } else if (preg_match('/MAX_EXECUTION_TIME|max_statement_time/i', $query) === 1) {
122 $mode = 'wrapped';
123 }
124 if (class_exists('ABJ_404_Solution_AjaxStageDiagnostics')) {
125 ABJ_404_Solution_AjaxStageDiagnostics::addStageMetadata(array('db_timeout_mode' => $mode));
126 }
127 }
128
129 /**
130 * @param string $query
131 * @param mixed $rows
132 * @return void
133 */
134 public function logMalformedRowsIfNeeded(string $query, $rows): void {
135 if (is_array($rows)) {
136 return;
137 }
138 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->extractSqlFilename($query);
139 $this->logger->errorMessage(
140 "Query result is not an array. Query: " . $sqlInfo,
141 new Exception("Query result is not an array.") // allow-raw-error: behavior preserved from pre-extraction DatabaseCore; passed to logger as diagnostic context, not thrown
142 );
143 }
144
145 /**
146 * Resolve a stable source identifier for safe logging.
147 *
148 * @param string $query
149 * @return string
150 */
151 public function extractSqlFilename($query) {
152 if (is_string($query) && $query !== '') {
153 if (preg_match('/\/\*\s*abj404:src=([A-Za-z0-9_:#.\\\\\-]+)\s*\*\//i', $query, $m)) {
154 return $m[1];
155 }
156 if (preg_match('/\/\*\s*-+\s*(.+?\.sql)\s+BEGIN\s*-+\s*\*\//i', $query, $m)) {
157 return basename($m[1]);
158 }
159 }
160 return $this->resolveCallerFromBacktrace();
161 }
162
163 /** @return string */
164 public function resolveCallerFromBacktrace() {
165 static $internalMethods = array(
166 'extractSqlFilename' => true,
167 'resolveCallerFromBacktrace' => true,
168 'beginQueryPreflight' => true,
169 'queryAndGetResults' => true,
170 'attemptInvalidDataRetry' => true,
171 'attemptMissingTableRepairAndRetry' => true,
172 'repairCorruptedTableAndRetry' => true,
173 'scheduleCollationRecovery' => true,
174 'call_user_func_array' => true,
175 'call_user_func' => true,
176 '__call' => true,
177 );
178 $frames = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 40);
179 foreach ($frames as $frame) {
180 if ($this->shouldSkipBacktraceFrame($frame, $internalMethods)) {
181 continue;
182 }
183 return $this->formatBacktraceSource($frame);
184 }
185 return 'unknown-source';
186 }
187
188 /**
189 * @param array<string, mixed> $frame
190 * @param array<string, bool> $internalMethods
191 * @return bool
192 */
193 private function shouldSkipBacktraceFrame(array $frame, array $internalMethods): bool {
194 $fn = isset($frame['function']) && is_string($frame['function']) ? $frame['function'] : '';
195 if ($fn === '' || isset($internalMethods[$fn]) || strpos($fn, '{closure') !== false) {
196 return true;
197 }
198
199 $cls = isset($frame['class']) && is_string($frame['class']) ? $frame['class'] : '';
200 if ($cls !== '' && (strpos($cls, 'Patchwork') !== false || strpos($cls, 'PHPUnit\\') === 0)) {
201 return true;
202 }
203 if (strpos($fn, 'Patchwork\\') !== false) {
204 return true;
205 }
206
207 $fullFile = isset($frame['file']) && is_string($frame['file']) ? $frame['file'] : '';
208 return $fullFile !== '' && (strpos($fullFile, '/patchwork/') !== false || strpos($fullFile, '\\patchwork\\') !== false);
209 }
210
211 /**
212 * @param array<string, mixed> $frame
213 * @return string
214 */
215 private function formatBacktraceSource(array $frame): string {
216 $fn = isset($frame['function']) && is_string($frame['function']) ? $frame['function'] : 'unknown-source';
217 $cls = isset($frame['class']) && is_string($frame['class']) ? $frame['class'] : '';
218 $fileLabel = $this->fileLabelFromBacktraceFrame($frame);
219
220 if ($this->isInternalDatabaseClass($cls) && $fileLabel !== '' && !$this->isInternalDatabaseFileLabel($fileLabel)) {
221 return $fileLabel . '::' . $fn;
222 }
223 if ($cls !== '') {
224 return $this->shortClassLabel($cls) . '::' . $fn;
225 }
226 if ($fileLabel !== '') {
227 return $fileLabel . '::' . $fn;
228 }
229 return $fn;
230 }
231
232 /**
233 * @param array<string, mixed> $frame
234 * @return string
235 */
236 private function fileLabelFromBacktraceFrame(array $frame): string {
237 $fullFile = isset($frame['file']) && is_string($frame['file']) ? $frame['file'] : '';
238 $file = $fullFile !== '' ? basename($fullFile) : '';
239 $fileLabel = preg_replace('/\.php$/i', '', $file);
240 return is_string($fileLabel) ? $fileLabel : $file;
241 }
242
243 /** @param string $className @return bool */
244 private function isInternalDatabaseClass(string $className): bool {
245 return $className === 'ABJ_404_Solution_DatabaseCore'
246 || $className === 'ABJ_404_Solution_DatabaseQueryExecutor'
247 || $className === 'ABJ_404_Solution_DatabaseQueryDiagnostics'
248 || $className === 'ABJ_404_Solution_DataAccess';
249 }
250
251 /** @param string $fileLabel @return bool */
252 private function isInternalDatabaseFileLabel(string $fileLabel): bool {
253 return $fileLabel === 'DatabaseCore'
254 || $fileLabel === 'DatabaseQueryExecutor'
255 || $fileLabel === 'DatabaseQueryDiagnostics'
256 || $fileLabel === 'DataAccess';
257 }
258
259 /** @param string $className @return string */
260 private function shortClassLabel(string $className): string {
261 $shortClass = $className;
262 $nsPos = strrpos($shortClass, '\\');
263 if ($nsPos !== false) {
264 $shortClass = substr($shortClass, $nsPos + 1);
265 }
266 if (strpos($shortClass, 'ABJ_404_Solution_') === 0) {
267 return substr($shortClass, strlen('ABJ_404_Solution_'));
268 }
269 return $shortClass;
270 }
271
272 /** @return void */
273 public function applyDiagnosticLatencyIfConfigured(): void {
274 if (!function_exists('abj404_get_simulated_db_latency_ms')) {
275 return;
276 }
277 $delayMs = absint(abj404_get_simulated_db_latency_ms());
278 if ($delayMs <= 0) {
279 return;
280 }
281 $delayMs = min(5000, $delayMs);
282 usleep($delayMs * 1000);
283 }
284 }
285