starrocks-br 0.5.0__tar.gz → 0.5.1__tar.gz
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-0.5.0 → starrocks_br-0.5.1}/PKG-INFO +1 -1
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/pyproject.toml +1 -1
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/cli.py +43 -20
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/concurrency.py +2 -9
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/config.py +11 -7
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/error_handler.py +42 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/exceptions.py +16 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/planner.py +3 -7
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br.egg-info/PKG-INFO +1 -1
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br.egg-info/SOURCES.txt +5 -1
- starrocks_br-0.5.1/tests/test_cli_backup.py +205 -0
- starrocks_br-0.5.1/tests/test_cli_exceptions.py +1113 -0
- starrocks_br-0.5.1/tests/test_cli_general.py +43 -0
- starrocks_br-0.5.1/tests/test_cli_init.py +16 -0
- starrocks_br-0.5.1/tests/test_cli_restore.py +156 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_concurrency.py +22 -12
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_config.py +8 -6
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_error_handler.py +13 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_planner.py +5 -3
- starrocks_br-0.5.0/tests/test_cli.py +0 -1351
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/README.md +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/setup.cfg +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/__init__.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/db.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/executor.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/health.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/history.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/labels.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/logger.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/repository.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/restore.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/schema.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/timezone.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br/utils.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br.egg-info/dependency_links.txt +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br.egg-info/entry_points.txt +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br.egg-info/requires.txt +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/src/starrocks_br.egg-info/top_level.txt +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_db.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_executor.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_health_checks.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_history.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_labels.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_logger.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_repository_sql.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_restore.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_schema_setup.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_timezone.py +0 -0
- {starrocks_br-0.5.0 → starrocks_br-0.5.1}/tests/test_utils.py +0 -0
|
@@ -125,11 +125,14 @@ def init(config):
|
|
|
125
125
|
" starrocks-br backup incremental --group my_daily_incremental --config config.yaml"
|
|
126
126
|
)
|
|
127
127
|
|
|
128
|
-
except
|
|
129
|
-
|
|
128
|
+
except exceptions.ConfigFileNotFoundError as e:
|
|
129
|
+
error_handler.handle_config_file_not_found_error(e)
|
|
130
130
|
sys.exit(1)
|
|
131
|
-
except
|
|
132
|
-
|
|
131
|
+
except exceptions.ConfigValidationError as e:
|
|
132
|
+
error_handler.handle_config_validation_error(e, config)
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
except FileNotFoundError as e:
|
|
135
|
+
error_handler.handle_config_file_not_found_error(exceptions.ConfigFileNotFoundError(str(e)))
|
|
133
136
|
sys.exit(1)
|
|
134
137
|
except Exception as e:
|
|
135
138
|
logger.error(f"Failed to initialize schema: {e}")
|
|
@@ -275,14 +278,26 @@ def backup_incremental(config, baseline_backup, group, name):
|
|
|
275
278
|
logger.error(f"{result['error_message']}")
|
|
276
279
|
sys.exit(1)
|
|
277
280
|
|
|
281
|
+
except exceptions.ConcurrencyConflictError as e:
|
|
282
|
+
error_handler.handle_concurrency_conflict_error(e, config)
|
|
283
|
+
sys.exit(1)
|
|
284
|
+
except exceptions.BackupLabelNotFoundError as e:
|
|
285
|
+
error_handler.handle_backup_label_not_found_error(e, config)
|
|
286
|
+
sys.exit(1)
|
|
287
|
+
except exceptions.NoFullBackupFoundError as e:
|
|
288
|
+
error_handler.handle_no_full_backup_found_error(e, config, group)
|
|
289
|
+
sys.exit(1)
|
|
290
|
+
except exceptions.ConfigFileNotFoundError as e:
|
|
291
|
+
error_handler.handle_config_file_not_found_error(e)
|
|
292
|
+
sys.exit(1)
|
|
293
|
+
except exceptions.ConfigValidationError as e:
|
|
294
|
+
error_handler.handle_config_validation_error(e, config)
|
|
295
|
+
sys.exit(1)
|
|
278
296
|
except FileNotFoundError as e:
|
|
279
|
-
|
|
297
|
+
error_handler.handle_config_file_not_found_error(exceptions.ConfigFileNotFoundError(str(e)))
|
|
280
298
|
sys.exit(1)
|
|
281
299
|
except ValueError as e:
|
|
282
|
-
logger.error(f"
|
|
283
|
-
sys.exit(1)
|
|
284
|
-
except RuntimeError as e:
|
|
285
|
-
logger.error(f"{e}")
|
|
300
|
+
logger.error(f"Error: {e}")
|
|
286
301
|
sys.exit(1)
|
|
287
302
|
except Exception as e:
|
|
288
303
|
logger.error(f"Unexpected error: {e}")
|
|
@@ -394,15 +409,23 @@ def backup_full(config, group, name):
|
|
|
394
409
|
logger.error(f"{result['error_message']}")
|
|
395
410
|
sys.exit(1)
|
|
396
411
|
|
|
397
|
-
except
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
412
|
+
except exceptions.ConcurrencyConflictError as e:
|
|
413
|
+
error_handler.handle_concurrency_conflict_error(e, config)
|
|
414
|
+
sys.exit(1)
|
|
415
|
+
except exceptions.ConfigFileNotFoundError as e:
|
|
416
|
+
error_handler.handle_config_file_not_found_error(e)
|
|
417
|
+
sys.exit(1)
|
|
418
|
+
except exceptions.ConfigValidationError as e:
|
|
419
|
+
error_handler.handle_config_validation_error(e, config)
|
|
420
|
+
sys.exit(1)
|
|
421
|
+
except FileNotFoundError as e:
|
|
422
|
+
error_handler.handle_config_file_not_found_error(exceptions.ConfigFileNotFoundError(str(e)))
|
|
423
|
+
sys.exit(1)
|
|
424
|
+
except ValueError as e:
|
|
425
|
+
logger.error(f"Error: {e}")
|
|
426
|
+
sys.exit(1)
|
|
427
|
+
except Exception as e:
|
|
428
|
+
logger.error(f"Unexpected error: {e}")
|
|
406
429
|
sys.exit(1)
|
|
407
430
|
|
|
408
431
|
|
|
@@ -559,8 +582,8 @@ def restore_command(config, target_label, group, table, rename_suffix, yes):
|
|
|
559
582
|
exceptions.ConfigValidationError(str(e)), config
|
|
560
583
|
)
|
|
561
584
|
sys.exit(1)
|
|
562
|
-
except
|
|
563
|
-
|
|
585
|
+
except exceptions.ConcurrencyConflictError as e:
|
|
586
|
+
error_handler.handle_concurrency_conflict_error(e, config)
|
|
564
587
|
sys.exit(1)
|
|
565
588
|
except Exception as e:
|
|
566
589
|
logger.error(f"Unexpected error: {e}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from typing import Literal
|
|
2
2
|
|
|
3
|
-
from . import logger, utils
|
|
3
|
+
from . import exceptions, logger, utils
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
def reserve_job_slot(db, scope: str, label: str) -> None:
|
|
@@ -46,14 +46,7 @@ def _can_heal_stale_job(scope: str, label: str, db) -> bool:
|
|
|
46
46
|
|
|
47
47
|
def _raise_concurrency_conflict(scope: str, active_jobs: list[tuple[str, str, str]]) -> None:
|
|
48
48
|
"""Raise a concurrency conflict error with helpful message."""
|
|
49
|
-
|
|
50
|
-
active_labels = [job[1] for job in active_jobs]
|
|
51
|
-
|
|
52
|
-
raise RuntimeError(
|
|
53
|
-
f"Concurrency conflict: Another '{scope}' job is already ACTIVE: {', '.join(active_job_strings)}. "
|
|
54
|
-
f"Wait for it to complete or cancel it via: UPDATE ops.run_status SET state='CANCELLED' "
|
|
55
|
-
f"WHERE label='{active_labels[0]}' AND state='ACTIVE'"
|
|
56
|
-
)
|
|
49
|
+
raise exceptions.ConcurrencyConflictError(scope, active_jobs)
|
|
57
50
|
|
|
58
51
|
|
|
59
52
|
def _insert_new_job(db, scope: str, label: str) -> None:
|
|
@@ -2,6 +2,8 @@ from typing import Any
|
|
|
2
2
|
|
|
3
3
|
import yaml
|
|
4
4
|
|
|
5
|
+
from . import exceptions
|
|
6
|
+
|
|
5
7
|
|
|
6
8
|
def load_config(config_path: str) -> dict[str, Any]:
|
|
7
9
|
"""Load and parse YAML configuration file.
|
|
@@ -20,7 +22,7 @@ def load_config(config_path: str) -> dict[str, Any]:
|
|
|
20
22
|
config = yaml.safe_load(f)
|
|
21
23
|
|
|
22
24
|
if not isinstance(config, dict):
|
|
23
|
-
raise
|
|
25
|
+
raise exceptions.ConfigValidationError("Config must be a dictionary")
|
|
24
26
|
|
|
25
27
|
return config
|
|
26
28
|
|
|
@@ -32,13 +34,13 @@ def validate_config(config: dict[str, Any]) -> None:
|
|
|
32
34
|
config: Configuration dictionary
|
|
33
35
|
|
|
34
36
|
Raises:
|
|
35
|
-
|
|
37
|
+
ConfigValidationError: If required fields are missing
|
|
36
38
|
"""
|
|
37
39
|
required_fields = ["host", "port", "user", "database", "repository"]
|
|
38
40
|
|
|
39
41
|
for field in required_fields:
|
|
40
42
|
if field not in config:
|
|
41
|
-
raise
|
|
43
|
+
raise exceptions.ConfigValidationError(f"Missing required config field: {field}")
|
|
42
44
|
|
|
43
45
|
_validate_tls_section(config.get("tls"))
|
|
44
46
|
|
|
@@ -48,17 +50,19 @@ def _validate_tls_section(tls_config) -> None:
|
|
|
48
50
|
return
|
|
49
51
|
|
|
50
52
|
if not isinstance(tls_config, dict):
|
|
51
|
-
raise
|
|
53
|
+
raise exceptions.ConfigValidationError("TLS configuration must be a dictionary")
|
|
52
54
|
|
|
53
55
|
enabled = bool(tls_config.get("enabled", False))
|
|
54
56
|
|
|
55
57
|
if enabled and not tls_config.get("ca_cert"):
|
|
56
|
-
raise
|
|
58
|
+
raise exceptions.ConfigValidationError(
|
|
59
|
+
"TLS configuration requires 'ca_cert' when 'enabled' is true"
|
|
60
|
+
)
|
|
57
61
|
|
|
58
62
|
if "verify_server_cert" in tls_config and not isinstance(
|
|
59
63
|
tls_config["verify_server_cert"], bool
|
|
60
64
|
):
|
|
61
|
-
raise
|
|
65
|
+
raise exceptions.ConfigValidationError(
|
|
62
66
|
"TLS configuration field 'verify_server_cert' must be a boolean if provided"
|
|
63
67
|
)
|
|
64
68
|
|
|
@@ -67,6 +71,6 @@ def _validate_tls_section(tls_config) -> None:
|
|
|
67
71
|
if not isinstance(tls_versions, list) or not all(
|
|
68
72
|
isinstance(version, str) for version in tls_versions
|
|
69
73
|
):
|
|
70
|
-
raise
|
|
74
|
+
raise exceptions.ConfigValidationError(
|
|
71
75
|
"TLS configuration field 'tls_versions' must be a list of strings if provided"
|
|
72
76
|
)
|
|
@@ -263,3 +263,45 @@ def handle_restore_operation_cancelled_error() -> None:
|
|
|
263
263
|
],
|
|
264
264
|
help_links=["starrocks-br restore --help"],
|
|
265
265
|
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def handle_concurrency_conflict_error(
|
|
269
|
+
exc: exceptions.ConcurrencyConflictError, config: str = None
|
|
270
|
+
) -> None:
|
|
271
|
+
active_job_strings = [f"{job[0]}:{job[1]}" for job in exc.active_jobs]
|
|
272
|
+
first_label = exc.active_labels[0] if exc.active_labels else "unknown"
|
|
273
|
+
|
|
274
|
+
display_structured_error(
|
|
275
|
+
title="CONCURRENCY CONFLICT",
|
|
276
|
+
reason=f"Another '{exc.scope}' job is already running.\nOnly one job of the same type can run at a time to prevent conflicts.",
|
|
277
|
+
what_to_do=[
|
|
278
|
+
f"Wait for the active job to complete: {', '.join(active_job_strings)}",
|
|
279
|
+
f"Check the job status in ops.run_status:\n SELECT * FROM ops.run_status WHERE label = '{first_label}' AND state = 'ACTIVE';",
|
|
280
|
+
f"If the job is stuck, cancel it manually:\n UPDATE ops.run_status SET state = 'CANCELLED' WHERE label = '{first_label}' AND state = 'ACTIVE';",
|
|
281
|
+
"Verify the job is not actually running in StarRocks before cancelling it",
|
|
282
|
+
],
|
|
283
|
+
inputs={
|
|
284
|
+
"--config": config,
|
|
285
|
+
"Scope": exc.scope,
|
|
286
|
+
"Active jobs": ", ".join(active_job_strings),
|
|
287
|
+
},
|
|
288
|
+
help_links=["Check ops.run_status table for job status"],
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def handle_no_full_backup_found_error(
|
|
293
|
+
exc: exceptions.NoFullBackupFoundError, config: str = None, group: str = None
|
|
294
|
+
) -> None:
|
|
295
|
+
display_structured_error(
|
|
296
|
+
title="NO FULL BACKUP FOUND",
|
|
297
|
+
reason=f"No successful full backup was found for database '{exc.database}'.\nIncremental backups require a baseline full backup to compare against.",
|
|
298
|
+
what_to_do=[
|
|
299
|
+
"Run a full backup first:\n starrocks-br backup full --config "
|
|
300
|
+
+ (config if config else "<config.yaml>")
|
|
301
|
+
+ f" --group {group if group else '<group_name>'}",
|
|
302
|
+
f"Verify no full backups exist for this database:\n SELECT label, backup_type, status, finished_at FROM ops.backup_history WHERE backup_type = 'full' AND label LIKE '{exc.database}_%' ORDER BY finished_at DESC;",
|
|
303
|
+
"After the full backup completes successfully, retry the incremental backup",
|
|
304
|
+
],
|
|
305
|
+
inputs={"Database": exc.database, "--config": config, "--group": group},
|
|
306
|
+
help_links=["starrocks-br backup full --help"],
|
|
307
|
+
)
|
|
@@ -91,3 +91,19 @@ class NoTablesFoundError(StarRocksBRError):
|
|
|
91
91
|
class RestoreOperationCancelledError(StarRocksBRError):
|
|
92
92
|
def __init__(self):
|
|
93
93
|
super().__init__("Restore operation cancelled by user")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ConcurrencyConflictError(StarRocksBRError):
|
|
97
|
+
def __init__(self, scope: str, active_jobs: list[tuple[str, str, str]]):
|
|
98
|
+
self.scope = scope
|
|
99
|
+
self.active_jobs = active_jobs
|
|
100
|
+
self.active_labels = [job[1] for job in active_jobs]
|
|
101
|
+
super().__init__(
|
|
102
|
+
f"Concurrency conflict: Another '{scope}' job is already active: {', '.join(f'{job[0]}:{job[1]}' for job in active_jobs)}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class NoFullBackupFoundError(StarRocksBRError):
|
|
107
|
+
def __init__(self, database: str):
|
|
108
|
+
self.database = database
|
|
109
|
+
super().__init__(f"No successful full backup found for database '{database}'")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import datetime
|
|
2
2
|
import hashlib
|
|
3
3
|
|
|
4
|
-
from starrocks_br import logger, timezone, utils
|
|
4
|
+
from starrocks_br import exceptions, logger, timezone, utils
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def find_latest_full_backup(db, database: str) -> dict[str, str] | None:
|
|
@@ -83,16 +83,12 @@ def find_recent_partitions(
|
|
|
83
83
|
"""
|
|
84
84
|
baseline_rows = db.query(baseline_query)
|
|
85
85
|
if not baseline_rows:
|
|
86
|
-
raise
|
|
87
|
-
f"Baseline backup '{baseline_backup_label}' not found or not successful"
|
|
88
|
-
)
|
|
86
|
+
raise exceptions.BackupLabelNotFoundError(baseline_backup_label)
|
|
89
87
|
baseline_time_raw = baseline_rows[0][0]
|
|
90
88
|
else:
|
|
91
89
|
latest_backup = find_latest_full_backup(db, database)
|
|
92
90
|
if not latest_backup:
|
|
93
|
-
raise
|
|
94
|
-
f"No successful full backup found for database '{database}'. Run a full database backup first."
|
|
95
|
-
)
|
|
91
|
+
raise exceptions.NoFullBackupFoundError(database)
|
|
96
92
|
baseline_time_raw = latest_backup["finished_at"]
|
|
97
93
|
|
|
98
94
|
if isinstance(baseline_time_raw, datetime.datetime):
|
|
@@ -24,7 +24,11 @@ src/starrocks_br.egg-info/dependency_links.txt
|
|
|
24
24
|
src/starrocks_br.egg-info/entry_points.txt
|
|
25
25
|
src/starrocks_br.egg-info/requires.txt
|
|
26
26
|
src/starrocks_br.egg-info/top_level.txt
|
|
27
|
-
tests/
|
|
27
|
+
tests/test_cli_backup.py
|
|
28
|
+
tests/test_cli_exceptions.py
|
|
29
|
+
tests/test_cli_general.py
|
|
30
|
+
tests/test_cli_init.py
|
|
31
|
+
tests/test_cli_restore.py
|
|
28
32
|
tests/test_concurrency.py
|
|
29
33
|
tests/test_config.py
|
|
30
34
|
tests/test_db.py
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from click.testing import CliRunner
|
|
2
|
+
|
|
3
|
+
from starrocks_br import cli
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_backup_incremental_success(
|
|
7
|
+
config_file,
|
|
8
|
+
mock_db,
|
|
9
|
+
mock_initialized_schema,
|
|
10
|
+
mock_healthy_cluster,
|
|
11
|
+
mock_repo_exists,
|
|
12
|
+
setup_password_env,
|
|
13
|
+
mocker,
|
|
14
|
+
):
|
|
15
|
+
"""Test successful incremental backup with default baseline (latest full backup)."""
|
|
16
|
+
runner = CliRunner()
|
|
17
|
+
|
|
18
|
+
mocker.patch(
|
|
19
|
+
"starrocks_br.planner.find_latest_full_backup",
|
|
20
|
+
return_value={
|
|
21
|
+
"label": "test_db_20251015_full",
|
|
22
|
+
"backup_type": "full",
|
|
23
|
+
"finished_at": "2025-10-15 10:00:00",
|
|
24
|
+
},
|
|
25
|
+
)
|
|
26
|
+
mocker.patch(
|
|
27
|
+
"starrocks_br.planner.find_recent_partitions",
|
|
28
|
+
return_value=[
|
|
29
|
+
{"database": "test_db", "table": "fact_table", "partition_name": "p20251016"}
|
|
30
|
+
],
|
|
31
|
+
)
|
|
32
|
+
mocker.patch("starrocks_br.labels.determine_backup_label", return_value="test_db_20251016_inc")
|
|
33
|
+
mocker.patch(
|
|
34
|
+
"starrocks_br.planner.build_incremental_backup_command",
|
|
35
|
+
return_value="BACKUP DATABASE test_db SNAPSHOT test_db_20251016_inc TO test_repo",
|
|
36
|
+
)
|
|
37
|
+
mocker.patch("starrocks_br.concurrency.reserve_job_slot")
|
|
38
|
+
mocker.patch("starrocks_br.planner.record_backup_partitions")
|
|
39
|
+
mocker.patch(
|
|
40
|
+
"starrocks_br.executor.execute_backup",
|
|
41
|
+
return_value={
|
|
42
|
+
"success": True,
|
|
43
|
+
"final_status": {"state": "FINISHED"},
|
|
44
|
+
"error_message": None,
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
result = runner.invoke(
|
|
49
|
+
cli.backup_incremental, ["--config", config_file, "--group", "daily_incremental"]
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
assert result.exit_code == 0
|
|
53
|
+
assert "Backup completed successfully" in result.output
|
|
54
|
+
assert "Using latest full backup as baseline: test_db_20251015_full (full)" in result.output
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_backup_incremental_with_specific_baseline(
|
|
58
|
+
config_file,
|
|
59
|
+
mock_db,
|
|
60
|
+
mock_initialized_schema,
|
|
61
|
+
mock_healthy_cluster,
|
|
62
|
+
mock_repo_exists,
|
|
63
|
+
setup_password_env,
|
|
64
|
+
mocker,
|
|
65
|
+
):
|
|
66
|
+
"""Test incremental backup with user-specified baseline."""
|
|
67
|
+
runner = CliRunner()
|
|
68
|
+
|
|
69
|
+
mocker.patch(
|
|
70
|
+
"starrocks_br.planner.find_recent_partitions",
|
|
71
|
+
return_value=[
|
|
72
|
+
{"database": "test_db", "table": "fact_table", "partition_name": "p20251016"}
|
|
73
|
+
],
|
|
74
|
+
)
|
|
75
|
+
mocker.patch("starrocks_br.labels.determine_backup_label", return_value="test_db_20251016_inc")
|
|
76
|
+
mocker.patch(
|
|
77
|
+
"starrocks_br.planner.build_incremental_backup_command",
|
|
78
|
+
return_value="BACKUP DATABASE test_db SNAPSHOT test_db_20251016_inc TO test_repo",
|
|
79
|
+
)
|
|
80
|
+
mocker.patch("starrocks_br.concurrency.reserve_job_slot")
|
|
81
|
+
mocker.patch("starrocks_br.planner.record_backup_partitions")
|
|
82
|
+
mocker.patch(
|
|
83
|
+
"starrocks_br.executor.execute_backup",
|
|
84
|
+
return_value={
|
|
85
|
+
"success": True,
|
|
86
|
+
"final_status": {"state": "FINISHED"},
|
|
87
|
+
"error_message": None,
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
result = runner.invoke(
|
|
92
|
+
cli.backup_incremental,
|
|
93
|
+
[
|
|
94
|
+
"--config",
|
|
95
|
+
config_file,
|
|
96
|
+
"--baseline-backup",
|
|
97
|
+
"test_db_20251010_full",
|
|
98
|
+
"--group",
|
|
99
|
+
"daily_incremental",
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
assert result.exit_code == 0
|
|
104
|
+
assert "Backup completed successfully" in result.output
|
|
105
|
+
assert "Using specified baseline backup: test_db_20251010_full" in result.output
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_backup_full_success(
|
|
109
|
+
config_file,
|
|
110
|
+
mock_db,
|
|
111
|
+
mock_initialized_schema,
|
|
112
|
+
mock_healthy_cluster,
|
|
113
|
+
mock_repo_exists,
|
|
114
|
+
setup_password_env,
|
|
115
|
+
mocker,
|
|
116
|
+
):
|
|
117
|
+
"""Test successful full backup."""
|
|
118
|
+
runner = CliRunner()
|
|
119
|
+
|
|
120
|
+
mocker.patch(
|
|
121
|
+
"starrocks_br.planner.build_full_backup_command",
|
|
122
|
+
return_value="BACKUP DATABASE test_db SNAPSHOT test_db_20251016_full TO test_repo",
|
|
123
|
+
)
|
|
124
|
+
mocker.patch(
|
|
125
|
+
"starrocks_br.planner.find_tables_by_group",
|
|
126
|
+
return_value=[{"database": "test_db", "table": "dim_customers"}],
|
|
127
|
+
)
|
|
128
|
+
mocker.patch("starrocks_br.planner.get_all_partitions_for_tables", return_value=[])
|
|
129
|
+
mocker.patch("starrocks_br.labels.determine_backup_label", return_value="test_db_20251016_full")
|
|
130
|
+
mocker.patch("starrocks_br.concurrency.reserve_job_slot")
|
|
131
|
+
mocker.patch("starrocks_br.planner.record_backup_partitions")
|
|
132
|
+
mocker.patch(
|
|
133
|
+
"starrocks_br.executor.execute_backup",
|
|
134
|
+
return_value={
|
|
135
|
+
"success": True,
|
|
136
|
+
"final_status": {"state": "FINISHED"},
|
|
137
|
+
"error_message": None,
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
result = runner.invoke(
|
|
142
|
+
cli.backup_full, ["--config", config_file, "--group", "weekly_dimensions"]
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
assert result.exit_code == 0
|
|
146
|
+
assert "Backup completed successfully" in result.output
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_backup_reserves_slot_before_recording_partitions(
|
|
150
|
+
config_file,
|
|
151
|
+
mock_db,
|
|
152
|
+
mock_initialized_schema,
|
|
153
|
+
mock_healthy_cluster,
|
|
154
|
+
mock_repo_exists,
|
|
155
|
+
setup_password_env,
|
|
156
|
+
mocker,
|
|
157
|
+
):
|
|
158
|
+
"""Test that backup reserves job slot before recording partitions (correct order)."""
|
|
159
|
+
runner = CliRunner()
|
|
160
|
+
call_order = []
|
|
161
|
+
|
|
162
|
+
def mock_reserve_job_slot(*args, **kwargs):
|
|
163
|
+
call_order.append("reserve_job_slot")
|
|
164
|
+
|
|
165
|
+
def mock_record_backup_partitions(*args, **kwargs):
|
|
166
|
+
call_order.append("record_backup_partitions")
|
|
167
|
+
|
|
168
|
+
mocker.patch("starrocks_br.labels.determine_backup_label", return_value="test_backup")
|
|
169
|
+
mocker.patch(
|
|
170
|
+
"starrocks_br.planner.find_latest_full_backup",
|
|
171
|
+
return_value={
|
|
172
|
+
"label": "test_db_20251015_full",
|
|
173
|
+
"backup_type": "full",
|
|
174
|
+
"finished_at": "2025-10-15 10:00:00",
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
mocker.patch(
|
|
178
|
+
"starrocks_br.planner.find_recent_partitions",
|
|
179
|
+
return_value=[
|
|
180
|
+
{"database": "test_db", "table": "fact_table", "partition_name": "p20251016"}
|
|
181
|
+
],
|
|
182
|
+
)
|
|
183
|
+
mocker.patch(
|
|
184
|
+
"starrocks_br.planner.build_incremental_backup_command",
|
|
185
|
+
return_value="BACKUP DATABASE test_db SNAPSHOT test_backup TO test_repo",
|
|
186
|
+
)
|
|
187
|
+
mocker.patch("starrocks_br.concurrency.reserve_job_slot", side_effect=mock_reserve_job_slot)
|
|
188
|
+
mocker.patch(
|
|
189
|
+
"starrocks_br.planner.record_backup_partitions", side_effect=mock_record_backup_partitions
|
|
190
|
+
)
|
|
191
|
+
mocker.patch(
|
|
192
|
+
"starrocks_br.executor.execute_backup",
|
|
193
|
+
return_value={
|
|
194
|
+
"success": True,
|
|
195
|
+
"final_status": {"state": "FINISHED"},
|
|
196
|
+
"error_message": None,
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
result = runner.invoke(cli.backup_incremental, ["--config", config_file, "--group", "daily"])
|
|
201
|
+
|
|
202
|
+
assert result.exit_code == 0
|
|
203
|
+
assert len(call_order) == 2
|
|
204
|
+
assert call_order[0] == "reserve_job_slot"
|
|
205
|
+
assert call_order[1] == "record_backup_partitions"
|