Source code for ccat_data_transfer.task_state_manager

from datetime import datetime

from ccat_ops_db.models import OperationKind


def _operation_type_str(operation_type) -> str:
    """Render an operation identity to its on-the-wire Redis string.

    Callers key off OperationKind members; Redis stores the identity as a plain
    string. An OperationKind member's VALUE is byte-identical to the legacy
    breadcrumb string, so the keys are unchanged on the wire. Using ``.value``
    explicitly (NOT an f-string of the member) is mandatory: under Python 3.12
    ``str(member)`` / ``f"{member}"`` render the member NAME, which would build a
    wrong key. Plain strings pass through unchanged.
    """
    if isinstance(operation_type, OperationKind):
        return operation_type.value
    return operation_type


[docs] class TaskStateManager: """Manager for tracking and recovering task states across all operation types."""
[docs] def __init__(self, redis_client): self.redis = redis_client
[docs] def register_task( self, task_id, operation_type, operation_id, additional_info=None, max_retries=3 ): """ Register a task in Redis with its metadata. Args: task_id (str): Celery task ID operation_type (str): Type of operation (transfer, archive, package, delete, verify) operation_id (int): Database ID of the operation additional_info (dict, optional): Additional context about the operation max_retries (int, optional): Maximum retry count for this task """ key = f"task:{task_id}" # Base data for all task types data = { "operation_type": operation_type, "operation_id": str(operation_id), "status": "RUNNING", "start_time": datetime.now().isoformat(), "heartbeat": datetime.now().isoformat(), "retry_count": "0", "max_retries": str(max_retries), } # Add additional info if provided if additional_info: for k, v in additional_info.items(): data[k] = str(v) # Store in Redis with TTL self.redis.hmset(key, data) self.redis.expire(key, 86400 * 2) # 48 hour TTL # Maintain indices for each operation type and ID self.redis.sadd(f"running_tasks:{operation_type}", task_id) self.redis.sadd(f"tasks_for_operation:{operation_type}:{operation_id}", task_id)
[docs] def update_heartbeat(self, task_id): """Update task heartbeat to indicate it's still running.""" key = f"task:{task_id}" if self.redis.exists(key): self.redis.hset(key, "heartbeat", datetime.now().isoformat())
[docs] def complete_task(self, task_id): """Mark task as completed and remove from tracking.""" key = f"task:{task_id}" # Get operation info before deleting if not self.redis.exists(key): return operation_type = self.redis.hget(key, "operation_type") operation_id = self.redis.hget(key, "operation_id") if operation_type and operation_id: # Remove from indices self.redis.srem(f"running_tasks:{operation_type}", task_id) self.redis.srem( f"tasks_for_operation:{operation_type}:{operation_id}", task_id ) # Mark as completed self.redis.hset(key, "status", "COMPLETED") self.redis.hset(key, "end_time", datetime.now().isoformat()) # Keep completed task info for a while before deleting self.redis.expire(key, 86400) # 24 hour TTL for completed tasks
[docs] def fail_task(self, task_id, error_message, is_retryable=True): """ Mark task as failed. Returns: tuple: (can_retry, operation_type, operation_id) """ key = f"task:{task_id}" if not self.redis.exists(key): return False, None, None # Get operation info operation_type = self.redis.hget(key, "operation_type") operation_id = self.redis.hget(key, "operation_id") # Update status self.redis.hset(key, "status", "FAILED") self.redis.hset(key, "error", error_message) self.redis.hset(key, "end_time", datetime.now().isoformat()) # Remove from running tasks if operation_type: self.redis.srem(f"running_tasks:{operation_type}", task_id) if is_retryable: # Increment retry count retry_count = int(self.redis.hincrby(key, "retry_count", 1)) max_retries = int(self.redis.hget(key, "max_retries") or 3) if retry_count <= max_retries: # Ready to be retried return True, operation_type, operation_id else: # Max retries exceeded self.redis.hset(key, "status", "FAILED_PERMANENT") return False, operation_type, operation_id else: # Not retryable self.redis.hset(key, "status", "FAILED_PERMANENT") return False, operation_type, operation_id
[docs] def get_stalled_tasks(self, heartbeat_timeout=300): """ Find tasks that haven't updated their heartbeat recently. Returns: list: List of dicts with task information """ now = datetime.now() stalled_tasks = [] # Get all running tasks all_task_keys = self.redis.keys("task:*") for task_key in all_task_keys: task_data = self.redis.hgetall(task_key) if task_data.get("status") != "RUNNING": continue # Check heartbeat try: last_heartbeat = datetime.fromisoformat(task_data.get("heartbeat")) task_id = task_key.replace("task:", "") if (now - last_heartbeat).total_seconds() > heartbeat_timeout: stalled_tasks.append( { "task_id": task_id, "operation_type": task_data.get("operation_type"), "operation_id": task_data.get("operation_id"), "last_heartbeat": last_heartbeat, "stalled_for": (now - last_heartbeat).total_seconds(), } ) except (ValueError, TypeError): # Invalid heartbeat format, consider stalled stalled_tasks.append( { "task_id": task_key.replace("task:", ""), "operation_type": task_data.get("operation_type"), "operation_id": task_data.get("operation_id"), "last_heartbeat": None, "stalled_for": None, } ) return stalled_tasks
[docs] def is_operation_alive(self, operation_type, operation_id, heartbeat_timeout=None): """ Report whether an operation still has a live worker behind it. Reverse-lookup of the per-operation index: read the sibling task_ids in ``tasks_for_operation:{operation_type}:{operation_id}`` and inspect each ``task:{task_id}`` hash. The operation is alive only if at least one sibling is ``RUNNING`` with a heartbeat fresher than ``heartbeat_timeout``. This is the liveness probe the archive reconciler gates on (#161): the primary dead-worker path is ``task_monitor`` heartbeat recovery, and this lets the reconciler stand down whenever a registered task is still beating. Args: operation_type: Operation identity — an ``OperationKind`` member (preferred) or its breadcrumb string (e.g. ``OperationKind.ARCHIVE`` / ``"long_term_archive"``). The member's value is the on-the-wire key, so both build the same Redis key. operation_id (int): Database ID of the operation. heartbeat_timeout (int, optional): Max heartbeat age in seconds to count a sibling as alive. Defaults to ``TASK_RECOVERY.heartbeat_timeout``. Returns: bool: True if a sibling task is RUNNING with a fresh heartbeat; False if the index set is empty, the hashes are missing, or every sibling is stale or non-RUNNING. """ operation_type = _operation_type_str(operation_type) if heartbeat_timeout is None: from .config.config import ccat_data_transfer_settings heartbeat_timeout = ( ccat_data_transfer_settings.TASK_RECOVERY.heartbeat_timeout ) now = datetime.now() task_ids = self.redis.smembers( f"tasks_for_operation:{operation_type}:{operation_id}" ) for task_id in task_ids or []: task_data = self.redis.hgetall(f"task:{task_id}") if not task_data or task_data.get("status") != "RUNNING": continue # Mirror get_stalled_tasks() staleness logic: an unparseable or # missing heartbeat is treated as not-alive (i.e. stale). try: last_heartbeat = datetime.fromisoformat(task_data.get("heartbeat")) except (ValueError, TypeError): continue if (now - last_heartbeat).total_seconds() < heartbeat_timeout: return True return False