| 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 |
|