PluginProbe ʕ •ᴥ•ʔ
JetBackup – Backup, Restore & Migrate / 3.1.18.8
JetBackup – Backup, Restore & Migrate v3.1.18.8
3.1.22.3 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.8.1 1.4.9 1.5.0 1.5.1 1.5.1.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 1.6.10 1.6.11 1.6.12 1.6.13 1.6.15 1.6.5.1 1.6.8.8 1.6.9 1.6.9.1 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7.5 2.0.8.7 2.0.9.11 2.0.9.14 2.0.9.15 2.0.9.6 2.0.9.7 2.0.9.9 3.1.10.7 3.1.11.1 3.1.12.3 3.1.13.4 3.1.14.17 3.1.15.4 3.1.16.1 3.1.17.5 3.1.18.10 3.1.18.8 3.1.18.9 3.1.19.8 3.1.20.3 3.1.21.3 3.1.7.9 3.1.9.2 trunk 1.1.90 1.1.91 1.2.0 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 1.4.2
backup / src / JetBackup / Data / Mysqldump.php
backup / src / JetBackup / Data Last commit date
.htaccess 5 months ago ArrayData.php 5 months ago DBObject.php 5 months ago Engine.php 5 months ago Mysqldump.php 5 months ago ReflectionObject.php 5 months ago SleekStore.php 5 months ago index.html 5 months ago web.config 5 months ago
Mysqldump.php
541 lines
1 <?php
2
3 namespace JetBackup\Data;
4
5 use Exception;
6 use JetBackup\Factory;
7 use JetBackup\Filesystem\AtomicWrite;
8 use JetBackup\Log\LogController;
9 use Mysqldump\Mysqldump as MysqldumpAlias;
10 use PDO;
11 use PDOException;
12
13 if (!defined( '__JETBACKUP__')) die('Direct access is not allowed');
14
15 class Mysqldump extends MysqldumpAlias {
16
17 private ArrayData $_data;
18 private LogController $_logController;
19
20 const QUERY_MAX_RETRIES = 10;
21 const SQLSTATE_ERRORS = ['08S01', '08001', '40001', 'HY000'];
22
23 /***
24 * These SQLSTATE errors indicate connection failures and trigger a retry.
25 * - '08S01' → Communication link failure (e.g., network disconnect, timeout, or dropped connection)
26 * - '08001' → Client unable to establish a connection (e.g., incorrect credentials, DNS issues)
27 * - '40001' → Transaction deadlock detected (e.g., deadlock errors requiring retry)
28 * - 'HY000' → General MySQL error (covers various issues, including MySQL server disconnects)
29 */
30
31 /**
32 * @throws Exception
33 */
34 public function __construct($db_name, $db_user, $db_password, $db_host) {
35
36 $this->_data = new ArrayData();
37
38 $this->_setDBName($db_name);
39 $this->_setDBPassword($db_password);
40 $this->_setDBUser($db_user);
41 $this->_setDBHost($db_host);
42
43 parent::__construct(
44 'mysql:host='.$this->getDBHost().';port='.$this->getDBPort().';dbname='.$this->getDBName(),
45 $this->getDBUser(),
46 $this->getDBPassword(),
47 [
48 'compress' => MysqldumpAlias::NONE,
49 'add-drop-table' => true,
50 'if-not-exists' => true,
51 'reset-auto-increment' => true,
52 'complete-insert' => true,
53 'default-character-set' => MysqldumpAlias::UTF8MB4,
54 'extended-insert' => false,
55 'insert-ignore' => true,
56 'lock-tables' => false,
57 'init_commands' => [
58 // Snapshot current mode safely (NULL-safe)
59 "SET @jb_sql_mode := IFNULL(@@SESSION.sql_mode, '');",
60
61 // Remove modes that commonly break dumps
62 "SET SESSION sql_mode = TRIM(BOTH ',' FROM
63 REPLACE(
64 REPLACE(
65 REPLACE(
66 REPLACE(
67 REPLACE(
68 CONCAT(',', @jb_sql_mode, ','),
69 ',ONLY_FULL_GROUP_BY,', ','
70 ),
71 ',STRICT_TRANS_TABLES,', ','
72 ),
73 ',STRICT_ALL_TABLES,', ','
74 ),
75 ',NO_ZERO_DATE,', ','
76 ),
77 ',TRADITIONAL,', ','
78 )
79 );",
80 ],
81 ]
82 );
83 }
84
85 public function setDumpSetting($key, $value): void { $this->dumpSettings[$key] = $value; }
86 public function getDumpSetting($key, $default = null) { return $this->dumpSettings[$key] ?? $default; }
87 public function set($key, $value) { $this->_data->set($key, $value); }
88 public function get($key, $default = '') {return $this->_data->get($key, $default);}
89 public function setLogController(LogController $log) {$this->_logController = $log;}
90
91 private function getLogController(): LogController {
92 if (!isset($this->_logController)) $this->_logController = new LogController();
93 return $this->_logController;
94 }
95
96 /**
97 * Check if the given object is a VIEW in the current database.
98 *
99 * @param string $name Table or view name
100 * @return bool true if it's a VIEW, false otherwise
101 * @throws Exception on query failure
102 */
103 public function _isView(string $name): bool {
104 if (!$this->dbHandler) {
105 $this->connect();
106 }
107
108 $sql = "SELECT TABLE_TYPE
109 FROM INFORMATION_SCHEMA.TABLES
110 WHERE TABLE_SCHEMA = :db
111 AND TABLE_NAME = :name
112 LIMIT 1";
113
114 $stmt = $this->dbHandler->prepare($sql);
115 $stmt->execute([
116 ':db' => $this->getDBName(),
117 ':name' => $name
118 ]);
119
120 $type = $stmt->fetchColumn();
121
122 return ($type === 'VIEW');
123 }
124
125 private function _setDBHost($db_host) {
126 if (strpos($db_host, ':') !== false) {
127 $parts = explode(':', $db_host, 2);
128 $db_host = $parts[0] ?? 'localhost';
129 $this->_setDBPort((int) ($parts[1] ?? 3306));
130 }
131 $this->set('db_host', $db_host);
132 }
133
134 public function getDBHost() { return $this->get('db_host'); }
135 private function _setDBPort($db_port) { $this->set('db_port', $db_port); }
136 public function getDBPort() { return $this->get('db_port', Factory::getSettingsGeneral()->getMySQLDefaultPort()); }
137 private function _setDBName($db_name) { $this->set('db_name', $db_name); }
138 public function getDBName() { return $this->get('db_name'); }
139 private function _setDBUser($db_user) { $this->set('db_user', $db_user); }
140 public function getDBUser() { return $this->get('db_user'); }
141 private function _setDBPassword($db_password) { $this->set('db_password', $db_password); }
142 public function getDBPassword() { return $this->get('db_password'); }
143
144 /**
145 * @throws Exception
146 */
147 public function setInclude(array $include) {
148
149 $name = $include[0] ?? null;
150
151 // reset first
152 $this->setDumpSetting('include-tables', []);
153 $this->setDumpSetting('include-views', []);
154 $this->setDumpSetting('exclude-tables', []);
155
156 if (!$name) return;
157
158 $isView = $this->_isView($name);
159
160 if ($isView) {
161 $this->setDumpSetting('include-views', [$name]);
162 $pattern = '/^(?!' . preg_quote($name, '/') . '$).*/';
163 $this->setDumpSetting('exclude-tables', [$pattern]);
164 $this->setDumpSetting('skip-triggers', true);
165 $this->setDumpSetting('add-drop-table', false);
166 $this->setDumpSetting('no-create-info', false);
167 } else {
168 $this->setDumpSetting('include-tables', [$name]);
169 $this->setDumpSetting('include-views', [$name]);
170 $this->setDumpSetting('exclude-tables', []);
171 }
172 }
173
174 public function getInclude() { return $this->getDumpSetting('include-tables', []); }
175 public function setExclude($exclude) { $this->setDumpSetting('no-data', $exclude); }
176 public function getExclude() { return $this->getDumpSetting('no-data', []); }
177
178 /**
179 * @param $buffer
180 * @return mixed|null
181 * Detect problematic SET commands that might involve '@OLD_' or '@saved_' variables
182 */
183 private function _checkResume($buffer) {
184
185 $problematicSetPattern = '/SET\s+\w+\s*=\s*@[\w_]+/i';
186
187 if (preg_match($problematicSetPattern, $buffer)) {
188 $this->getLogController()->logMessage("Skipping problematic statement: {$buffer}");
189 return null; // Skip this query
190 }
191
192 return $buffer;
193 }
194
195 /**
196 * Override connect method with proper retry mechanism.
197 * @throws Exception
198 */
199 protected function connect() {
200
201 $attempts = 0;
202 $waitTime = 500000; // Start with 500ms
203
204 while ($attempts < self::QUERY_MAX_RETRIES) {
205 try {
206 $this->getLogController()->logDebug("Attempting to reconnect to MySQL (Attempt #$attempts)");
207
208 parent::connect();
209
210 if ($this->dbHandler) {
211 $this->getLogController()->logDebug("Successfully reconnected to MySQL.");
212 return;
213 }
214
215 } catch (Exception $e) {
216 $this->getLogController()->logMessage("Reconnect attempt #$attempts failed (SQLSTATE: {$e->getCode()}): " . $e->getMessage());
217
218 if (in_array($e->getCode(), self::SQLSTATE_ERRORS)) {
219 $this->getLogController()->logMessage("Connection error detected (SQLSTATE: {$e->getCode()}), destroying dbHandler...");
220 $this->dbHandler = null;
221 }
222
223 if ($attempts >= self::QUERY_MAX_RETRIES - 1) {
224 throw new Exception("MySQL reconnect failed after $attempts attempts: " . $e->getMessage());
225 }
226
227 usleep($waitTime);
228 $waitTime = min($waitTime * 2, 60000000); // max 60s
229 }
230
231 $attempts++;
232 }
233 }
234
235 /**
236 * Import SQL file with resumable progress tracking.
237 *
238 * @throws Exception
239 */
240 public function import($path) {
241
242 try {
243
244 if (!$path || !is_file($path)) throw new Exception("[import] File {$path} does not exist.");
245
246 $_table = basename($path);
247 $_progress_file = $path . ".progress";
248 $_progress_position = file_exists($_progress_file) ? (int) file_get_contents($_progress_file) : 0;
249
250 $handle = fopen($path, 'rb');
251 if (!$handle) throw new Exception("Failed reading file {$path}. Check access permissions.");
252
253 if (!$this->dbHandler) $this->connect();
254
255 // BEFORE ANY CHANGES:
256 $this->query_exec("SET @jb_prev_sql_mode := @@SESSION.sql_mode;");
257 $this->query_exec("SET @jb_prev_fk := @@SESSION.FOREIGN_KEY_CHECKS;");
258 $this->query_exec("SET SESSION FOREIGN_KEY_CHECKS=0;");
259
260 // Add NO_ENGINE_SUBSTITUTION without clobbering others
261 $this->query_exec("
262 SET SESSION sql_mode = TRIM(BOTH ',' FROM
263 CONCAT_WS(',',
264 REPLACE(REPLACE(@@SESSION.sql_mode, ',NO_ENGINE_SUBSTITUTION', ''), 'NO_ENGINE_SUBSTITUTION', ''),
265 'NO_ENGINE_SUBSTITUTION'
266 )
267 );
268 ");
269
270 // Relax strict/only_full_group_by
271 $this->query_exec("
272 SET SESSION sql_mode = TRIM(BOTH ',' FROM
273 REPLACE(REPLACE(REPLACE(
274 CONCAT(',', IFNULL(@@SESSION.sql_mode,''), ','),
275 ',ONLY_FULL_GROUP_BY,', ','
276 ), ',STRICT_TRANS_TABLES,', ','
277 ), ',STRICT_ALL_TABLES,', ','
278 )
279 );
280 ");
281
282 if ($_progress_position > 0) {
283 fseek($handle, $_progress_position);
284 $this->getLogController()->logMessage("Resuming import for {$_table}, Position: {$_progress_position}");
285 } else {
286 $this->getLogController()->logMessage("Starting new import for: {$_table}");
287 }
288
289 $buffer = '';
290
291 while (!feof($handle)) {
292 $lineRaw = fgets($handle);
293 if ($lineRaw === false) break;
294
295 $lineTrim = ltrim($lineRaw);
296 if (substr($lineTrim, 0, 2) === '--' || trim($lineRaw) === '') continue;
297
298 $buffer .= $lineRaw;
299
300 if (preg_match('/;\s*$/', rtrim($lineRaw))) {
301 try {
302 $stmt = $this->_checkResume($buffer);
303 if ($stmt !== null) {
304 if (stripos($stmt, 'CREATE') !== false && stripos($stmt, 'VIEW') !== false) {
305 $stmt = self::normalizeCreateView($stmt);
306 }
307 $this->query_exec($stmt);
308 }
309
310 try {
311 AtomicWrite::write($_progress_file, (string) ftell($handle), $this->getLogController());
312 } catch (Exception $e) {
313 $this->getLogController()->logError("Failed to update progress file: " . $e->getMessage());
314 // Continue execution despite progress file write failure
315 }
316
317 $buffer = '';
318 } catch (PDOException $e) {
319
320 $this->getLogController()->logMessage("Failed to execute query: {$buffer}");
321 $this->getLogController()->logMessage("Error: " . $e->getMessage());
322 $this->getLogController()->logMessage("SQLSTATE: " . $e->getCode());
323
324 throw new Exception("Failed to execute query: {$buffer}");
325 }
326 }
327 }
328
329 fclose($handle);
330 $this->getLogController()->logMessage("Finished importing {$_table}");
331
332 if (is_file($_progress_file)) {
333 @unlink($_progress_file);
334 $this->getLogController()->logMessage("Progress file for table removed");
335 }
336
337 } catch (Exception $e) {
338 $this->getLogController()->logMessage("Error: " . $e->getMessage());
339 throw new Exception($e->getMessage());
340 } finally {
341 // Best-effort restore of previous mode
342 try { $this->query_exec("SET SESSION sql_mode = @jb_prev_sql_mode;"); } catch (\Exception $e) {}
343 try { $this->query_exec("SET SESSION FOREIGN_KEY_CHECKS=@jb_prev_fk;"); } catch (\Exception $e) {}
344 try { $this->query_exec("SET SESSION sql_mode = @jb_prev_sql_mode;"); } catch (\Exception $e) {}
345 }
346 }
347
348 private static function normalizeCreateView(string $sql): string {
349 if (!preg_match('/\bCREATE\b.*\bVIEW\b/is', $sql)) {
350 return $sql;
351 }
352
353 $parts = preg_split('/\bAS\b/i', $sql, 2);
354 $header = $parts[0] ?? $sql;
355 $body = $parts[1] ?? '';
356
357 $header = preg_replace(
358 '/\/\*!\d+\s+DEFINER\s*=\s*[^*]+SQL\s+SECURITY\s+(?:DEFINER|INVOKER)\s*\*\//i',
359 ' ',
360 $header
361 );
362
363 $header = preg_replace(
364 '/\bDEFINER\s*=\s*(?:`[^`]+`@`[^`]+`|\'[^\']+\'@\'[^\']+\'|[^ \t\n\r\f\)]+)\s*/i',
365 ' ',
366 $header
367 );
368
369 $header = preg_replace('/\bALGORITHM\s*=\s*\w+\s*/i', ' ', $header);
370
371 $header = preg_replace('/\bCREATE\s+(?!OR\s+REPLACE\b)/i', 'CREATE OR REPLACE ', $header, 1);
372
373 if (preg_match('/\bSQL\s+SECURITY\s+(?:DEFINER|INVOKER)\b/i', $header)) {
374 $header = preg_replace('/\bSQL\s+SECURITY\s+(?:DEFINER|INVOKER)\b/i', 'SQL SECURITY INVOKER', $header, 1);
375 } else {
376 $header = preg_replace(
377 '/\b(CREATE\s+(?:OR\s+REPLACE\s+)?)(VIEW\b)/i',
378 '$1SQL SECURITY INVOKER $2',
379 $header,
380 1
381 );
382 }
383
384 $header = preg_replace('/[ \t]+/', ' ', $header);
385 $header = trim($header);
386
387 if ($body === '') {
388 return $header;
389 }
390 return $header . ' AS' . (preg_match('/^\s/', $body) ? '' : ' ') . $body;
391 }
392
393 /**
394 * Check if a table exists in the current database.
395 *
396 * @param string $name Table name (unquoted)
397 * @param bool $includeViews If true, treat views as existing "tables" as well
398 * @return bool
399 * @throws Exception
400 */
401 public function tableExists(string $name, bool $includeViews = true): bool {
402
403 if ($name === '') return false;
404
405 $attempt = 0;
406 $waitTime = 500000; // 0.5s
407 $sql = "SELECT 1
408 FROM INFORMATION_SCHEMA.TABLES
409 WHERE TABLE_SCHEMA = :db
410 AND TABLE_NAME = :name" . ($includeViews ? "" : " AND TABLE_TYPE = 'BASE TABLE'") . "
411 LIMIT 1";
412
413 while ($attempt < self::QUERY_MAX_RETRIES) {
414 try {
415 if (!$this->dbHandler) $this->connect();
416
417 $stmt = $this->dbHandler->prepare($sql);
418 $stmt->execute([
419 ':db' => $this->getDBName(),
420 ':name' => $name,
421 ]);
422
423 return (bool) $stmt->fetchColumn();
424
425 } catch (\PDOException $e) {
426 $sqlState = (string) $e->getCode();
427
428 if (in_array($sqlState, self::SQLSTATE_ERRORS, true)) {
429 $this->getLogController()->logMessage("tableExists(): SQLSTATE {$sqlState}, retrying (attempt #{$attempt})...");
430 $this->connect();
431 usleep($waitTime);
432 $waitTime = min($waitTime * 2, 60000000);
433 $attempt++;
434 continue;
435 }
436
437 throw new Exception("Failed checking existence for table '{$name}' [SQLSTATE {$sqlState}]: " . $e->getMessage(), 0, $e);
438 }
439 }
440
441 return false;
442 }
443
444 /**
445 * Execute a query that returns a result set (e.g., SELECT, SHOW TABLES).
446 *
447 * @param string $query The SQL query to execute.
448 * @param array $params Bound parameters
449 *
450 * @return array|null Returns an array of results or null on failure.
451 * @throws Exception
452 */
453 public function query_exec(string $query, array $params = []): ?array {
454 $waitTime = 500000; // 0.5s
455 $attempt = 0;
456
457 $is_dml = (bool) preg_match('/^\s*(INSERT|UPDATE|DELETE|REPLACE)\b/i', $query);
458 $is_ddl_or_set = (bool) preg_match('/^\s*(CREATE|ALTER|DROP|RENAME|TRUNCATE|GRANT|REVOKE|ANALYZE|OPTIMIZE|REPAIR|SET)\b/i', $query);
459
460 $begin_tx = $is_dml && !$is_ddl_or_set;
461
462 $retryable_sqlstates = ['08S01'];
463 $retryable_drivercodes = [2006, 2013, 1205, 1213];
464
465 while ($attempt < self::QUERY_MAX_RETRIES) {
466 try {
467 if (!$this->dbHandler) {
468 $this->getLogController()->logMessage("No database handler, connecting...");
469 $this->connect();
470 }
471
472 if ($begin_tx) {
473 if ($this->dbHandler->inTransaction()) {
474 $this->getLogController()->logMessage("Warning: already in txn, committing previous one.");
475 try { $this->dbHandler->commit(); } catch (\Throwable $ignore) {}
476 }
477 $this->getLogController()->logMessage(
478 "Starting transaction for: " . (strlen($query) > 90 ? substr($query,0,87) . "..." : $query)
479 );
480 $this->dbHandler->beginTransaction();
481 }
482
483 $stmt = $this->dbHandler->prepare($query);
484 $ok = $stmt->execute($params);
485
486 if ($ok) {
487 if ($begin_tx && $this->dbHandler->inTransaction()) {
488 $this->getLogController()->logMessage(
489 "Committing transaction for: " . (strlen($query) > 90 ? substr($query,0,87) . "..." : $query)
490 );
491 $this->dbHandler->commit();
492 }
493 return $stmt->fetchAll(PDO::FETCH_OBJ);
494 }
495
496 $this->getLogController()->logMessage("Query execution returned false.");
497 return null;
498
499 } catch (PDOException $e) {
500 if ($begin_tx && $this->dbHandler && $this->dbHandler->inTransaction()) {
501 $this->getLogController()->logMessage("Rolling back transaction due to error.");
502 try { $this->dbHandler->rollBack(); } catch (\Throwable $ignore) {}
503 }
504
505 $sqlState = (string) $e->getCode();
506 $errInfo = property_exists($e, 'errorInfo') ? (array) $e->errorInfo : [];
507 $driverCode = $errInfo[1] ?? null;
508 $driverMsg = $errInfo[2] ?? '';
509 $snippet = (strlen($query) > 300) ? substr($query,0,297) . '...' : $query;
510
511 $this->getLogController()->logError(
512 "[SQL ERROR] SQLSTATE={$sqlState} driverCode=" . var_export($driverCode,true) .
513 " msg=" . $e->getMessage() . " driverMsg=" . $driverMsg .
514 " | Query: {$snippet} | Params: " . json_encode($params)
515 );
516
517 $retryable = in_array($sqlState, $retryable_sqlstates, true)
518 || (is_int($driverCode) && in_array($driverCode, $retryable_drivercodes, true));
519
520 if ($retryable) {
521 $this->getLogController()->logMessage("Transient DB error; reconnecting and retrying (attempt {$attempt}).");
522 try { $this->connect(); } catch (\Throwable $ignore) {}
523 usleep($waitTime);
524 $waitTime = min($waitTime * 2, 60000000);
525 $attempt++;
526 continue;
527 }
528
529 throw new Exception(
530 "Query error [SQLSTATE {$sqlState}" . ($driverCode !== null ? "/{$driverCode}" : "") . "]: " . $e->getMessage(),
531 0,
532 $e
533 );
534 }
535 }
536
537 $this->getLogController()->logError("Max retries reached for query.");
538 return null;
539 }
540 }
541