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

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

237 lines 8.8 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 class ABJ_404_Solution_DatabaseConnectionManager {
8
9 /** @var int Hard stop for result sets abandoned on the shared connection. */
10 private const MAX_PENDING_RESULT_SETS = 64;
11
12 /** @var ABJ_404_Solution_DatabaseCore */
13 private $core;
14
15 /** @var ABJ_404_Solution_Logging */
16 private $logger;
17
18 /**
19 * @param ABJ_404_Solution_DatabaseCore $core
20 * @param ABJ_404_Solution_Logging $logger
21 */
22 public function __construct(ABJ_404_Solution_DatabaseCore $core, $logger) {
23 $this->core = $core;
24 $this->logger = $logger;
25 }
26
27 /**
28 * Forward DatabaseCore infrastructure calls that remain owned by the core.
29 *
30 * @param string $name
31 * @param array<int, mixed> $arguments
32 * @return mixed
33 */
34 public function __call(string $name, array $arguments) {
35 return $this->core->$name(...$arguments);
36 }
37
38 /**
39 * Probe wpdb's check_connection method defensively for custom wpdb
40 * drop-ins (HyperDB, LudicrousDB, mu-cluster proxies) that may not
41 * implement it. WordPress core has shipped this method since 4.1,
42 * but a `wp-content/db.php` drop-in can replace `$wpdb` with a
43 * subclass that omits it. Calling an undefined method on such a
44 * subclass throws a fatal Error before we'd hit a try/catch.
45 *
46 * Returns true (assume connected) when the method is missing -- the
47 * absence of a probe is not a connection failure, and the standard
48 * wpdb default is also "no probe == connected".
49 *
50 * @param object $wpdb The current $wpdb instance (or subclass).
51 * @param bool $allowReconnect Passed through to check_connection().
52 * @return bool True if connected (or unable to probe); false if probed and disconnected.
53 */
54 public function safeCheckConnection($wpdb, bool $allowReconnect = false): bool {
55 if (!is_object($wpdb)) {
56 return true;
57 }
58 if (!method_exists($wpdb, 'check_connection') && !is_callable(array($wpdb, 'check_connection'))) {
59 return true;
60 }
61 return (bool) $wpdb->check_connection($allowReconnect);
62 }
63
64 /**
65 * Ensure database connection is active and reconnect if necessary.
66 *
67 * @return bool True if connection is active, false otherwise
68 */
69 public function ensureConnection(
70 ?ABJ_404_Solution_DatabaseQueryPreflightTracer $preflight = null
71 ) {
72 global $wpdb;
73
74 if (!isset($wpdb)) {
75 return true;
76 }
77
78 try {
79 $initialCheck = fn(): bool => $this->safeCheckConnection($wpdb, false);
80 $isConnected = $preflight === null
81 ? $initialCheck()
82 : $preflight->trace(
83 ABJ_404_Solution_DatabaseQueryPreflightTracer::CONNECTION_CHECK,
84 $initialCheck,
85 array('fields' => array('check_attempt' => 'initial'))
86 );
87
88 if (!$isConnected) {
89 $this->logger->debugMessage("Database connection lost, attempting to reconnect...");
90
91 if (is_object($wpdb) && method_exists($wpdb, 'db_connect')) {
92 $reconnect = static fn() => $wpdb->db_connect();
93 if ($preflight === null) {
94 $reconnect();
95 } else {
96 $preflight->trace(
97 ABJ_404_Solution_DatabaseQueryPreflightTracer::CONNECTION_RECONNECT,
98 $reconnect
99 );
100 }
101 }
102
103 $recheck = fn(): bool => $this->safeCheckConnection($wpdb, false);
104 $reconnected = $preflight === null
105 ? $recheck()
106 : $preflight->trace(
107 ABJ_404_Solution_DatabaseQueryPreflightTracer::CONNECTION_CHECK,
108 $recheck,
109 array('fields' => array('check_attempt' => 'post_reconnect'))
110 );
111 if ($reconnected) {
112 $this->logger->debugMessage("Database reconnection successful");
113 return true;
114 }
115
116 $this->logger->errorMessage("Failed to reconnect to database");
117 return false;
118 }
119 } catch (Exception $e) {
120 $this->logger->debugMessage("Connection check failed: " . $e->getMessage());
121 return true;
122 } catch (Error $e) {
123 $this->logger->debugMessage("Connection check not available: " . $e->getMessage());
124 return true;
125 }
126
127 return true;
128 }
129
130 /**
131 * Reset wpdb state before a recovery retry.
132 *
133 * WordPress's wpdb::flush() drains multi-query results only when
134 * wpdb::$result is a mysqli_result. A query that fails with client error
135 * 2014 leaves that property as false, so flush alone cannot recover the
136 * shared connection. Once a later query has received error 2014, the
137 * pending results have already been abandoned by their owner and must be
138 * consumed before any component can use the handle again.
139 *
140 * Custom wpdb drop-ins may expose PDO or proxy handles instead of mysqli.
141 * Those cannot be drained here; return false so callers do not issue a
142 * retry that is guaranteed to fail.
143 *
144 * @param string $errorText Error produced by the attempt being recovered.
145 * @return bool True when retrying is safe, false when connection state
146 * could not be recovered.
147 */
148 public function resetForRetry(string $errorText = ''): bool {
149 global $wpdb;
150
151 $commandsOutOfSync = $this->core->errorClassifier()
152 ->taxonomy()
153 ->connectivity()
154 ->isCommandsOutOfSyncError($errorText);
155 if ($commandsOutOfSync && !$this->drainPendingMysqliResults($wpdb ?? null)) {
156 return false;
157 }
158
159 if (!is_object($wpdb) || !is_callable(array($wpdb, 'flush'))) {
160 if ($commandsOutOfSync) {
161 $this->logger->warn(
162 'Commands-out-of-sync recovery could not reset wpdb bookkeeping: flush() is unavailable.'
163 );
164 return false;
165 }
166 return true;
167 }
168
169 try {
170 $wpdb->flush();
171 } catch (Throwable $e) {
172 $this->logger->warn(
173 'Database retry reset failed while flushing wpdb state: '
174 . get_class($e) . ': ' . $e->getMessage()
175 );
176 return false;
177 }
178 return true;
179 }
180
181 /**
182 * Consume abandoned result sets from WordPress's shared mysqli handle.
183 *
184 * The fixed bound prevents a malformed or wedged handle from spinning at
185 * shutdown. Each loop consumes the current result before advancing, which
186 * is required by mysqli after multi_query().
187 *
188 * @param mixed $wpdb Current WordPress database object or custom drop-in.
189 * @return bool True only when no pending result set remains.
190 */
191 private function drainPendingMysqliResults($wpdb): bool {
192 $dbh = is_object($wpdb) && isset($wpdb->dbh) ? $wpdb->dbh : null;
193 if (!extension_loaded('mysqli') || !$dbh instanceof \mysqli) {
194 $handleType = is_object($dbh) ? get_class($dbh) : gettype($dbh);
195 $this->logger->warn(
196 'Commands-out-of-sync recovery requires a mysqli handle; current wpdb handle type is '
197 . $handleType . '.'
198 );
199 return false;
200 }
201
202 try {
203 for ($processed = 0; $processed < self::MAX_PENDING_RESULT_SETS; $processed++) {
204 $pendingResult = $dbh->store_result();
205 if ($pendingResult instanceof \mysqli_result) {
206 $pendingResult->free();
207 }
208 if (!$dbh->more_results()) {
209 return true;
210 }
211 if ($processed + 1 >= self::MAX_PENDING_RESULT_SETS) {
212 $this->logger->warn(
213 'Commands-out-of-sync recovery stopped after the fixed limit of '
214 . self::MAX_PENDING_RESULT_SETS . ' pending result sets.'
215 );
216 return false;
217 }
218 if (!$dbh->next_result()) {
219 $this->logger->warn(
220 'Commands-out-of-sync recovery could not advance to the next pending result: '
221 . (string)$dbh->error . ' (mysqli errno ' . (int)$dbh->errno . ').'
222 );
223 return false;
224 }
225 }
226 } catch (Throwable $e) {
227 $this->logger->warn(
228 'Commands-out-of-sync recovery failed while draining mysqli results: '
229 . get_class($e) . ': ' . $e->getMessage()
230 );
231 return false;
232 }
233
234 return false;
235 }
236 }
237