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 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
- def restore_command(config, target_label, group, rename_suffix):
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
- tables_to_restore = restore.get_tables_from_backup(database, target_label, group)
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__(self, host: str, port: int, user: str, password: str, database: str):
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
- self._connection = mysql.connector.connect(
28
- host=self.host,
29
- port=self.port,
30
- user=self.user,
31
- password=self.password,
32
- database=self.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
- from typing import Dict, Literal, Optional
4
- from . import history, concurrency, logger
3
+ import re
4
+ from typing import Dict, Literal, Optional, Tuple
5
+ from . import history, concurrency, logger, timezone
5
6
 
6
- MAX_POLLS = 21600 # 6 hours
7
+ MAX_POLLS = 86400 # 1 day
7
8
 
8
- def submit_backup_command(db, backup_command: str) -> tuple[bool, Optional[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
- error_msg = f"Failed to submit backup command: {type(e).__name__}: {str(e)}"
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
- started_at = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
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
- return {
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": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
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": row[2]
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
- baseline_time = baseline_rows[0][0]
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
- baseline_time = latest_backup['finished_at']
97
+ baseline_time_raw = latest_backup['finished_at']
85
98
 
86
- if isinstance(baseline_time, str):
87
- threshold_str = baseline_time
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
- threshold_str = baseline_time.strftime("%Y-%m-%d %H:%M:%S")
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, str):
133
- version_time_str = visible_version_time
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
- version_time_str = visible_version_time.strftime("%Y-%m-%d %H:%M:%S")
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 version_time_str > threshold_str:
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