PluginProbe ʕ •ᴥ•ʔ
JetBackup – Backup, Restore & Migrate / trunk
JetBackup – Backup, Restore & Migrate vtrunk
3.1.23.6 3.1.23.5 3.1.23.3 3.1.22.4 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 1 year ago ArrayData.php 1 year ago DBObject.php 1 year ago Engine.php 1 year ago Mysqldump.php 3 days ago ReflectionObject.php 1 year ago SleekStore.php 1 year ago SqlStatementParser.php 3 days ago index.html 1 year ago web.config 1 year ago
Mysqldump.php
515 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 // Only a real SET statement uses the @OLD_/@saved_ session vars that may not survive the
186 // import and need skipping. Check the statement's first word, not the whole text, so a value
187 // like "... SET x=@OLD_FOO ..." inside an INSERT can't make us skip (and lose) that row.
188 $head = SqlStatementParser::effectiveHead($buffer);
189
190 if (strncasecmp($head, 'SET', 3) === 0 && preg_match('/^SET\b[\s\S]*=\s*@[\w_]+/i', $head)) {
191 $this->getLogController()->logMessage("Skipping problematic statement: {$buffer}");
192 return null; // Skip this query
193 }
194
195 return $buffer;
196 }
197
198 /**
199 * Override connect method with proper retry mechanism.
200 * @throws Exception
201 */
202 protected function connect() {
203
204 $attempts = 0;
205 $waitTime = 500000; // Start with 500ms
206
207 while ($attempts < self::QUERY_MAX_RETRIES) {
208 try {
209 $this->getLogController()->logDebug("Attempting to reconnect to MySQL (Attempt #$attempts)");
210
211 parent::connect();
212
213 if ($this->dbHandler) {
214 $this->getLogController()->logDebug("Successfully reconnected to MySQL.");
215 return;
216 }
217
218 } catch (Exception $e) {
219 $this->getLogController()->logMessage("Reconnect attempt #$attempts failed (SQLSTATE: {$e->getCode()}): " . $e->getMessage());
220
221 if (in_array($e->getCode(), self::SQLSTATE_ERRORS)) {
222 $this->getLogController()->logMessage("Connection error detected (SQLSTATE: {$e->getCode()}), destroying dbHandler...");
223 $this->dbHandler = null;
224 }
225
226 if ($attempts >= self::QUERY_MAX_RETRIES - 1) {
227 throw new Exception("MySQL reconnect failed after $attempts attempts: " . $e->getMessage());
228 }
229
230 usleep($waitTime);
231 $waitTime = min($waitTime * 2, 60000000); // max 60s
232 }
233
234 $attempts++;
235 }
236 }
237
238 /**
239 * Import SQL file with resumable progress tracking.
240 *
241 * @throws Exception
242 */
243 public function import($path) {
244
245 try {
246
247 if (!$path || !is_file($path)) throw new Exception("[import] File {$path} does not exist.");
248
249 $_table = basename($path);
250 $_progress_file = $path . ".progress";
251 $_progress_position = file_exists($_progress_file) ? (int) file_get_contents($_progress_file) : 0;
252
253 $handle = fopen($path, 'rb');
254 if (!$handle) throw new Exception("Failed reading file {$path}. Check access permissions.");
255
256 if (!$this->dbHandler) $this->connect();
257
258 // BEFORE ANY CHANGES:
259 $this->query_exec("SET @jb_prev_sql_mode := @@SESSION.sql_mode;");
260 $this->query_exec("SET @jb_prev_fk := @@SESSION.FOREIGN_KEY_CHECKS;");
261 $this->query_exec("SET SESSION FOREIGN_KEY_CHECKS=0;");
262
263 // Add NO_ENGINE_SUBSTITUTION without clobbering others
264 $this->query_exec("
265 SET SESSION sql_mode = TRIM(BOTH ',' FROM
266 CONCAT_WS(',',
267 REPLACE(REPLACE(@@SESSION.sql_mode, ',NO_ENGINE_SUBSTITUTION', ''), 'NO_ENGINE_SUBSTITUTION', ''),
268 'NO_ENGINE_SUBSTITUTION'
269 )
270 );
271 ");
272
273 // Relax strict/only_full_group_by
274 $this->query_exec("
275 SET SESSION sql_mode = TRIM(BOTH ',' FROM
276 REPLACE(REPLACE(REPLACE(
277 CONCAT(',', IFNULL(@@SESSION.sql_mode,''), ','),
278 ',ONLY_FULL_GROUP_BY,', ','
279 ), ',STRICT_TRANS_TABLES,', ','
280 ), ',STRICT_ALL_TABLES,', ','
281 )
282 );
283 ");
284
285 if ($_progress_position > 0) {
286 fseek($handle, $_progress_position);
287 $this->getLogController()->logMessage("Resuming import for {$_table}, Position: {$_progress_position}");
288 } else {
289 $this->getLogController()->logMessage("Starting new import for: {$_table}");
290 }
291
292 // The parser splits statements while ignoring quotes and comments, so a ';' (or a
293 // CREATE/VIEW/DEFINER= word) inside a quoted value is never read as SQL. It also handles
294 // comment/blank lines, so we don't pre-skip lines by hand anymore.
295 $parser = new SqlStatementParser();
296
297 while (!feof($handle)) {
298 $lineRaw = fgets($handle);
299 if ($lineRaw === false) break;
300
301 foreach ($parser->feed($lineRaw) as $rawStmt) {
302 $this->_importStatement($rawStmt);
303 }
304
305 // Save the resume position only when we're between statements, so a resume always
306 // starts on a whole statement.
307 if (!$parser->hasPending()) {
308 try {
309 AtomicWrite::write($_progress_file, (string) ftell($handle), $this->getLogController());
310 } catch (Exception $e) {
311 $this->getLogController()->logError("Failed to update progress file: " . $e->getMessage());
312 // keep going even if the progress file can't be written
313 }
314 }
315 }
316
317 // A last statement with no ';' at the end (some dumps skip it).
318 if (($rawStmt = $parser->flush()) !== null) {
319 $this->_importStatement($rawStmt);
320 }
321
322 fclose($handle);
323 $this->getLogController()->logMessage("Finished importing {$_table}");
324
325 if (is_file($_progress_file)) {
326 @unlink($_progress_file);
327 $this->getLogController()->logMessage("Progress file for table removed");
328 }
329
330 } catch (Exception $e) {
331 $this->getLogController()->logMessage("Error: " . $e->getMessage());
332 throw new Exception($e->getMessage());
333 } finally {
334 // Best-effort restore of previous mode
335 try { $this->query_exec("SET SESSION sql_mode = @jb_prev_sql_mode;"); } catch (\Exception $e) {}
336 try { $this->query_exec("SET SESSION FOREIGN_KEY_CHECKS=@jb_prev_fk;"); } catch (\Exception $e) {}
337 try { $this->query_exec("SET SESSION sql_mode = @jb_prev_sql_mode;"); } catch (\Exception $e) {}
338 }
339 }
340
341 /**
342 * Run one statement from the dump: skip the session-restore SETs, fix up a real CREATE VIEW so it
343 * restores on another server, then run it. A statement counts as a view only when it truly starts
344 * with CREATE ... VIEW, so an INSERT that just mentions those words runs as-is.
345 *
346 * @throws Exception
347 */
348 private function _importStatement(string $rawStmt): void {
349 try {
350 $stmt = $this->_checkResume($rawStmt);
351 if ($stmt === null) return;
352
353 if (SqlStatementParser::isCreateView($stmt)) {
354 $stmt = SqlStatementParser::normalizeCreateView($stmt);
355 }
356
357 $this->query_exec($stmt);
358 } catch (PDOException $e) {
359 $this->getLogController()->logMessage("Failed to execute query: {$rawStmt}");
360 $this->getLogController()->logMessage("Error: " . $e->getMessage());
361 $this->getLogController()->logMessage("SQLSTATE: " . $e->getCode());
362
363 throw new Exception("Failed to execute query: {$rawStmt}");
364 }
365 }
366
367 /**
368 * Check if a table exists in the current database.
369 *
370 * @param string $name Table name (unquoted)
371 * @param bool $includeViews If true, treat views as existing "tables" as well
372 * @return bool
373 * @throws Exception
374 */
375 public function tableExists(string $name, bool $includeViews = true): bool {
376
377 if ($name === '') return false;
378
379 $attempt = 0;
380 $waitTime = 500000; // 0.5s
381 $sql = "SELECT 1
382 FROM INFORMATION_SCHEMA.TABLES
383 WHERE TABLE_SCHEMA = :db
384 AND TABLE_NAME = :name" . ($includeViews ? "" : " AND TABLE_TYPE = 'BASE TABLE'") . "
385 LIMIT 1";
386
387 while ($attempt < self::QUERY_MAX_RETRIES) {
388 try {
389 if (!$this->dbHandler) $this->connect();
390
391 $stmt = $this->dbHandler->prepare($sql);
392 $stmt->execute([
393 ':db' => $this->getDBName(),
394 ':name' => $name,
395 ]);
396
397 return (bool) $stmt->fetchColumn();
398
399 } catch (\PDOException $e) {
400 $sqlState = (string) $e->getCode();
401
402 if (in_array($sqlState, self::SQLSTATE_ERRORS, true)) {
403 $this->getLogController()->logMessage("tableExists(): SQLSTATE {$sqlState}, retrying (attempt #{$attempt})...");
404 $this->connect();
405 usleep($waitTime);
406 $waitTime = min($waitTime * 2, 60000000);
407 $attempt++;
408 continue;
409 }
410
411 throw new Exception("Failed checking existence for table '{$name}' [SQLSTATE {$sqlState}]: " . $e->getMessage(), 0, $e);
412 }
413 }
414
415 return false;
416 }
417
418 /**
419 * Execute a query that returns a result set (e.g., SELECT, SHOW TABLES).
420 *
421 * @param string $query The SQL query to execute.
422 * @param array $params Bound parameters
423 *
424 * @return array|null Returns an array of results or null on failure.
425 * @throws Exception
426 */
427 public function query_exec(string $query, array $params = []): ?array {
428 $waitTime = 500000; // 0.5s
429 $attempt = 0;
430
431 $is_dml = (bool) preg_match('/^\s*(INSERT|UPDATE|DELETE|REPLACE)\b/i', $query);
432 $is_ddl_or_set = (bool) preg_match('/^\s*(CREATE|ALTER|DROP|RENAME|TRUNCATE|GRANT|REVOKE|ANALYZE|OPTIMIZE|REPAIR|SET)\b/i', $query);
433
434 $begin_tx = $is_dml && !$is_ddl_or_set;
435
436 $retryable_sqlstates = ['08S01'];
437 $retryable_drivercodes = [2006, 2013, 1205, 1213];
438
439 while ($attempt < self::QUERY_MAX_RETRIES) {
440 try {
441 if (!$this->dbHandler) {
442 $this->getLogController()->logMessage("No database handler, connecting...");
443 $this->connect();
444 }
445
446 if ($begin_tx) {
447 if ($this->dbHandler->inTransaction()) {
448 $this->getLogController()->logMessage("Warning: already in txn, committing previous one.");
449 try { $this->dbHandler->commit(); } catch (\Throwable $ignore) {}
450 }
451 $this->getLogController()->logMessage(
452 "Starting transaction for: " . (strlen($query) > 90 ? substr($query,0,87) . "..." : $query)
453 );
454 $this->dbHandler->beginTransaction();
455 }
456
457 $stmt = $this->dbHandler->prepare($query);
458 $ok = $stmt->execute($params);
459
460 if ($ok) {
461 if ($begin_tx && $this->dbHandler->inTransaction()) {
462 $this->getLogController()->logMessage(
463 "Committing transaction for: " . (strlen($query) > 90 ? substr($query,0,87) . "..." : $query)
464 );
465 $this->dbHandler->commit();
466 }
467 return $stmt->fetchAll(PDO::FETCH_OBJ);
468 }
469
470 $this->getLogController()->logMessage("Query execution returned false.");
471 return null;
472
473 } catch (PDOException $e) {
474 if ($begin_tx && $this->dbHandler && $this->dbHandler->inTransaction()) {
475 $this->getLogController()->logMessage("Rolling back transaction due to error.");
476 try { $this->dbHandler->rollBack(); } catch (\Throwable $ignore) {}
477 }
478
479 $sqlState = (string) $e->getCode();
480 $errInfo = property_exists($e, 'errorInfo') ? (array) $e->errorInfo : [];
481 $driverCode = $errInfo[1] ?? null;
482 $driverMsg = $errInfo[2] ?? '';
483 $snippet = (strlen($query) > 300) ? substr($query,0,297) . '...' : $query;
484
485 $this->getLogController()->logError(
486 "[SQL ERROR] SQLSTATE={$sqlState} driverCode=" . var_export($driverCode,true) .
487 " msg=" . $e->getMessage() . " driverMsg=" . $driverMsg .
488 " | Query: {$snippet} | Params: " . json_encode($params)
489 );
490
491 $retryable = in_array($sqlState, $retryable_sqlstates, true)
492 || (is_int($driverCode) && in_array($driverCode, $retryable_drivercodes, true));
493
494 if ($retryable) {
495 $this->getLogController()->logMessage("Transient DB error; reconnecting and retrying (attempt {$attempt}).");
496 try { $this->connect(); } catch (\Throwable $ignore) {}
497 usleep($waitTime);
498 $waitTime = min($waitTime * 2, 60000000);
499 $attempt++;
500 continue;
501 }
502
503 throw new Exception(
504 "Query error [SQLSTATE {$sqlState}" . ($driverCode !== null ? "/{$driverCode}" : "") . "]: " . $e->getMessage(),
505 0,
506 $e
507 );
508 }
509 }
510
511 $this->getLogController()->logError("Max retries reached for query.");
512 return null;
513 }
514 }
515