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

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

379 lines 12.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 * Durable attribution for post-driver database recovery and retry work.
9 *
10 * One instance follows the original query from its first driver return through
11 * every selected recovery branch. Retry attempts reuse the original query
12 * identity and add a stable attempt ID, allowing DatabaseQueryFilterTracer to
13 * attribute WordPress callbacks and driver entry/exit to the exact retry.
14 */
15 final class ABJ_404_Solution_DatabaseQueryRecoveryTracer {
16
17 /** @var string */
18 private $requestId;
19 /** @var int */
20 private $queryOrdinal;
21 /** @var string */
22 private $sqlId;
23 /** @var string */
24 private $recoveryId;
25 /** @var int */
26 private $sequence = 0;
27 /** @var bool */
28 private $recoveryStarted = false;
29 /** @var bool */
30 private $recoveryCompleted = false;
31 /** @var int */
32 private $branchesSelected = 0;
33 /** @var int */
34 private $operationsTraced = 0;
35 /** @var int */
36 private $attemptsTraced = 0;
37
38 /**
39 * @param array{q:int,sql_id:string}|null $queryIdentity
40 */
41 public static function begin(?array $queryIdentity): self {
42 $requestId = ABJ_404_Solution_AjaxQueryTimeline::armedRequestId();
43 $queryOrdinal = (int)($queryIdentity['q'] ?? 0);
44 $sqlId = is_string($queryIdentity['sql_id'] ?? null)
45 ? $queryIdentity['sql_id']
46 : '';
47 $recoveryId = $requestId !== '' && $queryOrdinal > 0 && $sqlId !== ''
48 ? substr(hash('sha256', $requestId . '|' . $queryOrdinal . '|' . $sqlId . '|recovery'), 0, 12)
49 : '';
50 return new self($requestId, $queryOrdinal, $sqlId, $recoveryId);
51 }
52
53 private function __construct(
54 string $requestId,
55 int $queryOrdinal,
56 string $sqlId,
57 string $recoveryId
58 ) {
59 $this->requestId = $requestId;
60 $this->queryOrdinal = $queryOrdinal;
61 $this->sqlId = $sqlId;
62 $this->recoveryId = $recoveryId;
63 }
64
65 /** Record the first wpdb attempt returning or throwing. */
66 public function recordFirstDriverReturn(
67 string $status = 'complete',
68 ?Throwable $failure = null
69 ): void {
70 $fields = array_merge($this->baseFields(), array(
71 'status' => self::status($status),
72 ));
73 if ($failure !== null) {
74 $fields['failure_class'] = self::className($failure);
75 }
76 $this->write('query_first_driver_return', $fields);
77 }
78
79 /** Open the recovery cycle after the first wpdb attempt returned. */
80 public function startRecovery(): void {
81 if ($this->recoveryStarted) {
82 return;
83 }
84 $this->recoveryStarted = true;
85 $this->write('query_recovery_start', array_merge(
86 $this->baseFields(),
87 array('operation_id' => $this->recoveryId)
88 ));
89 }
90
91 /** Close the recovery cycle once. */
92 public function completeRecovery(
93 string $status = 'complete',
94 ?Throwable $failure = null
95 ): void {
96 if (!$this->recoveryStarted || $this->recoveryCompleted) {
97 return;
98 }
99 $this->recoveryCompleted = true;
100 $fields = array_merge($this->baseFields(), array(
101 'operation_id' => $this->recoveryId,
102 'status' => self::status($status),
103 'branches_selected' => $this->branchesSelected,
104 'operations_traced' => $this->operationsTraced,
105 'attempts_traced' => $this->attemptsTraced,
106 ));
107 if ($failure !== null) {
108 $fields['failure_class'] = self::className($failure);
109 }
110 $this->write('query_recovery_end', $fields);
111 }
112
113 /**
114 * @template T
115 * @param callable():T $work
116 * @return T
117 */
118 public function traceBranch(string $branch, callable $work) {
119 $this->branchesSelected++;
120 return $this->tracePair(
121 'query_recovery_branch',
122 self::branch($branch),
123 '',
124 $work
125 );
126 }
127
128 /**
129 * @template T
130 * @param callable():T $work
131 * @return T
132 */
133 public function traceOperation(string $branch, string $operation, callable $work) {
134 $this->operationsTraced++;
135 return $this->tracePair(
136 'query_recovery_operation',
137 self::branch($branch),
138 self::operation($operation),
139 $work
140 );
141 }
142
143 /**
144 * Run one SQL retry through the existing query-filter and driver tracer.
145 *
146 * @template T
147 * @param callable():T $queryCall
148 * @return T
149 */
150 public function traceAttempt(string $branch, string $reason, callable $queryCall) {
151 if (!$this->isArmed()) {
152 return $queryCall();
153 }
154 $branch = self::branch($branch);
155 $reason = self::reason($reason);
156 $this->attemptsTraced++;
157 $attemptId = $this->nextId('attempt|' . $branch . '|' . $reason);
158 $fields = array_merge($this->baseFields(), array(
159 'operation_id' => $attemptId,
160 'attempt_id' => $attemptId,
161 'branch' => $branch,
162 'reason' => $reason,
163 ));
164 $this->write('query_recovery_attempt_start', $fields);
165 $queryIdentity = array_merge($this->queryFields(), array(
166 'attempt_id' => $attemptId,
167 'recovery_id' => $this->recoveryId,
168 'recovery_branch' => $branch,
169 ));
170 try {
171 $result = ABJ_404_Solution_DatabaseQueryFilterTracer::trace(
172 $queryIdentity,
173 $queryCall
174 );
175 } catch (Throwable $e) {
176 $this->write('query_recovery_attempt_end', array_merge($fields, array(
177 'status' => 'failed',
178 'failure_class' => self::className($e),
179 'result_status' => 'exception',
180 'row_count' => 0,
181 'rows_affected' => 0,
182 )));
183 throw $e;
184 }
185 $resultFields = is_array($result)
186 ? self::safeResultFields($result)
187 : array(
188 'result_status' => 'unavailable',
189 'row_count' => 0,
190 'rows_affected' => 0,
191 );
192 $this->write('query_recovery_attempt_end', array_merge(
193 $fields,
194 array('status' => 'complete'),
195 $resultFields
196 ));
197 return $result;
198 }
199
200 /**
201 * @template T
202 * @param callable():T $work
203 * @return T
204 */
205 private function tracePair(
206 string $eventPrefix,
207 string $branch,
208 string $operation,
209 callable $work
210 ) {
211 if (!$this->isArmed()) {
212 return $work();
213 }
214 $operationId = $this->nextId($eventPrefix . '|' . $branch . '|' . $operation);
215 $fields = array_merge($this->baseFields(), array(
216 'operation_id' => $operationId,
217 'branch' => $branch,
218 ));
219 if ($operation !== '') {
220 $fields['operation'] = $operation;
221 }
222 $this->writePairStart($eventPrefix, $fields);
223 try {
224 $result = $work();
225 } catch (Throwable $e) {
226 $this->writePairEnd($eventPrefix, array_merge($fields, array(
227 'status' => 'failed',
228 'failure_class' => self::className($e),
229 )));
230 throw $e;
231 }
232 $this->writePairEnd($eventPrefix, array_merge($fields, array(
233 'status' => 'complete',
234 )));
235 return $result;
236 }
237
238 /** @return array{q:int,sql_id:string} */
239 private function queryFields(): array {
240 return array('q' => $this->queryOrdinal, 'sql_id' => $this->sqlId);
241 }
242
243 /** @return array{q:int,sql_id:string,recovery_id:string} */
244 private function baseFields(): array {
245 return array_merge($this->queryFields(), array(
246 'recovery_id' => $this->recoveryId,
247 ));
248 }
249
250 private function nextId(string $scope): string {
251 $this->sequence++;
252 return substr(hash(
253 'sha256',
254 $this->requestId . '|' . $this->recoveryId . '|' . $this->sequence . '|' . $scope
255 ), 0, 12);
256 }
257
258 private function isArmed(): bool {
259 return $this->requestId !== ''
260 && $this->queryOrdinal > 0
261 && $this->sqlId !== ''
262 && $this->recoveryId !== '';
263 }
264
265 /** @param array<string, mixed> $fields */
266 private function write(string $event, array $fields): void {
267 if (!$this->isArmed()) {
268 return;
269 }
270 try {
271 ABJ_404_Solution_AjaxFrequentCheckpointWriter::append(
272 $this->requestId,
273 $event,
274 $fields,
275 ABJ_404_Solution_AjaxFrequentCheckpointWriter::resolvedDirectoryForRequest(
276 $this->requestId
277 ),
278 true
279 );
280 } catch (Throwable $e) {
281 abj404_logPhpFallback(
282 'database-query-recovery-tracer',
283 $event . ' write failed; exception=' . self::className($e)
284 . '; code=' . (string)$e->getCode()
285 );
286 }
287 }
288
289 private static function status(string $status): string {
290 return $status === 'complete' ? 'complete' : 'failed';
291 }
292
293 private static function branch(string $branch): string {
294 $allowed = array(
295 'timeout_wrapper',
296 'transient_connection',
297 'commands_out_of_sync',
298 'missing_table',
299 'invalid_data',
300 'deadlock',
301 'collation',
302 'timeout',
303 'database_issue',
304 'corrupted_table',
305 'duplicate_id',
306 );
307 return in_array($branch, $allowed, true) ? $branch : 'unknown';
308 }
309
310 private static function operation(string $operation): string {
311 $allowed = array(
312 'connection_recovery',
313 'connection_retry_reset',
314 'repair_create',
315 'retry_prepare',
316 'retry_suppression',
317 'retry_backoff',
318 'schedule_recovery',
319 'timeout_log',
320 'notice_update',
321 );
322 return in_array($operation, $allowed, true) ? $operation : 'unknown';
323 }
324
325 private static function reason(string $reason): string {
326 $allowed = array(
327 'timeout_wrapper_rejected',
328 'connection_lost',
329 'pending_results_drained',
330 'missing_table',
331 'invalid_data',
332 'deadlock_or_lock_timeout',
333 'corrupted_table',
334 );
335 return in_array($reason, $allowed, true) ? $reason : 'unknown';
336 }
337
338 private static function className(Throwable $failure): string {
339 $name = preg_replace('/[^A-Za-z0-9_\\\\-]/', '_', get_class($failure));
340 return substr(is_string($name) ? $name : 'Throwable', 0, 96);
341 }
342
343 /**
344 * @param array<mixed, mixed> $result
345 * @return array{result_status:string,row_count:int,rows_affected:int}
346 */
347 private static function safeResultFields(array $result): array {
348 $lastError = is_scalar($result['last_error'] ?? null)
349 ? (string)$result['last_error']
350 : '';
351 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
352 return array(
353 'result_status' => $lastError === '' ? 'success' : 'error',
354 'row_count' => count($rows),
355 'rows_affected' => is_numeric($result['rows_affected'] ?? null)
356 ? max(0, (int)$result['rows_affected'])
357 : 0,
358 );
359 }
360
361 /** @param array<string, mixed> $fields */
362 private function writePairStart(string $prefix, array $fields): void {
363 if ($prefix === 'query_recovery_branch') {
364 $this->write('query_recovery_branch_start', $fields);
365 return;
366 }
367 $this->write('query_recovery_operation_start', $fields);
368 }
369
370 /** @param array<string, mixed> $fields */
371 private function writePairEnd(string $prefix, array $fields): void {
372 if ($prefix === 'query_recovery_branch') {
373 $this->write('query_recovery_branch_end', $fields);
374 return;
375 }
376 $this->write('query_recovery_operation_end', $fields);
377 }
378 }
379