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 / core / DatabaseMetadataLockWaitGuard.php

DatabaseMetadataLockWaitGuard.php in 404 Solution trunk, at includes/core/DatabaseMetadataLockWaitGuard.php

355 lines 14.1 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__ . '/PhpErrorLogFallback.php';
8
9 /**
10 * Explicit adapter for wpdb drop-ins whose underlying connection is neither
11 * mysqli nor a MySQL PDO connection.
12 */
13 interface ABJ_404_Solution_DatabaseSessionStatementConnection {
14 /** @return array{success: bool, error: string} */
15 public function executeAbj404SessionStatement(string $sql): array;
16 }
17
18 /**
19 * Bounds one metadata-lock-sensitive operation without changing the session's
20 * lasting configuration or wpdb's mutable result envelope.
21 *
22 * MySQL's lock_wait_timeout defaults to 31,536,000 seconds. Unlike
23 * innodb_lock_wait_timeout, it governs metadata locks, so even a normally fast
24 * SHOW COLUMNS, ALTER TABLE, or INSERT into a table being altered can otherwise
25 * hold a PHP request for effectively a year. The guard writes its temporary
26 * session settings through the underlying connection rather than wpdb: those
27 * bookkeeping statements therefore cannot overwrite last_error, last_query,
28 * rows_affected, or appear to callers as application queries.
29 */
30 final class ABJ_404_Solution_DatabaseMetadataLockWaitGuard {
31
32 private const MAX_WAIT_SECONDS = 5;
33
34 /** @var int Distinguishes nested guarded operations on one connection. */
35 private static $nextSavedVariableId = 1;
36
37 /** @var ABJ_404_Solution_Logging|(callable(): (ABJ_404_Solution_Logging|null))|null */
38 private $logger;
39
40 /** @var (callable(mixed, string): array{success: bool, error: string})|null */
41 private $sessionStatementRunner;
42
43 /**
44 * @param ABJ_404_Solution_Logging|(callable(): (ABJ_404_Solution_Logging|null))|null $logger
45 * @param (callable(mixed, string): array{success: bool, error: string})|null $sessionStatementRunner
46 * Optional connection adapter for database drop-ins whose handle is not
47 * mysqli or PDO. The ordinary WordPress path needs no adapter.
48 */
49 public function __construct($logger = null, $sessionStatementRunner = null) {
50 $this->logger = $logger;
51 $this->sessionStatementRunner = is_callable($sessionStatementRunner)
52 ? $sessionStatementRunner : null;
53 }
54
55 /**
56 * @param object $wpdb Active WordPress database handle or compatible drop-in.
57 * @param array{description: string, operation: callable(): mixed} $request
58 * @return array{status: 'completed'|'setup_failed', value: mixed, error: string}
59 */
60 public function runWithBoundedWait($wpdb, array $request): array {
61 $description = $request['description'];
62 $operation = $request['operation'];
63 $connection = $this->connectionOf($wpdb);
64
65 if (!$this->canRunSessionStatement($connection)) {
66 $error = 'No supported live database session connection is available.';
67 $this->restoreLastError($wpdb, $error);
68 $this->warnSetupFailure($description, $error);
69 return array(
70 'status' => 'setup_failed',
71 'value' => null,
72 'error' => $error,
73 );
74 }
75
76 $savedVariable = '@abj404_saved_lock_wait_timeout_' . self::$nextSavedVariableId++;
77 $setup = $this->runSessionStatement(
78 $connection,
79 'SET ' . $savedVariable . ' = @@SESSION.lock_wait_timeout'
80 );
81 if (!$setup['success']) {
82 $this->restoreLastError($wpdb, $setup['error']);
83 $this->warnSetupFailure($description, $setup['error']);
84 return array('status' => 'setup_failed', 'value' => null, 'error' => $setup['error']);
85 }
86
87 // Keep an existing stricter host policy rather than widening it to the
88 // plugin ceiling. Split this from the save statement because MySQL and
89 // MariaDB do not accept the same mixed user/system-variable SET form.
90 $setup = $this->runSessionStatement(
91 $connection,
92 'SET SESSION lock_wait_timeout = CAST(LEAST(' . $savedVariable . ', '
93 . self::MAX_WAIT_SECONDS . ') AS UNSIGNED)'
94 );
95 if (!$setup['success']) {
96 $this->clearSavedVariable(array(
97 'connection' => $connection,
98 'savedVariable' => $savedVariable,
99 'description' => $description,
100 ));
101 $this->restoreLastError($wpdb, $setup['error']);
102 $this->warnSetupFailure($description, $setup['error']);
103 return array('status' => 'setup_failed', 'value' => null, 'error' => $setup['error']);
104 }
105
106 try {
107 $value = $operation();
108 $operationError = $this->lastError($wpdb);
109 } catch (Throwable $exception) {
110 $this->restoreSessionTimeout(array(
111 'connection' => $connection,
112 'savedVariable' => $savedVariable,
113 'description' => $description,
114 ));
115 throw $exception;
116 }
117
118 $this->restoreSessionTimeout(array(
119 'connection' => $connection,
120 'savedVariable' => $savedVariable,
121 'description' => $description,
122 ));
123 return array('status' => 'completed', 'value' => $value, 'error' => $operationError);
124 }
125
126 /**
127 * @param object $wpdb
128 * @return mixed
129 */
130 private function connectionOf($wpdb) {
131 // PDO-backed wpdb adapters commonly keep the live session in a private
132 // pdo field instead of exposing WordPress's mysqli-oriented dbh field.
133 foreach (array('dbh', 'pdo') as $propertyName) {
134 $property = $this->readProperty($wpdb, $propertyName);
135 if ($property['found'] && $property['value'] !== null) {
136 return $property['value'];
137 }
138 }
139 return null;
140 }
141
142 /** @param mixed $connection */
143 private function canRunSessionStatement($connection): bool {
144 if ($this->sessionStatementRunner !== null) {
145 return $connection !== null;
146 }
147 return (class_exists('mysqli', false) && $connection instanceof mysqli)
148 || $this->isMysqlPdo($connection)
149 || $connection instanceof ABJ_404_Solution_DatabaseSessionStatementConnection;
150 }
151
152 /** @param mixed $connection */
153 private function isMysqlPdo($connection): bool {
154 if (!$connection instanceof PDO) {
155 return false;
156 }
157 try {
158 return $connection->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql';
159 } catch (Throwable $exception) {
160 $this->warn(
161 'Could not identify the PDO driver before applying the database metadata-lock guard. '
162 . get_class($exception) . ': ' . $exception->getMessage()
163 );
164 return false;
165 }
166 }
167
168 /**
169 * @param mixed $connection
170 * @return array{success: bool, error: string}
171 */
172 private function runSessionStatement($connection, string $sql): array {
173 try {
174 if ($this->sessionStatementRunner !== null) {
175 $adapterResponse = call_user_func($this->sessionStatementRunner, $connection, $sql);
176 if (is_array($adapterResponse)
177 && isset($adapterResponse['success'], $adapterResponse['error'])
178 && is_bool($adapterResponse['success'])
179 && is_string($adapterResponse['error'])) {
180 return $adapterResponse;
181 }
182 return array(
183 'success' => false,
184 'error' => 'The metadata-lock session adapter returned an invalid result.',
185 );
186 }
187
188 if ($connection instanceof ABJ_404_Solution_DatabaseSessionStatementConnection) {
189 return $connection->executeAbj404SessionStatement($sql);
190 }
191
192 if (class_exists('mysqli', false) && $connection instanceof mysqli) {
193 $mysqliResponse = mysqli_query($connection, $sql);
194 return array(
195 'success' => $mysqliResponse !== false,
196 'error' => $mysqliResponse === false ? (string)mysqli_error($connection) : '',
197 );
198 }
199
200 if ($connection instanceof PDO) {
201 $pdoResponse = $connection->exec($sql);
202 $error = $pdoResponse === false ? $connection->errorInfo() : array();
203 return array(
204 'success' => $pdoResponse !== false,
205 'error' => $pdoResponse === false && isset($error[2]) ? (string)$error[2] : '',
206 );
207 }
208 } catch (Throwable $t) {
209 return array(
210 'success' => false,
211 'error' => get_class($t) . ': ' . $t->getMessage(),
212 );
213 }
214
215 return array('success' => false, 'error' => 'No supported database session connection.');
216 }
217
218 /** @param array{connection: mixed, savedVariable: string, description: string} $request */
219 private function restoreSessionTimeout(array $request): void {
220 $connection = $request['connection'];
221 $savedVariable = $request['savedVariable'];
222 $description = $request['description'];
223 $restore = $this->runSessionStatement(
224 $connection,
225 'SET SESSION lock_wait_timeout = ' . $savedVariable
226 );
227 if (!$restore['success']) {
228 $this->warn(
229 'Could not restore the database metadata-lock timeout after ' . $description . '. '
230 . 'The connection retains the shorter safety timeout. Database error: '
231 . ($restore['error'] !== '' ? $restore['error'] : '(none reported)')
232 );
233 }
234 $this->clearSavedVariable($request);
235 }
236
237 /** @param array{connection: mixed, savedVariable: string, description: string} $request */
238 private function clearSavedVariable(array $request): void {
239 $connection = $request['connection'];
240 $savedVariable = $request['savedVariable'];
241 $description = $request['description'];
242 $clear = $this->runSessionStatement($connection, 'SET ' . $savedVariable . ' = NULL');
243 if (!$clear['success']) {
244 $this->warn(
245 'Could not clear the saved metadata-lock timeout variable after ' . $description . '. '
246 . 'Database error: ' . ($clear['error'] !== '' ? $clear['error'] : '(none reported)')
247 );
248 }
249 }
250
251 /** @param object $wpdb */
252 private function lastError($wpdb): string {
253 $property = $this->readProperty($wpdb, 'last_error');
254 return $property['found'] && is_scalar($property['value'])
255 ? trim((string)$property['value'])
256 : '';
257 }
258
259 /** @param object $wpdb */
260 private function restoreLastError($wpdb, string $error): void {
261 $this->writeProperty($wpdb, 'last_error', $error);
262 }
263
264 /**
265 * @param object $object
266 * @return array{found: bool, value: mixed}
267 */
268 private function readProperty($object, string $name): array {
269 if (property_exists($object, $name)) {
270 try {
271 $property = new ReflectionProperty($object, $name);
272 if (PHP_VERSION_ID < 80100) {
273 $property->setAccessible(true);
274 }
275 return array('found' => true, 'value' => $property->getValue($object));
276 } catch (Throwable $exception) {
277 $this->warn(
278 'Could not read wpdb field ' . $name . ' around a guarded operation. '
279 . get_class($exception) . ': ' . $exception->getMessage()
280 );
281 return array('found' => false, 'value' => null);
282 }
283 }
284 if (is_callable(array($object, '__get'))) {
285 try {
286 return array('found' => true, 'value' => call_user_func(array($object, '__get'), $name));
287 } catch (Throwable $exception) {
288 $this->warn(
289 'Could not read wpdb drop-in field ' . $name . ' around a guarded operation. '
290 . get_class($exception) . ': ' . $exception->getMessage()
291 );
292 }
293 }
294 return array('found' => false, 'value' => null);
295 }
296
297 /**
298 * @param object $object
299 * @param mixed $value
300 */
301 private function writeProperty($object, string $name, $value): void {
302 if (property_exists($object, $name)) {
303 try {
304 $property = new ReflectionProperty($object, $name);
305 if (PHP_VERSION_ID < 80100) {
306 $property->setAccessible(true);
307 }
308 $property->setValue($object, $value);
309 } catch (Throwable $exception) {
310 $this->warn(
311 'Could not restore wpdb field ' . $name . ' after guard setup failed. '
312 . get_class($exception) . ': ' . $exception->getMessage()
313 );
314 }
315 return;
316 }
317 if (is_callable(array($object, '__set'))) {
318 try {
319 call_user_func(array($object, '__set'), $name, $value);
320 } catch (Throwable $exception) {
321 $this->warn(
322 'Could not restore wpdb drop-in field ' . $name . ' after guard setup failed. '
323 . get_class($exception) . ': ' . $exception->getMessage()
324 );
325 }
326 }
327 }
328
329 private function warnSetupFailure(string $description, string $error): void {
330 $this->warn(
331 'Could not establish a bounded database metadata-lock timeout before '
332 . $description . '; the potentially unbounded operation was not attempted. '
333 . 'Database error: ' . ($error !== '' ? $error : '(none reported)')
334 );
335 }
336
337 private function warn(string $message): void {
338 $logger = $this->logger;
339 if (is_callable($logger) && (!is_object($logger) || !method_exists($logger, 'warn'))) {
340 try {
341 $logger = $logger();
342 } catch (Throwable $exception) {
343 abj404_logPhpFallback(
344 'service-resolution-fallback',
345 $message . ' Logger resolution also failed: ' . $exception->getMessage()
346 );
347 return;
348 }
349 }
350 if (is_object($logger) && method_exists($logger, 'warn')) {
351 $logger->warn($message);
352 }
353 }
354 }
355