starrocks-br 0.5.2__tar.gz → 0.6.0__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.
Files changed (52) hide show
  1. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/PKG-INFO +15 -2
  2. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/README.md +14 -1
  3. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/pyproject.toml +1 -1
  4. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/cli.py +103 -36
  5. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/concurrency.py +33 -23
  6. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/config.py +75 -0
  7. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/error_handler.py +59 -12
  8. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/exceptions.py +14 -0
  9. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/executor.py +9 -2
  10. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/history.py +9 -9
  11. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/labels.py +5 -3
  12. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/planner.py +56 -13
  13. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/repository.py +1 -1
  14. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/restore.py +197 -40
  15. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/schema.py +89 -43
  16. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br.egg-info/PKG-INFO +15 -2
  17. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_cli_backup.py +1 -0
  18. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_cli_exceptions.py +8 -3
  19. starrocks_br-0.6.0/tests/test_cli_init.py +80 -0
  20. starrocks_br-0.6.0/tests/test_config.py +405 -0
  21. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_error_handler.py +162 -0
  22. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_executor.py +1 -1
  23. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_planner.py +107 -0
  24. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_restore.py +223 -1
  25. starrocks_br-0.6.0/tests/test_schema_setup.py +498 -0
  26. starrocks_br-0.5.2/tests/test_cli_init.py +0 -30
  27. starrocks_br-0.5.2/tests/test_config.py +0 -190
  28. starrocks_br-0.5.2/tests/test_schema_setup.py +0 -234
  29. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/LICENSE +0 -0
  30. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/setup.cfg +0 -0
  31. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/__init__.py +0 -0
  32. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/db.py +0 -0
  33. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/health.py +0 -0
  34. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/logger.py +0 -0
  35. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/timezone.py +0 -0
  36. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br/utils.py +0 -0
  37. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br.egg-info/SOURCES.txt +0 -0
  38. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br.egg-info/dependency_links.txt +0 -0
  39. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br.egg-info/entry_points.txt +0 -0
  40. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br.egg-info/requires.txt +0 -0
  41. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/src/starrocks_br.egg-info/top_level.txt +0 -0
  42. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_cli_general.py +0 -0
  43. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_cli_restore.py +0 -0
  44. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_concurrency.py +0 -0
  45. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_db.py +0 -0
  46. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_health_checks.py +0 -0
  47. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_history.py +0 -0
  48. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_labels.py +0 -0
  49. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_logger.py +0 -0
  50. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_repository_sql.py +0 -0
  51. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_timezone.py +0 -0
  52. {starrocks_br-0.5.2 → starrocks_br-0.6.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: starrocks-br
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: StarRocks Backup and Restore automation tool
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -95,6 +95,15 @@ port: 9030 # MySQL protocol port
95
95
  user: "root" # Database user with backup/restore privileges
96
96
  database: "your_database" # Database containing tables to backup
97
97
  repository: "your_repo_name" # Repository created via CREATE REPOSITORY in StarRocks
98
+
99
+ # Optional: Define table inventory groups directly in config
100
+ table_inventory:
101
+ - group: "production"
102
+ tables:
103
+ - database: "mydb"
104
+ table: "users"
105
+ - database: "mydb"
106
+ table: "orders"
98
107
  ```
99
108
 
100
109
  Set password:
@@ -111,7 +120,11 @@ See [Configuration Reference](docs/configuration.md) for TLS and advanced option
111
120
  starrocks-br init --config config.yaml
112
121
  ```
113
122
 
114
- **Define inventory groups** (in StarRocks):
123
+ This creates the `ops` database and automatically populates table inventory from your config (if defined).
124
+
125
+ **Note:** If you modify the `table_inventory` in your config file, rerun `starrocks-br init --config config.yaml` to update the database.
126
+
127
+ **Alternative: Define inventory groups manually** (in StarRocks):
115
128
  ```sql
116
129
  INSERT INTO ops.table_inventory (inventory_group, database_name, table_name)
117
130
  VALUES
@@ -77,6 +77,15 @@ port: 9030 # MySQL protocol port
77
77
  user: "root" # Database user with backup/restore privileges
78
78
  database: "your_database" # Database containing tables to backup
79
79
  repository: "your_repo_name" # Repository created via CREATE REPOSITORY in StarRocks
80
+
81
+ # Optional: Define table inventory groups directly in config
82
+ table_inventory:
83
+ - group: "production"
84
+ tables:
85
+ - database: "mydb"
86
+ table: "users"
87
+ - database: "mydb"
88
+ table: "orders"
80
89
  ```
81
90
 
82
91
  Set password:
@@ -93,7 +102,11 @@ See [Configuration Reference](docs/configuration.md) for TLS and advanced option
93
102
  starrocks-br init --config config.yaml
94
103
  ```
95
104
 
96
- **Define inventory groups** (in StarRocks):
105
+ This creates the `ops` database and automatically populates table inventory from your config (if defined).
106
+
107
+ **Note:** If you modify the `table_inventory` in your config file, rerun `starrocks-br init --config config.yaml` to update the database.
108
+
109
+ **Alternative: Define inventory groups manually** (in StarRocks):
97
110
  ```sql
98
111
  INSERT INTO ops.table_inventory (inventory_group, database_name, table_name)
99
112
  VALUES
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "starrocks-br"
7
- version = "0.5.2"
7
+ version = "0.6.0"
8
8
  description = "StarRocks Backup and Restore automation tool"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -98,13 +98,13 @@ def cli(ctx, verbose):
98
98
  @cli.command("init")
99
99
  @click.option("--config", required=True, help="Path to config YAML file")
100
100
  def init(config):
101
- """Initialize ops database and control tables.
101
+ """Initialize operations database and control tables.
102
102
 
103
- Creates the ops database with required tables:
104
- - ops.table_inventory: Inventory groups mapping to databases/tables
105
- - ops.backup_history: Backup operation history
106
- - ops.restore_history: Restore operation history
107
- - ops.run_status: Job concurrency control
103
+ Creates the operations database (default: 'ops') with required tables:
104
+ - table_inventory: Inventory groups mapping to databases/tables
105
+ - backup_history: Backup operation history
106
+ - restore_history: Restore operation history
107
+ - run_status: Job concurrency control
108
108
 
109
109
  Run this once before using backup/restore commands.
110
110
  """
@@ -112,6 +112,9 @@ def init(config):
112
112
  cfg = config_module.load_config(config)
113
113
  config_module.validate_config(cfg)
114
114
 
115
+ ops_database = config_module.get_ops_database(cfg)
116
+ table_inventory_entries = config_module.get_table_inventory_entries(cfg)
117
+
115
118
  database = db.StarRocksDB(
116
119
  host=cfg["host"],
117
120
  port=cfg["port"],
@@ -121,23 +124,43 @@ def init(config):
121
124
  tls_config=cfg.get("tls"),
122
125
  )
123
126
 
127
+ ops_database = config_module.get_ops_database(cfg)
128
+
124
129
  with database:
125
- logger.info("Initializing ops schema...")
126
- schema.initialize_ops_schema(database)
127
- logger.info("")
128
- logger.info("Next steps:")
129
- logger.info("1. Insert your table inventory records:")
130
- logger.info(" INSERT INTO ops.table_inventory")
131
- logger.info(" (inventory_group, database_name, table_name)")
132
- logger.info(" VALUES ('my_daily_incremental', 'your_db', 'your_fact_table');")
133
- logger.info(" VALUES ('my_full_database_backup', 'your_db', '*');")
134
- logger.info(" VALUES ('my_full_dimension_tables', 'your_db', 'dim_customers');")
135
- logger.info(" VALUES ('my_full_dimension_tables', 'your_db', 'dim_products');")
130
+ logger.info("Validating repository...")
131
+ repository.ensure_repository(database, cfg["repository"])
136
132
  logger.info("")
137
- logger.info("2. Run your first backup:")
138
- logger.info(
139
- " starrocks-br backup incremental --group my_daily_incremental --config config.yaml"
133
+
134
+ logger.info("Initializing ops schema...")
135
+ schema.initialize_ops_schema(
136
+ database, ops_database=ops_database, table_inventory_entries=table_inventory_entries
140
137
  )
138
+ logger.info("")
139
+
140
+ if table_inventory_entries:
141
+ logger.success(
142
+ f"Table inventory bootstrapped from config with {len(table_inventory_entries)} entries"
143
+ )
144
+ logger.info("")
145
+ logger.info("Next steps:")
146
+ logger.info("1. Run your first backup:")
147
+ logger.info(
148
+ f" starrocks-br backup incremental --group <your_group_name> --config {config}"
149
+ )
150
+ else:
151
+ logger.info("Next steps:")
152
+ logger.info("1. Insert your table inventory records:")
153
+ logger.info(f" INSERT INTO {ops_database}.table_inventory")
154
+ logger.info(" (inventory_group, database_name, table_name)")
155
+ logger.info(" VALUES ('my_daily_incremental', 'your_db', 'your_fact_table');")
156
+ logger.info(" VALUES ('my_full_database_backup', 'your_db', '*');")
157
+ logger.info(" VALUES ('my_full_dimension_tables', 'your_db', 'dim_customers');")
158
+ logger.info(" VALUES ('my_full_dimension_tables', 'your_db', 'dim_products');")
159
+ logger.info("")
160
+ logger.info("2. Run your first backup:")
161
+ logger.info(
162
+ " starrocks-br backup incremental --group my_daily_incremental --config config.yaml"
163
+ )
141
164
 
142
165
  except exceptions.ConfigFileNotFoundError as e:
143
166
  error_handler.handle_config_file_not_found_error(e)
@@ -196,13 +219,17 @@ def backup_incremental(config, baseline_backup, group, name):
196
219
  tls_config=cfg.get("tls"),
197
220
  )
198
221
 
222
+ ops_database = config_module.get_ops_database(cfg)
223
+
199
224
  with database:
200
- was_created = schema.ensure_ops_schema(database)
225
+ was_created = schema.ensure_ops_schema(database, ops_database=ops_database)
201
226
  if was_created:
202
227
  logger.warning(
203
228
  "ops schema was auto-created. Please run 'starrocks-br init' after populating config."
204
229
  )
205
- logger.warning("Remember to populate ops.table_inventory with your backup groups!")
230
+ logger.warning(
231
+ "Remember to populate the table_inventory table with your backup groups!"
232
+ )
206
233
  sys.exit(1) # Exit if schema was just created, requires user action
207
234
 
208
235
  healthy, message = health.check_cluster_health(database)
@@ -221,6 +248,7 @@ def backup_incremental(config, baseline_backup, group, name):
221
248
  backup_type="incremental",
222
249
  database_name=cfg["database"],
223
250
  custom_name=name,
251
+ ops_database=ops_database,
224
252
  )
225
253
 
226
254
  logger.success(f"Generated label: {label}")
@@ -239,7 +267,11 @@ def backup_incremental(config, baseline_backup, group, name):
239
267
  )
240
268
 
241
269
  partitions = planner.find_recent_partitions(
242
- database, cfg["database"], baseline_backup_label=baseline_backup, group_name=group
270
+ database,
271
+ cfg["database"],
272
+ baseline_backup_label=baseline_backup,
273
+ group_name=group,
274
+ ops_database=ops_database,
243
275
  )
244
276
 
245
277
  if not partitions:
@@ -252,9 +284,11 @@ def backup_incremental(config, baseline_backup, group, name):
252
284
  partitions, cfg["repository"], label, cfg["database"]
253
285
  )
254
286
 
255
- concurrency.reserve_job_slot(database, scope="backup", label=label)
287
+ concurrency.reserve_job_slot(
288
+ database, scope="backup", label=label, ops_database=ops_database
289
+ )
256
290
 
257
- planner.record_backup_partitions(database, label, partitions)
291
+ planner.record_backup_partitions(database, label, partitions, ops_database=ops_database)
258
292
 
259
293
  logger.success("Job slot reserved")
260
294
  logger.info(f"Starting incremental backup for group '{group}'...")
@@ -265,6 +299,7 @@ def backup_incremental(config, baseline_backup, group, name):
265
299
  backup_type="incremental",
266
300
  scope="backup",
267
301
  database=cfg["database"],
302
+ ops_database=ops_database,
268
303
  )
269
304
 
270
305
  if result["success"]:
@@ -288,7 +323,7 @@ def backup_incremental(config, baseline_backup, group, name):
288
323
  if state == "LOST":
289
324
  logger.critical("Backup tracking lost!")
290
325
  logger.warning("Another backup operation started during ours.")
291
- logger.tip("Enable ops.run_status concurrency checks to prevent this.")
326
+ logger.tip("Enable run_status concurrency checks to prevent this.")
292
327
  logger.error(f"{result['error_message']}")
293
328
  sys.exit(1)
294
329
 
@@ -348,13 +383,17 @@ def backup_full(config, group, name):
348
383
  tls_config=cfg.get("tls"),
349
384
  )
350
385
 
386
+ ops_database = config_module.get_ops_database(cfg)
387
+
351
388
  with database:
352
- was_created = schema.ensure_ops_schema(database)
389
+ was_created = schema.ensure_ops_schema(database, ops_database=ops_database)
353
390
  if was_created:
354
391
  logger.warning(
355
392
  "ops schema was auto-created. Please run 'starrocks-br init' after populating config."
356
393
  )
357
- logger.warning("Remember to populate ops.table_inventory with your backup groups!")
394
+ logger.warning(
395
+ "Remember to populate the table_inventory table with your backup groups!"
396
+ )
358
397
  sys.exit(1) # Exit if schema was just created, requires user action
359
398
 
360
399
  healthy, message = health.check_cluster_health(database)
@@ -369,13 +408,25 @@ def backup_full(config, group, name):
369
408
  logger.success(f"Repository '{cfg['repository']}' verified")
370
409
 
371
410
  label = labels.determine_backup_label(
372
- db=database, backup_type="full", database_name=cfg["database"], custom_name=name
411
+ db=database,
412
+ backup_type="full",
413
+ database_name=cfg["database"],
414
+ custom_name=name,
415
+ ops_database=ops_database,
373
416
  )
374
417
 
375
418
  logger.success(f"Generated label: {label}")
376
419
 
420
+ tables = planner.find_tables_by_group(database, group, ops_database)
421
+ planner.validate_tables_exist(database, cfg["database"], tables, group)
422
+
377
423
  backup_command = planner.build_full_backup_command(
378
- database, group, cfg["repository"], label, cfg["database"]
424
+ database,
425
+ group,
426
+ cfg["repository"],
427
+ label,
428
+ cfg["database"],
429
+ ops_database=ops_database,
379
430
  )
380
431
 
381
432
  if not backup_command:
@@ -389,9 +440,13 @@ def backup_full(config, group, name):
389
440
  database, cfg["database"], tables
390
441
  )
391
442
 
392
- concurrency.reserve_job_slot(database, scope="backup", label=label)
443
+ concurrency.reserve_job_slot(
444
+ database, scope="backup", label=label, ops_database=ops_database
445
+ )
393
446
 
394
- planner.record_backup_partitions(database, label, all_partitions)
447
+ planner.record_backup_partitions(
448
+ database, label, all_partitions, ops_database=ops_database
449
+ )
395
450
 
396
451
  logger.success("Job slot reserved")
397
452
  logger.info(f"Starting full backup for group '{group}'...")
@@ -402,6 +457,7 @@ def backup_full(config, group, name):
402
457
  backup_type="full",
403
458
  scope="backup",
404
459
  database=cfg["database"],
460
+ ops_database=ops_database,
405
461
  )
406
462
 
407
463
  if result["success"]:
@@ -419,10 +475,13 @@ def backup_full(config, group, name):
419
475
  if state == "LOST":
420
476
  logger.critical("Backup tracking lost!")
421
477
  logger.warning("Another backup operation started during ours.")
422
- logger.tip("Enable ops.run_status concurrency checks to prevent this.")
478
+ logger.tip("Enable run_status concurrency checks to prevent this.")
423
479
  logger.error(f"{result['error_message']}")
424
480
  sys.exit(1)
425
481
 
482
+ except exceptions.InvalidTablesInInventoryError as e:
483
+ error_handler.handle_invalid_tables_in_inventory_error(e, config)
484
+ sys.exit(1)
426
485
  except exceptions.ConcurrencyConflictError as e:
427
486
  error_handler.handle_concurrency_conflict_error(e, config)
428
487
  sys.exit(1)
@@ -499,13 +558,17 @@ def restore_command(config, target_label, group, table, rename_suffix, yes):
499
558
  tls_config=cfg.get("tls"),
500
559
  )
501
560
 
561
+ ops_database = config_module.get_ops_database(cfg)
562
+
502
563
  with database:
503
- was_created = schema.ensure_ops_schema(database)
564
+ was_created = schema.ensure_ops_schema(database, ops_database=ops_database)
504
565
  if was_created:
505
566
  logger.warning(
506
567
  "ops schema was auto-created. Please run 'starrocks-br init' after populating config."
507
568
  )
508
- logger.warning("Remember to populate ops.table_inventory with your backup groups!")
569
+ logger.warning(
570
+ "Remember to populate the table_inventory table with your backup groups!"
571
+ )
509
572
  sys.exit(1) # Exit if schema was just created, requires user action
510
573
 
511
574
  healthy, message = health.check_cluster_health(database)
@@ -521,7 +584,9 @@ def restore_command(config, target_label, group, table, rename_suffix, yes):
521
584
 
522
585
  logger.info(f"Finding restore sequence for target backup: {target_label}")
523
586
 
524
- restore_pair = restore.find_restore_pair(database, target_label)
587
+ restore_pair = restore.find_restore_pair(
588
+ database, target_label, ops_database=ops_database
589
+ )
525
590
  logger.success(f"Found restore sequence: {' -> '.join(restore_pair)}")
526
591
 
527
592
  logger.info("Determining tables to restore from backup manifest...")
@@ -532,6 +597,7 @@ def restore_command(config, target_label, group, table, rename_suffix, yes):
532
597
  group=group,
533
598
  table=table,
534
599
  database=cfg["database"] if table else None,
600
+ ops_database=ops_database,
535
601
  )
536
602
 
537
603
  if not tables_to_restore:
@@ -549,6 +615,7 @@ def restore_command(config, target_label, group, table, rename_suffix, yes):
549
615
  tables_to_restore,
550
616
  rename_suffix,
551
617
  skip_confirmation=yes,
618
+ ops_database=ops_database,
552
619
  )
553
620
 
554
621
  if result["success"]:
@@ -17,45 +17,51 @@ from typing import Literal
17
17
  from . import exceptions, logger, utils
18
18
 
19
19
 
20
- def reserve_job_slot(db, scope: str, label: str) -> None:
21
- """Reserve a job slot in ops.run_status to prevent overlapping jobs.
20
+ def reserve_job_slot(db, scope: str, label: str, ops_database: str = "ops") -> None:
21
+ """Reserve a job slot in the run_status table to prevent overlapping jobs.
22
22
 
23
23
  We consider any row with state='ACTIVE' for the same scope as a conflict.
24
24
  However, we implement self-healing logic to automatically clean up stale locks.
25
25
  """
26
- active_jobs = _get_active_jobs_for_scope(db, scope)
26
+ active_jobs = _get_active_jobs_for_scope(db, scope, ops_database)
27
27
 
28
28
  if not active_jobs:
29
- _insert_new_job(db, scope, label)
29
+ _insert_new_job(db, scope, label, ops_database)
30
30
  return
31
31
 
32
- _handle_active_job_conflicts(db, scope, active_jobs)
32
+ _handle_active_job_conflicts(db, scope, active_jobs, ops_database)
33
33
 
34
- _insert_new_job(db, scope, label)
34
+ _insert_new_job(db, scope, label, ops_database)
35
35
 
36
36
 
37
- def _get_active_jobs_for_scope(db, scope: str) -> list[tuple[str, str, str]]:
37
+ def _get_active_jobs_for_scope(
38
+ db, scope: str, ops_database: str = "ops"
39
+ ) -> list[tuple[str, str, str]]:
38
40
  """Get all active jobs for the given scope."""
39
- rows = db.query("SELECT scope, label, state FROM ops.run_status WHERE state = 'ACTIVE'")
41
+ rows = db.query(
42
+ f"SELECT scope, label, state FROM {ops_database}.run_status WHERE state = 'ACTIVE'"
43
+ )
40
44
  return [row for row in rows if row[0] == scope]
41
45
 
42
46
 
43
- def _handle_active_job_conflicts(db, scope: str, active_jobs: list[tuple[str, str, str]]) -> None:
47
+ def _handle_active_job_conflicts(
48
+ db, scope: str, active_jobs: list[tuple[str, str, str]], ops_database: str = "ops"
49
+ ) -> None:
44
50
  """Handle conflicts with active jobs, cleaning up stale ones where possible."""
45
51
  for active_scope, active_label, _ in active_jobs:
46
- if _can_heal_stale_job(active_scope, active_label, db):
47
- _cleanup_stale_job(db, active_scope, active_label)
52
+ if _can_heal_stale_job(active_scope, active_label, db, ops_database):
53
+ _cleanup_stale_job(db, active_scope, active_label, ops_database)
48
54
  logger.success(f"Cleaned up stale backup job: {active_label}")
49
55
  else:
50
56
  _raise_concurrency_conflict(scope, active_jobs)
51
57
 
52
58
 
53
- def _can_heal_stale_job(scope: str, label: str, db) -> bool:
59
+ def _can_heal_stale_job(scope: str, label: str, db, ops_database: str = "ops") -> bool:
54
60
  """Check if a stale job can be healed (only for backup jobs)."""
55
61
  if scope != "backup":
56
62
  return False
57
63
 
58
- return _is_backup_job_stale(db, label)
64
+ return _is_backup_job_stale(db, label, ops_database)
59
65
 
60
66
 
61
67
  def _raise_concurrency_conflict(scope: str, active_jobs: list[tuple[str, str, str]]) -> None:
@@ -63,22 +69,22 @@ def _raise_concurrency_conflict(scope: str, active_jobs: list[tuple[str, str, st
63
69
  raise exceptions.ConcurrencyConflictError(scope, active_jobs)
64
70
 
65
71
 
66
- def _insert_new_job(db, scope: str, label: str) -> None:
72
+ def _insert_new_job(db, scope: str, label: str, ops_database: str = "ops") -> None:
67
73
  """Insert a new active job record."""
68
74
  sql = f"""
69
- INSERT INTO ops.run_status (scope, label, state, started_at)
75
+ INSERT INTO {ops_database}.run_status (scope, label, state, started_at)
70
76
  VALUES ({utils.quote_value(scope)}, {utils.quote_value(label)}, 'ACTIVE', NOW())
71
77
  """
72
78
  db.execute(sql)
73
79
 
74
80
 
75
- def _is_backup_job_stale(db, label: str) -> bool:
81
+ def _is_backup_job_stale(db, label: str, ops_database: str = "ops") -> bool:
76
82
  """Check if a backup job is stale by querying StarRocks SHOW BACKUP.
77
83
 
78
84
  Returns True if the job is stale (not actually running), False if it's still active.
79
85
  """
80
86
  try:
81
- user_databases = _get_user_databases(db)
87
+ user_databases = _get_user_databases(db, ops_database)
82
88
 
83
89
  for database_name in user_databases:
84
90
  job_status = _check_backup_job_in_database(db, database_name, label)
@@ -98,9 +104,9 @@ def _is_backup_job_stale(db, label: str) -> bool:
98
104
  return False
99
105
 
100
106
 
101
- def _get_user_databases(db) -> list[str]:
107
+ def _get_user_databases(db, ops_database: str = "ops") -> list[str]:
102
108
  """Get list of user databases (excluding system databases)."""
103
- system_databases = {"information_schema", "mysql", "sys", "ops"}
109
+ system_databases = {"information_schema", "mysql", "sys", ops_database}
104
110
 
105
111
  databases = db.query("SHOW DATABASES")
106
112
  return [
@@ -159,10 +165,10 @@ def _extract_backup_info(result) -> tuple[str, str]:
159
165
  return snapshot_name, state
160
166
 
161
167
 
162
- def _cleanup_stale_job(db, scope: str, label: str) -> None:
168
+ def _cleanup_stale_job(db, scope: str, label: str, ops_database: str = "ops") -> None:
163
169
  """Clean up a stale job by updating its state to CANCELLED."""
164
170
  sql = f"""
165
- UPDATE ops.run_status
171
+ UPDATE {ops_database}.run_status
166
172
  SET state='CANCELLED', finished_at=NOW()
167
173
  WHERE scope={utils.quote_value(scope)} AND label={utils.quote_value(label)} AND state='ACTIVE'
168
174
  """
@@ -170,14 +176,18 @@ def _cleanup_stale_job(db, scope: str, label: str) -> None:
170
176
 
171
177
 
172
178
  def complete_job_slot(
173
- db, scope: str, label: str, final_state: Literal["FINISHED", "FAILED", "CANCELLED"]
179
+ db,
180
+ scope: str,
181
+ label: str,
182
+ final_state: Literal["FINISHED", "FAILED", "CANCELLED"],
183
+ ops_database: str = "ops",
174
184
  ) -> None:
175
185
  """Complete job slot and persist final state.
176
186
 
177
187
  Simple approach: update the same row by scope/label.
178
188
  """
179
189
  sql = f"""
180
- UPDATE ops.run_status
190
+ UPDATE {ops_database}.run_status
181
191
  SET state={utils.quote_value(final_state)}, finished_at=NOW()
182
192
  WHERE scope={utils.quote_value(scope)} AND label={utils.quote_value(label)}
183
193
  """
@@ -57,6 +57,34 @@ def validate_config(config: dict[str, Any]) -> None:
57
57
  raise exceptions.ConfigValidationError(f"Missing required config field: {field}")
58
58
 
59
59
  _validate_tls_section(config.get("tls"))
60
+ _validate_table_inventory_section(config.get("table_inventory"))
61
+
62
+
63
+ def get_ops_database(config: dict[str, Any]) -> str:
64
+ """Get the ops database name from config, defaulting to 'ops'."""
65
+ return config.get("ops_database", "ops")
66
+
67
+
68
+ def get_table_inventory_entries(config: dict[str, Any]) -> list[tuple[str, str, str]]:
69
+ """Extract table inventory entries from config.
70
+
71
+ Args:
72
+ config: Configuration dictionary
73
+
74
+ Returns:
75
+ List of tuples (group, database, table)
76
+ """
77
+ table_inventory = config.get("table_inventory")
78
+ if not table_inventory:
79
+ return []
80
+
81
+ entries = []
82
+ for group_entry in table_inventory:
83
+ group = group_entry["group"]
84
+ for table_entry in group_entry["tables"]:
85
+ entries.append((group, table_entry["database"], table_entry["table"]))
86
+
87
+ return entries
60
88
 
61
89
 
62
90
  def _validate_tls_section(tls_config) -> None:
@@ -88,3 +116,50 @@ def _validate_tls_section(tls_config) -> None:
88
116
  raise exceptions.ConfigValidationError(
89
117
  "TLS configuration field 'tls_versions' must be a list of strings if provided"
90
118
  )
119
+
120
+
121
+ def _validate_table_inventory_section(table_inventory) -> None:
122
+ if table_inventory is None:
123
+ return
124
+
125
+ if not isinstance(table_inventory, list):
126
+ raise exceptions.ConfigValidationError("'table_inventory' must be a list")
127
+
128
+ for entry in table_inventory:
129
+ if not isinstance(entry, dict):
130
+ raise exceptions.ConfigValidationError(
131
+ "Each entry in 'table_inventory' must be a dictionary"
132
+ )
133
+
134
+ if "group" not in entry:
135
+ raise exceptions.ConfigValidationError(
136
+ "Each entry in 'table_inventory' must have a 'group' field"
137
+ )
138
+
139
+ if "tables" not in entry:
140
+ raise exceptions.ConfigValidationError(
141
+ "Each entry in 'table_inventory' must have a 'tables' field"
142
+ )
143
+
144
+ if not isinstance(entry["group"], str):
145
+ raise exceptions.ConfigValidationError("'group' field must be a string")
146
+
147
+ tables = entry["tables"]
148
+ if not isinstance(tables, list):
149
+ raise exceptions.ConfigValidationError("'tables' field must be a list")
150
+
151
+ for table_entry in tables:
152
+ if not isinstance(table_entry, dict):
153
+ raise exceptions.ConfigValidationError("Each table entry must be a dictionary")
154
+
155
+ if "database" not in table_entry or "table" not in table_entry:
156
+ raise exceptions.ConfigValidationError(
157
+ "Each table entry must have 'database' and 'table' fields"
158
+ )
159
+
160
+ if not isinstance(table_entry["database"], str) or not isinstance(
161
+ table_entry["table"], str
162
+ ):
163
+ raise exceptions.ConfigValidationError(
164
+ "'database' and 'table' fields must be strings"
165
+ )