starrocks-br 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- starrocks_br/cli.py +104 -8
- starrocks_br/config.py +26 -3
- starrocks_br/db.py +75 -9
- starrocks_br/executor.py +62 -12
- starrocks_br/planner.py +41 -17
- starrocks_br/restore.py +64 -12
- starrocks_br/schema.py +6 -3
- starrocks_br/timezone.py +125 -0
- starrocks_br-0.3.0.dist-info/METADATA +456 -0
- starrocks_br-0.3.0.dist-info/RECORD +20 -0
- starrocks_br-0.1.0.dist-info/METADATA +0 -12
- starrocks_br-0.1.0.dist-info/RECORD +0 -19
- {starrocks_br-0.1.0.dist-info → starrocks_br-0.3.0.dist-info}/WHEEL +0 -0
- {starrocks_br-0.1.0.dist-info → starrocks_br-0.3.0.dist-info}/entry_points.txt +0 -0
- {starrocks_br-0.1.0.dist-info → starrocks_br-0.3.0.dist-info}/top_level.txt +0 -0
starrocks_br/cli.py
CHANGED
|
@@ -15,6 +15,40 @@ from . import schema
|
|
|
15
15
|
from . import logger
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def _handle_snapshot_exists_error(error_details: dict, label: str, config: str, repository: str, backup_type: str, group: str, baseline_backup: str = None) -> None:
|
|
19
|
+
"""Handle snapshot_exists error by providing helpful guidance to the user.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
error_details: Error details dict containing error_type and snapshot_name
|
|
23
|
+
label: The backup label that was generated
|
|
24
|
+
config: Path to config file
|
|
25
|
+
repository: Repository name
|
|
26
|
+
backup_type: Type of backup ('incremental' or 'full')
|
|
27
|
+
group: Inventory group name
|
|
28
|
+
baseline_backup: Optional baseline backup label (for incremental backups)
|
|
29
|
+
"""
|
|
30
|
+
snapshot_name = error_details.get('snapshot_name', label)
|
|
31
|
+
logger.error(f"Snapshot '{snapshot_name}' already exists in the repository.")
|
|
32
|
+
logger.info("")
|
|
33
|
+
logger.info("This typically happens when:")
|
|
34
|
+
logger.info(" • The CLI lost connectivity during a previous backup operation")
|
|
35
|
+
logger.info(" • The backup completed on the server, but backup_history wasn't updated")
|
|
36
|
+
logger.info("")
|
|
37
|
+
logger.info("To resolve this, retry the backup with a custom label using --name:")
|
|
38
|
+
|
|
39
|
+
if backup_type == 'incremental':
|
|
40
|
+
retry_cmd = f" starrocks-br backup incremental --config {config} --group {group} --name {snapshot_name}_retry"
|
|
41
|
+
if baseline_backup:
|
|
42
|
+
retry_cmd += f" --baseline-backup {baseline_backup}"
|
|
43
|
+
logger.info(retry_cmd)
|
|
44
|
+
else:
|
|
45
|
+
logger.info(f" starrocks-br backup full --config {config} --group {group} --name {snapshot_name}_retry")
|
|
46
|
+
|
|
47
|
+
logger.info("")
|
|
48
|
+
logger.tip("You can verify the existing backup by checking the repository or running:")
|
|
49
|
+
logger.tip(f" SHOW SNAPSHOT ON {repository} WHERE Snapshot = '{snapshot_name}'")
|
|
50
|
+
|
|
51
|
+
|
|
18
52
|
@click.group()
|
|
19
53
|
def cli():
|
|
20
54
|
"""StarRocks Backup & Restore automation tool."""
|
|
@@ -43,7 +77,8 @@ def init(config):
|
|
|
43
77
|
port=cfg['port'],
|
|
44
78
|
user=cfg['user'],
|
|
45
79
|
password=os.getenv('STARROCKS_PASSWORD'),
|
|
46
|
-
database=cfg['database']
|
|
80
|
+
database=cfg['database'],
|
|
81
|
+
tls_config=cfg.get('tls'),
|
|
47
82
|
)
|
|
48
83
|
|
|
49
84
|
with database:
|
|
@@ -102,7 +137,8 @@ def backup_incremental(config, baseline_backup, group, name):
|
|
|
102
137
|
port=cfg['port'],
|
|
103
138
|
user=cfg['user'],
|
|
104
139
|
password=os.getenv('STARROCKS_PASSWORD'),
|
|
105
|
-
database=cfg['database']
|
|
140
|
+
database=cfg['database'],
|
|
141
|
+
tls_config=cfg.get('tls'),
|
|
106
142
|
)
|
|
107
143
|
|
|
108
144
|
with database:
|
|
@@ -174,6 +210,13 @@ def backup_incremental(config, baseline_backup, group, name):
|
|
|
174
210
|
logger.success(f"Backup completed successfully: {result['final_status']['state']}")
|
|
175
211
|
sys.exit(0)
|
|
176
212
|
else:
|
|
213
|
+
error_details = result.get('error_details')
|
|
214
|
+
if error_details and error_details.get('error_type') == 'snapshot_exists':
|
|
215
|
+
_handle_snapshot_exists_error(
|
|
216
|
+
error_details, label, config, cfg['repository'], 'incremental', group, baseline_backup
|
|
217
|
+
)
|
|
218
|
+
sys.exit(1)
|
|
219
|
+
|
|
177
220
|
state = result.get('final_status', {}).get('state', 'UNKNOWN')
|
|
178
221
|
if state == "LOST":
|
|
179
222
|
logger.critical("Backup tracking lost!")
|
|
@@ -215,7 +258,8 @@ def backup_full(config, group, name):
|
|
|
215
258
|
port=cfg['port'],
|
|
216
259
|
user=cfg['user'],
|
|
217
260
|
password=os.getenv('STARROCKS_PASSWORD'),
|
|
218
|
-
database=cfg['database']
|
|
261
|
+
database=cfg['database'],
|
|
262
|
+
tls_config=cfg.get('tls'),
|
|
219
263
|
)
|
|
220
264
|
|
|
221
265
|
with database:
|
|
@@ -274,6 +318,13 @@ def backup_full(config, group, name):
|
|
|
274
318
|
logger.success(f"Backup completed successfully: {result['final_status']['state']}")
|
|
275
319
|
sys.exit(0)
|
|
276
320
|
else:
|
|
321
|
+
error_details = result.get('error_details')
|
|
322
|
+
if error_details and error_details.get('error_type') == 'snapshot_exists':
|
|
323
|
+
_handle_snapshot_exists_error(
|
|
324
|
+
error_details, label, config, cfg['repository'], 'full', group
|
|
325
|
+
)
|
|
326
|
+
sys.exit(1)
|
|
327
|
+
|
|
277
328
|
state = result.get('final_status', {}).get('state', 'UNKNOWN')
|
|
278
329
|
if state == "LOST":
|
|
279
330
|
logger.critical("Backup tracking lost!")
|
|
@@ -300,8 +351,10 @@ def backup_full(config, group, name):
|
|
|
300
351
|
@click.option('--config', required=True, help='Path to config YAML file')
|
|
301
352
|
@click.option('--target-label', required=True, help='Backup label to restore to')
|
|
302
353
|
@click.option('--group', help='Optional inventory group to filter tables to restore')
|
|
354
|
+
@click.option('--table', help='Optional table name to restore (table name only, database comes from config). Cannot be used with --group.')
|
|
303
355
|
@click.option('--rename-suffix', default='_restored', help='Suffix for temporary tables during restore (default: _restored)')
|
|
304
|
-
|
|
356
|
+
@click.option('--yes', is_flag=True, help='Skip confirmation prompt and proceed automatically')
|
|
357
|
+
def restore_command(config, target_label, group, table, rename_suffix, yes):
|
|
305
358
|
"""Restore data to a specific point in time using intelligent backup chain resolution.
|
|
306
359
|
|
|
307
360
|
This command automatically determines the correct sequence of backups needed for restore:
|
|
@@ -311,9 +364,23 @@ def restore_command(config, target_label, group, rename_suffix):
|
|
|
311
364
|
The restore process uses temporary tables with the specified suffix for safety, then performs
|
|
312
365
|
an atomic rename to make the restored data live.
|
|
313
366
|
|
|
314
|
-
Flow: load config → find restore pair → get tables from backup → execute restore flow
|
|
367
|
+
Flow: load config → check health → ensure repository → find restore pair → get tables from backup → execute restore flow
|
|
315
368
|
"""
|
|
316
369
|
try:
|
|
370
|
+
if group and table:
|
|
371
|
+
logger.error("Cannot specify both --group and --table. Use --table for single table restore or --group for inventory group restore.")
|
|
372
|
+
sys.exit(1)
|
|
373
|
+
|
|
374
|
+
if table:
|
|
375
|
+
table = table.strip()
|
|
376
|
+
if not table:
|
|
377
|
+
logger.error("Table name cannot be empty")
|
|
378
|
+
sys.exit(1)
|
|
379
|
+
|
|
380
|
+
if '.' in table:
|
|
381
|
+
logger.error("Table name must not include database prefix. Use 'table_name' not 'database.table_name'. Database comes from config file.")
|
|
382
|
+
sys.exit(1)
|
|
383
|
+
|
|
317
384
|
cfg = config_module.load_config(config)
|
|
318
385
|
config_module.validate_config(cfg)
|
|
319
386
|
|
|
@@ -322,7 +389,8 @@ def restore_command(config, target_label, group, rename_suffix):
|
|
|
322
389
|
port=cfg['port'],
|
|
323
390
|
user=cfg['user'],
|
|
324
391
|
password=os.getenv('STARROCKS_PASSWORD'),
|
|
325
|
-
database=cfg['database']
|
|
392
|
+
database=cfg['database'],
|
|
393
|
+
tls_config=cfg.get('tls'),
|
|
326
394
|
)
|
|
327
395
|
|
|
328
396
|
with database:
|
|
@@ -332,6 +400,17 @@ def restore_command(config, target_label, group, rename_suffix):
|
|
|
332
400
|
logger.warning("Remember to populate ops.table_inventory with your backup groups!")
|
|
333
401
|
sys.exit(1) # Exit if schema was just created, requires user action
|
|
334
402
|
|
|
403
|
+
healthy, message = health.check_cluster_health(database)
|
|
404
|
+
if not healthy:
|
|
405
|
+
logger.error(f"Cluster health check failed: {message}")
|
|
406
|
+
sys.exit(1)
|
|
407
|
+
|
|
408
|
+
logger.success(f"Cluster health: {message}")
|
|
409
|
+
|
|
410
|
+
repository.ensure_repository(database, cfg['repository'])
|
|
411
|
+
|
|
412
|
+
logger.success(f"Repository '{cfg['repository']}' verified")
|
|
413
|
+
|
|
335
414
|
logger.info(f"Finding restore sequence for target backup: {target_label}")
|
|
336
415
|
|
|
337
416
|
try:
|
|
@@ -342,11 +421,24 @@ def restore_command(config, target_label, group, rename_suffix):
|
|
|
342
421
|
sys.exit(1)
|
|
343
422
|
|
|
344
423
|
logger.info("Determining tables to restore from backup manifest...")
|
|
345
|
-
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
tables_to_restore = restore.get_tables_from_backup(
|
|
427
|
+
database,
|
|
428
|
+
target_label,
|
|
429
|
+
group=group,
|
|
430
|
+
table=table,
|
|
431
|
+
database=cfg['database'] if table else None
|
|
432
|
+
)
|
|
433
|
+
except ValueError as e:
|
|
434
|
+
logger.error(str(e))
|
|
435
|
+
sys.exit(1)
|
|
346
436
|
|
|
347
437
|
if not tables_to_restore:
|
|
348
438
|
if group:
|
|
349
439
|
logger.warning(f"No tables found in backup '{target_label}' for group '{group}'")
|
|
440
|
+
elif table:
|
|
441
|
+
logger.warning(f"No tables found in backup '{target_label}' for table '{table}'")
|
|
350
442
|
else:
|
|
351
443
|
logger.warning(f"No tables found in backup '{target_label}'")
|
|
352
444
|
sys.exit(1)
|
|
@@ -359,7 +451,8 @@ def restore_command(config, target_label, group, rename_suffix):
|
|
|
359
451
|
cfg['repository'],
|
|
360
452
|
restore_pair,
|
|
361
453
|
tables_to_restore,
|
|
362
|
-
rename_suffix
|
|
454
|
+
rename_suffix,
|
|
455
|
+
skip_confirmation=yes
|
|
363
456
|
)
|
|
364
457
|
|
|
365
458
|
if result['success']:
|
|
@@ -375,6 +468,9 @@ def restore_command(config, target_label, group, rename_suffix):
|
|
|
375
468
|
except ValueError as e:
|
|
376
469
|
logger.error(f"Configuration error: {e}")
|
|
377
470
|
sys.exit(1)
|
|
471
|
+
except RuntimeError as e:
|
|
472
|
+
logger.error(f"{e}")
|
|
473
|
+
sys.exit(1)
|
|
378
474
|
except Exception as e:
|
|
379
475
|
logger.error(f"Unexpected error: {e}")
|
|
380
476
|
sys.exit(1)
|
starrocks_br/config.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import yaml
|
|
2
|
-
from typing import Dict
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
3
|
|
|
4
4
|
|
|
5
|
-
def load_config(config_path: str) -> Dict:
|
|
5
|
+
def load_config(config_path: str) -> Dict[str, Any]:
|
|
6
6
|
"""Load and parse YAML configuration file.
|
|
7
7
|
|
|
8
8
|
Args:
|
|
@@ -24,7 +24,7 @@ def load_config(config_path: str) -> Dict:
|
|
|
24
24
|
return config
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
def validate_config(config: Dict) -> None:
|
|
27
|
+
def validate_config(config: Dict[str, Any]) -> None:
|
|
28
28
|
"""Validate that config contains required fields.
|
|
29
29
|
|
|
30
30
|
Args:
|
|
@@ -39,3 +39,26 @@ def validate_config(config: Dict) -> None:
|
|
|
39
39
|
if field not in config:
|
|
40
40
|
raise ValueError(f"Missing required config field: {field}")
|
|
41
41
|
|
|
42
|
+
_validate_tls_section(config.get('tls'))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _validate_tls_section(tls_config) -> None:
|
|
46
|
+
if tls_config is None:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if not isinstance(tls_config, dict):
|
|
50
|
+
raise ValueError("TLS configuration must be a dictionary")
|
|
51
|
+
|
|
52
|
+
enabled = bool(tls_config.get('enabled', False))
|
|
53
|
+
|
|
54
|
+
if enabled and not tls_config.get('ca_cert'):
|
|
55
|
+
raise ValueError("TLS configuration requires 'ca_cert' when 'enabled' is true")
|
|
56
|
+
|
|
57
|
+
if 'verify_server_cert' in tls_config and not isinstance(tls_config['verify_server_cert'], bool):
|
|
58
|
+
raise ValueError("TLS configuration field 'verify_server_cert' must be a boolean if provided")
|
|
59
|
+
|
|
60
|
+
if 'tls_versions' in tls_config:
|
|
61
|
+
tls_versions = tls_config['tls_versions']
|
|
62
|
+
if not isinstance(tls_versions, list) or not all(isinstance(version, str) for version in tls_versions):
|
|
63
|
+
raise ValueError("TLS configuration field 'tls_versions' must be a list of strings if provided")
|
|
64
|
+
|
starrocks_br/db.py
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import mysql.connector
|
|
2
|
-
from typing import List
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
class StarRocksDB:
|
|
6
6
|
"""Database connection wrapper for StarRocks."""
|
|
7
7
|
|
|
8
|
-
def __init__(
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
host: str,
|
|
11
|
+
port: int,
|
|
12
|
+
user: str,
|
|
13
|
+
password: str,
|
|
14
|
+
database: str,
|
|
15
|
+
tls_config: Optional[Dict[str, Any]] = None,
|
|
16
|
+
):
|
|
9
17
|
"""Initialize database connection.
|
|
10
18
|
|
|
11
19
|
Args:
|
|
@@ -21,16 +29,44 @@ class StarRocksDB:
|
|
|
21
29
|
self.password = password
|
|
22
30
|
self.database = database
|
|
23
31
|
self._connection = None
|
|
32
|
+
self.tls_config = tls_config or {}
|
|
33
|
+
self._timezone: Optional[str] = None
|
|
24
34
|
|
|
25
35
|
def connect(self) -> None:
|
|
26
36
|
"""Establish database connection."""
|
|
27
|
-
|
|
28
|
-
host
|
|
29
|
-
port
|
|
30
|
-
user
|
|
31
|
-
password
|
|
32
|
-
database
|
|
33
|
-
|
|
37
|
+
conn_args: Dict[str, Any] = {
|
|
38
|
+
'host': self.host,
|
|
39
|
+
'port': self.port,
|
|
40
|
+
'user': self.user,
|
|
41
|
+
'password': self.password,
|
|
42
|
+
'database': self.database,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if self.tls_config.get('enabled'):
|
|
46
|
+
ssl_args: Dict[str, Any] = {
|
|
47
|
+
'ssl_ca': self.tls_config.get('ca_cert'),
|
|
48
|
+
'ssl_cert': self.tls_config.get('client_cert'),
|
|
49
|
+
'ssl_key': self.tls_config.get('client_key'),
|
|
50
|
+
'ssl_verify_cert': self.tls_config.get('verify_server_cert', True),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
tls_versions = self.tls_config.get('tls_versions', ['TLSv1.2', 'TLSv1.3'])
|
|
54
|
+
if tls_versions:
|
|
55
|
+
ssl_args['tls_versions'] = tls_versions
|
|
56
|
+
|
|
57
|
+
conn_args.update({key: value for key, value in ssl_args.items() if value is not None})
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
self._connection = mysql.connector.connect(**conn_args)
|
|
61
|
+
except mysql.connector.Error as e:
|
|
62
|
+
if self.tls_config.get('enabled') and "SSL is required" in str(e):
|
|
63
|
+
raise mysql.connector.Error(
|
|
64
|
+
f"TLS is enabled in configuration but StarRocks server doesn't support it. "
|
|
65
|
+
f"Error: {e}. "
|
|
66
|
+
f"To fix this, you need to enable TLS/SSL in your StarRocks server configuration. "
|
|
67
|
+
f"Alternatively, set 'enabled: false' in the tls section of your config file."
|
|
68
|
+
) from e
|
|
69
|
+
raise
|
|
34
70
|
|
|
35
71
|
def close(self) -> None:
|
|
36
72
|
"""Close database connection."""
|
|
@@ -85,4 +121,34 @@ class StarRocksDB:
|
|
|
85
121
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
86
122
|
"""Context manager exit."""
|
|
87
123
|
self.close()
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def timezone(self) -> str:
|
|
127
|
+
"""Get the StarRocks cluster timezone.
|
|
128
|
+
|
|
129
|
+
Queries the cluster timezone on first access and caches it for subsequent use.
|
|
130
|
+
If the query fails (e.g., database unavailable, connection error, permissions),
|
|
131
|
+
defaults to 'UTC' to ensure the property always returns a valid timezone string.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Timezone string (e.g., 'Asia/Shanghai', 'UTC', '+08:00')
|
|
135
|
+
Defaults to 'UTC' if query fails or returns no results.
|
|
136
|
+
"""
|
|
137
|
+
if self._timezone is None:
|
|
138
|
+
try:
|
|
139
|
+
query = "SHOW VARIABLES LIKE 'time_zone'"
|
|
140
|
+
rows = self.query(query)
|
|
141
|
+
|
|
142
|
+
if not rows:
|
|
143
|
+
self._timezone = "UTC"
|
|
144
|
+
else:
|
|
145
|
+
row = rows[0]
|
|
146
|
+
if isinstance(row, dict):
|
|
147
|
+
self._timezone = row.get("Value", "UTC")
|
|
148
|
+
else:
|
|
149
|
+
self._timezone = row[1] if len(row) > 1 else "UTC"
|
|
150
|
+
except Exception:
|
|
151
|
+
self._timezone = "UTC"
|
|
152
|
+
|
|
153
|
+
return self._timezone
|
|
88
154
|
|
starrocks_br/executor.py
CHANGED
|
@@ -1,23 +1,68 @@
|
|
|
1
1
|
import time
|
|
2
2
|
import datetime
|
|
3
|
-
|
|
4
|
-
from
|
|
3
|
+
import re
|
|
4
|
+
from typing import Dict, Literal, Optional, Tuple
|
|
5
|
+
from . import history, concurrency, logger, timezone
|
|
5
6
|
|
|
6
|
-
MAX_POLLS =
|
|
7
|
+
MAX_POLLS = 86400 # 1 day
|
|
7
8
|
|
|
8
|
-
def submit_backup_command(db, backup_command: str) ->
|
|
9
|
+
def submit_backup_command(db, backup_command: str) -> Tuple[bool, Optional[str], Optional[Dict[str, str]]]:
|
|
9
10
|
"""Submit a backup command to StarRocks.
|
|
10
11
|
|
|
11
|
-
Returns (success, error_message).
|
|
12
|
+
Returns (success, error_message, error_details).
|
|
13
|
+
error_details is a dict with keys like 'error_type' and 'snapshot_name' for specific error cases.
|
|
12
14
|
"""
|
|
13
15
|
try:
|
|
14
16
|
db.execute(backup_command.strip())
|
|
15
|
-
return True, None
|
|
17
|
+
return True, None, None
|
|
16
18
|
except Exception as e:
|
|
17
|
-
|
|
19
|
+
error_str = str(e)
|
|
20
|
+
error_type = type(e).__name__
|
|
21
|
+
|
|
22
|
+
snapshot_exists_match = _check_snapshot_exists_error(e, error_str)
|
|
23
|
+
if snapshot_exists_match:
|
|
24
|
+
snapshot_name = snapshot_exists_match
|
|
25
|
+
error_details = {
|
|
26
|
+
'error_type': 'snapshot_exists',
|
|
27
|
+
'snapshot_name': snapshot_name
|
|
28
|
+
}
|
|
29
|
+
error_msg = f"Snapshot '{snapshot_name}' already exists in repository"
|
|
30
|
+
logger.error(error_msg)
|
|
31
|
+
logger.error(f"backup_command: {backup_command}")
|
|
32
|
+
return False, error_msg, error_details
|
|
33
|
+
|
|
34
|
+
error_msg = f"Failed to submit backup command: {error_type}: {error_str}"
|
|
18
35
|
logger.error(error_msg)
|
|
19
36
|
logger.error(f"backup_command: {backup_command}")
|
|
20
|
-
return False, error_msg
|
|
37
|
+
return False, error_msg, None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _check_snapshot_exists_error(exception: Exception, error_str: str) -> Optional[str]:
|
|
41
|
+
"""Check if the error is a 'snapshot already exists' error and extract snapshot name.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
exception: The exception that was raised
|
|
45
|
+
error_str: String representation of the error
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Snapshot name if this is a snapshot exists error, None otherwise
|
|
49
|
+
"""
|
|
50
|
+
snapshot_name_pattern = r"Snapshot with name '([^']+)' already exist"
|
|
51
|
+
error_lower = error_str.lower()
|
|
52
|
+
|
|
53
|
+
is_snapshot_exists_error = (
|
|
54
|
+
"already exist" in error_lower or
|
|
55
|
+
"already exists" in error_lower or
|
|
56
|
+
("5064" in error_str and "already exist" in error_lower) or
|
|
57
|
+
(hasattr(exception, 'errno') and exception.errno == 5064)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if is_snapshot_exists_error:
|
|
61
|
+
match = re.search(snapshot_name_pattern, error_str, re.IGNORECASE)
|
|
62
|
+
if match:
|
|
63
|
+
return match.group(1)
|
|
64
|
+
|
|
65
|
+
return None
|
|
21
66
|
|
|
22
67
|
|
|
23
68
|
def poll_backup_status(db, label: str, database: str, max_polls: int = MAX_POLLS, poll_interval: float = 1.0) -> Dict[str, str]:
|
|
@@ -117,15 +162,19 @@ def execute_backup(
|
|
|
117
162
|
if not database:
|
|
118
163
|
database = _extract_database_from_command(backup_command)
|
|
119
164
|
|
|
120
|
-
|
|
165
|
+
cluster_tz = db.timezone
|
|
166
|
+
started_at = timezone.get_current_time_in_cluster_tz(cluster_tz)
|
|
121
167
|
|
|
122
|
-
success, submit_error = submit_backup_command(db, backup_command)
|
|
168
|
+
success, submit_error, error_details = submit_backup_command(db, backup_command)
|
|
123
169
|
if not success:
|
|
124
|
-
|
|
170
|
+
result = {
|
|
125
171
|
"success": False,
|
|
126
172
|
"final_status": None,
|
|
127
173
|
"error_message": submit_error or "Failed to submit backup command (unknown error)"
|
|
128
174
|
}
|
|
175
|
+
if error_details:
|
|
176
|
+
result["error_details"] = error_details
|
|
177
|
+
return result
|
|
129
178
|
|
|
130
179
|
try:
|
|
131
180
|
final_status = poll_backup_status(db, label, database, max_polls, poll_interval)
|
|
@@ -133,6 +182,7 @@ def execute_backup(
|
|
|
133
182
|
success = final_status["state"] == "FINISHED"
|
|
134
183
|
|
|
135
184
|
try:
|
|
185
|
+
finished_at = timezone.get_current_time_in_cluster_tz(cluster_tz)
|
|
136
186
|
history.log_backup(
|
|
137
187
|
db,
|
|
138
188
|
{
|
|
@@ -141,7 +191,7 @@ def execute_backup(
|
|
|
141
191
|
"status": final_status["state"],
|
|
142
192
|
"repository": repository,
|
|
143
193
|
"started_at": started_at,
|
|
144
|
-
"finished_at":
|
|
194
|
+
"finished_at": finished_at,
|
|
145
195
|
"error_message": None if success else (final_status["state"] or ""),
|
|
146
196
|
},
|
|
147
197
|
)
|
starrocks_br/planner.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from typing import List, Dict, Optional
|
|
2
|
+
import datetime
|
|
3
|
+
import hashlib
|
|
2
4
|
|
|
3
|
-
from starrocks_br import logger
|
|
5
|
+
from starrocks_br import logger, timezone
|
|
4
6
|
|
|
5
7
|
|
|
6
8
|
def find_latest_full_backup(db, database: str) -> Optional[Dict[str, str]]:
|
|
@@ -11,7 +13,8 @@ def find_latest_full_backup(db, database: str) -> Optional[Dict[str, str]]:
|
|
|
11
13
|
database: Database name to search for
|
|
12
14
|
|
|
13
15
|
Returns:
|
|
14
|
-
Dictionary with keys: label, backup_type, finished_at, or None if no full backup found
|
|
16
|
+
Dictionary with keys: label, backup_type, finished_at, or None if no full backup found.
|
|
17
|
+
The finished_at value is returned as a string in the cluster timezone format.
|
|
15
18
|
"""
|
|
16
19
|
query = f"""
|
|
17
20
|
SELECT label, backup_type, finished_at
|
|
@@ -29,10 +32,18 @@ def find_latest_full_backup(db, database: str) -> Optional[Dict[str, str]]:
|
|
|
29
32
|
return None
|
|
30
33
|
|
|
31
34
|
row = rows[0]
|
|
35
|
+
finished_at = row[2]
|
|
36
|
+
|
|
37
|
+
if isinstance(finished_at, datetime.datetime):
|
|
38
|
+
cluster_tz = db.timezone
|
|
39
|
+
finished_at = finished_at.strftime("%Y-%m-%d %H:%M:%S")
|
|
40
|
+
elif not isinstance(finished_at, str):
|
|
41
|
+
finished_at = str(finished_at)
|
|
42
|
+
|
|
32
43
|
return {
|
|
33
44
|
"label": row[0],
|
|
34
45
|
"backup_type": row[1],
|
|
35
|
-
"finished_at":
|
|
46
|
+
"finished_at": finished_at
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
|
|
@@ -66,6 +77,8 @@ def find_recent_partitions(db, database: str, baseline_backup_label: Optional[st
|
|
|
66
77
|
Returns list of dictionaries with keys: database, table, partition_name.
|
|
67
78
|
Only partitions of tables within the specified database are returned.
|
|
68
79
|
"""
|
|
80
|
+
cluster_tz = db.timezone
|
|
81
|
+
|
|
69
82
|
if baseline_backup_label:
|
|
70
83
|
baseline_query = f"""
|
|
71
84
|
SELECT finished_at
|
|
@@ -76,17 +89,21 @@ def find_recent_partitions(db, database: str, baseline_backup_label: Optional[st
|
|
|
76
89
|
baseline_rows = db.query(baseline_query)
|
|
77
90
|
if not baseline_rows:
|
|
78
91
|
raise ValueError(f"Baseline backup '{baseline_backup_label}' not found or not successful")
|
|
79
|
-
|
|
92
|
+
baseline_time_raw = baseline_rows[0][0]
|
|
80
93
|
else:
|
|
81
94
|
latest_backup = find_latest_full_backup(db, database)
|
|
82
95
|
if not latest_backup:
|
|
83
96
|
raise ValueError(f"No successful full backup found for database '{database}'. Run a full database backup first.")
|
|
84
|
-
|
|
97
|
+
baseline_time_raw = latest_backup['finished_at']
|
|
85
98
|
|
|
86
|
-
if isinstance(
|
|
87
|
-
|
|
99
|
+
if isinstance(baseline_time_raw, datetime.datetime):
|
|
100
|
+
baseline_time_str = baseline_time_raw.strftime("%Y-%m-%d %H:%M:%S")
|
|
101
|
+
elif isinstance(baseline_time_raw, str):
|
|
102
|
+
baseline_time_str = baseline_time_raw
|
|
88
103
|
else:
|
|
89
|
-
|
|
104
|
+
baseline_time_str = str(baseline_time_raw)
|
|
105
|
+
|
|
106
|
+
baseline_dt = timezone.parse_datetime_with_tz(baseline_time_str, cluster_tz)
|
|
90
107
|
|
|
91
108
|
group_tables = find_tables_by_group(db, group_name)
|
|
92
109
|
|
|
@@ -129,12 +146,16 @@ def find_recent_partitions(db, database: str, baseline_backup_label: Optional[st
|
|
|
129
146
|
partition_name = row[1]
|
|
130
147
|
visible_version_time = row[3]
|
|
131
148
|
|
|
132
|
-
if isinstance(visible_version_time,
|
|
133
|
-
|
|
149
|
+
if isinstance(visible_version_time, datetime.datetime):
|
|
150
|
+
visible_version_time_str = visible_version_time.strftime("%Y-%m-%d %H:%M:%S")
|
|
151
|
+
elif isinstance(visible_version_time, str):
|
|
152
|
+
visible_version_time_str = visible_version_time
|
|
134
153
|
else:
|
|
135
|
-
|
|
154
|
+
visible_version_time_str = str(visible_version_time)
|
|
155
|
+
|
|
156
|
+
visible_version_dt = timezone.parse_datetime_with_tz(visible_version_time_str, cluster_tz)
|
|
136
157
|
|
|
137
|
-
if
|
|
158
|
+
if visible_version_dt > baseline_dt:
|
|
138
159
|
recent_partitions.append({
|
|
139
160
|
'database': db_name,
|
|
140
161
|
'table': table_name,
|
|
@@ -212,7 +233,7 @@ def build_full_backup_command(db, group_name: str, repository: str, label: str,
|
|
|
212
233
|
|
|
213
234
|
def record_backup_partitions(db, label: str, partitions: List[Dict[str, str]]) -> None:
|
|
214
235
|
"""Record partition metadata for a backup in ops.backup_partitions table.
|
|
215
|
-
|
|
236
|
+
|
|
216
237
|
Args:
|
|
217
238
|
db: Database connection
|
|
218
239
|
label: Backup label
|
|
@@ -220,12 +241,15 @@ def record_backup_partitions(db, label: str, partitions: List[Dict[str, str]]) -
|
|
|
220
241
|
"""
|
|
221
242
|
if not partitions:
|
|
222
243
|
return
|
|
223
|
-
|
|
244
|
+
|
|
224
245
|
for partition in partitions:
|
|
246
|
+
composite_key = f"{label}|{partition['database']}|{partition['table']}|{partition['partition_name']}"
|
|
247
|
+
key_hash = hashlib.md5(composite_key.encode('utf-8')).hexdigest()
|
|
248
|
+
|
|
225
249
|
db.execute(f"""
|
|
226
|
-
INSERT INTO ops.backup_partitions
|
|
227
|
-
(label, database_name, table_name, partition_name)
|
|
228
|
-
VALUES ('{label}', '{partition['database']}', '{partition['table']}', '{partition['partition_name']}')
|
|
250
|
+
INSERT INTO ops.backup_partitions
|
|
251
|
+
(key_hash, label, database_name, table_name, partition_name)
|
|
252
|
+
VALUES ('{key_hash}', '{label}', '{partition['database']}', '{partition['table']}', '{partition['partition_name']}')
|
|
229
253
|
""")
|
|
230
254
|
|
|
231
255
|
|