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 / database / DatabaseTransactionExecutor.php

DatabaseTransactionExecutor.php in 404 Solution trunk, at includes/database/DatabaseTransactionExecutor.php

225 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 require_once __DIR__ . '/../core/DatabaseMetadataLockWaitGuard.php';
8
9 /**
10 * Executes SQL statements inside a transaction with deadlock-aware retry.
11 *
12 * Transaction lifecycle is separate from the single-query pipeline: it owns
13 * BEGIN/COMMIT/ROLLBACK bookkeeping, infrastructure-error classification for
14 * statement failures, and retry delay for deadlock or lock-wait timeouts.
15 */
16 class ABJ_404_Solution_DatabaseTransactionExecutor {
17
18 /** @var ABJ_404_Solution_DatabaseCore */
19 private $core;
20
21 /** @var ABJ_404_Solution_Logging */
22 private $logger;
23
24 /** @var ABJ_404_Solution_DatabaseMetadataLockWaitGuard */
25 private $metadataLockWaitGuard;
26
27 /**
28 * @param ABJ_404_Solution_DatabaseCore $core
29 * @param ABJ_404_Solution_Logging $logger
30 */
31 public function __construct(ABJ_404_Solution_DatabaseCore $core, $logger) {
32 $this->core = $core;
33 $this->logger = $logger;
34 $this->metadataLockWaitGuard = new ABJ_404_Solution_DatabaseMetadataLockWaitGuard($logger);
35 }
36
37 /**
38 * @param array<int, string> $statementArray
39 * @return void
40 */
41 public function executeAsTransaction(array $statementArray): void {
42 global $wpdb;
43 $maxAttempts = 3;
44 $lastException = null;
45 $lastError = '';
46
47 for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
48 $allIsWell = true;
49 $lastError = '';
50 $lastException = null;
51 try {
52 // DAO-bypass-approved: transaction boundary must run on the active wpdb connection before grouped statements execute.
53 $wpdb->query('START TRANSACTION');
54 foreach ($statementArray as $statement) {
55 // DAO-bypass-approved: transaction executor must preserve same-connection transaction state and last_error per statement.
56 $wpdb->query($statement);
57 // Null-safe read (matches DataAccess.php/StatsRepository.php/NGramFilter.php):
58 // a partial test double or a future custom wpdb drop-in that omits
59 // last_error must not raise an "undefined property" notice here.
60 $statementError = trim((string)($wpdb->last_error ?? ''));
61 if ($statementError !== '') {
62 $allIsWell = false;
63 $lastError = $statementError;
64 if (!$this->core->errorClassifier()->classifyAndHandleInfrastructureError($lastError)) {
65 $this->logger->errorMessage("Error executing SQL transaction: " . $lastError);
66 $this->logger->errorMessage("SQL causing the transaction error: " . $statement);
67 }
68 break;
69 }
70 }
71 } catch (Throwable $ex) {
72 $allIsWell = false;
73 $lastException = $ex;
74 $lastError = $ex->getMessage();
75 }
76
77 if ($allIsWell && $lastException == null) {
78 // DAO-bypass-approved: transaction boundary must commit the active wpdb connection.
79 $wpdb->query('commit');
80 return;
81 }
82
83 // DAO-bypass-approved: transaction boundary must roll back the active wpdb connection after any grouped statement failure.
84 $wpdb->query('rollback');
85 $retryable = $this->core->errorClassifier()->isDeadlockOrLockTimeoutError($lastError);
86 if (!$retryable || $attempt >= $maxAttempts) {
87 break;
88 }
89 $sleepMicros = 100000 + random_int(0, 200000);
90 usleep($sleepMicros);
91 }
92
93 if ($lastException != null) {
94 throw $lastException;
95 }
96 if ($lastError !== '') {
97 throw new Exception($lastError); // allow-raw-error: behavior preserved from pre-extraction DatabaseCore::executeAsTransaction
98 }
99 }
100
101 /**
102 * Execute one conditional mutation in a SERIALIZABLE transaction.
103 *
104 * SERIALIZABLE is set for the next transaction only, so the source-range
105 * absence read in INSERT ... SELECT ... WHERE NOT EXISTS is protected from
106 * a concurrent insert without changing the connection's lasting isolation
107 * level. A deadlock loser retries and then observes the winner's row.
108 *
109 * @param array{sql: string, params: array<int, mixed>, description: string} $request
110 * @return array{rows: array<int, mixed>, last_error: string, last_result: array<int, mixed>, rows_affected: int, insert_id: int}
111 */
112 public function executeSerializableMutation(array $request): array {
113 global $wpdb;
114 $prepared = $this->prepareMutation($wpdb, $request['sql'], $request['params']);
115 $guarded = $this->metadataLockWaitGuard->runWithBoundedWait($wpdb, array(
116 'description' => $request['description'],
117 'operation' => function () use ($wpdb, $prepared) {
118 return $this->executeSerializableMutationWithRetry($wpdb, $prepared);
119 },
120 ));
121 $value = $guarded['value'];
122 if ($guarded['status'] !== 'completed' || !is_array($value)
123 || !isset($value['rows'], $value['last_error'], $value['last_result'],
124 $value['rows_affected'], $value['insert_id'])
125 || !is_array($value['rows']) || !is_string($value['last_error'])
126 || !is_array($value['last_result']) || !is_numeric($value['rows_affected'])
127 || !is_numeric($value['insert_id'])) {
128 $error = $guarded['error'] !== ''
129 ? $guarded['error']
130 : 'Could not establish a bounded metadata-lock timeout.';
131 throw new Exception($error); // allow-raw-error: original database guard failure is the actionable context
132 }
133 return array(
134 'rows' => array_values($value['rows']),
135 'last_error' => $value['last_error'],
136 'last_result' => array_values($value['last_result']),
137 'rows_affected' => (int)$value['rows_affected'],
138 'insert_id' => (int)$value['insert_id'],
139 );
140 }
141
142 /**
143 * @param \wpdb $wpdb
144 * @param array<int, mixed> $params
145 */
146 private function prepareMutation($wpdb, string $sql, array $params): string {
147 if (empty($params)) {
148 return $sql;
149 }
150 // DAO-bypass-approved: the transaction owns the active connection and
151 // must bind on the same wpdb handle that executes the statement.
152 $prepared = call_user_func_array(
153 array($wpdb, 'prepare'),
154 array_merge(array($sql), array_values($params))
155 );
156 if (!is_string($prepared) || $prepared === '') {
157 throw new Exception('wpdb could not prepare the serializable mutation.');
158 }
159 return $prepared;
160 }
161
162 /**
163 * @param \wpdb $wpdb
164 * @return array{rows: array<int, mixed>, last_error: string, last_result: array<int, mixed>, rows_affected: int, insert_id: int}
165 */
166 private function executeSerializableMutationWithRetry($wpdb, string $statement): array {
167 $maxAttempts = 3;
168 $lastError = '';
169
170 for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
171 $transactionStarted = false;
172 try {
173 $this->runControlStatement($wpdb, 'SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
174 $this->runControlStatement($wpdb, 'START TRANSACTION');
175 $transactionStarted = true;
176
177 // DAO-bypass-approved: mutation must share the connection and
178 // transaction with its SERIALIZABLE absence check.
179 // DAO-bypass-approved: guarded conditional mutation must execute on the active transaction connection.
180 $queryResult = $wpdb->query($statement);
181 $lastError = trim($wpdb->last_error);
182 if ($queryResult === false || $lastError !== '') {
183 throw new Exception($lastError !== '' ? $lastError : 'Database mutation failed.');
184 }
185
186 $rowsAffected = (int)$wpdb->rows_affected;
187 $insertId = (int)$wpdb->insert_id;
188 $lastResult = is_array($wpdb->last_result) ? $wpdb->last_result : array();
189 $this->runControlStatement($wpdb, 'COMMIT');
190
191 return array(
192 'rows' => array(),
193 'last_error' => '',
194 'last_result' => $lastResult,
195 'rows_affected' => $rowsAffected,
196 'insert_id' => $insertId,
197 );
198 } catch (Throwable $exception) {
199 $lastError = $exception->getMessage();
200 if ($transactionStarted) {
201 // DAO-bypass-approved: failure cleanup on the same active transaction.
202 $wpdb->query('ROLLBACK');
203 }
204 $retryable = $this->core->errorClassifier()->isDeadlockOrLockTimeoutError($lastError);
205 if (!$retryable || $attempt >= $maxAttempts) {
206 throw $exception;
207 }
208 usleep(100000 + random_int(0, 200000));
209 }
210 }
211
212 throw new Exception($lastError !== '' ? $lastError : 'Serializable mutation failed.');
213 }
214
215 /** @param \wpdb $wpdb */
216 private function runControlStatement($wpdb, string $statement): void {
217 // DAO-bypass-approved: transaction/isolation boundary on the active connection.
218 $result = $wpdb->query($statement);
219 $lastError = trim($wpdb->last_error);
220 if ($result === false || $lastError !== '') {
221 throw new Exception($lastError !== '' ? $lastError : 'Database refused: ' . $statement);
222 }
223 }
224 }
225