pyrunner / core /tasks.py
Perspicacious's picture
Deploy PyRunner on Hugging Face
2529305
Raw
History Blame Contribute Delete
13.8 kB
"""
Async tasks for PyRunner using django-q2.
This module contains task functions that are executed asynchronously
by django-q2 workers.
"""
import logging
from uuid import UUID
from django.utils import timezone
from django_q.tasks import async_task
from core.models import Run, Script, ScriptSchedule, GlobalSettings, PackageOperation
logger = logging.getLogger(__name__)
def execute_run_task(run_id: str, webhook_data: dict | None = None) -> dict:
"""
Execute a script run asynchronously.
This is the task function called by django-q2 workers.
It fetches the Run by ID and delegates to the executor.
Args:
run_id: The UUID of the Run record (as string)
webhook_data: Optional webhook data to inject as environment variables
Returns:
dict: Execution result summary for logging/monitoring
"""
from core.executor import execute_run # Import here to avoid circular imports
from core.services.notification_service import NotificationService
try:
run = Run.objects.select_related("script", "script__environment").get(
id=UUID(run_id)
)
except Run.DoesNotExist:
logger.error(f"Run {run_id} not found - task cannot execute")
return {
"success": False,
"run_id": run_id,
"error": "Run not found",
}
except ValueError as e:
logger.error(f"Invalid run_id format: {run_id} - {e}")
return {
"success": False,
"run_id": run_id,
"error": f"Invalid UUID format: {e}",
}
execute_run(run, webhook_data=webhook_data)
run.refresh_from_db()
# Send notifications after run completion
try:
notification_results = NotificationService.send_notification(run)
if notification_results.get("email_sent") or notification_results.get("webhook_sent"):
logger.info(f"Notifications sent for run {run_id}: {notification_results}")
except Exception as e:
logger.error(f"Failed to send notifications for run {run_id}: {e}")
logger.info(f"Task completed for Run {run_id} with status {run.status}")
return {
"success": run.status == Run.Status.SUCCESS,
"run_id": run_id,
"status": run.status,
"exit_code": run.exit_code,
}
def queue_script_run(run: Run, webhook_data: dict | None = None) -> str:
"""
Queue a Run for async execution.
This is the main entry point for queuing script runs.
It handles setting up the async task and storing the task_id.
Args:
run: The Run model instance to execute
webhook_data: Optional webhook data to inject as environment variables
Returns:
str: The django-q2 task ID
Raises:
ValueError: If the run is not in PENDING status
"""
if run.status != Run.Status.PENDING:
raise ValueError(
f"Cannot queue run {run.id}: status is {run.status}, expected PENDING"
)
task_id = async_task(
"core.tasks.execute_run_task",
str(run.id),
webhook_data,
task_name=f"run-{run.id}",
timeout=run.script.timeout_seconds + 60,
)
run.task_id = task_id
run.save(update_fields=["task_id"])
logger.info(f"Queued Run {run.id} as task {task_id}")
return task_id
def execute_scheduled_run(script_id: str) -> dict:
"""
Execute a scheduled script run.
Called by django-q2 scheduler.
Args:
script_id: The UUID of the Script (as string)
Returns:
dict: Execution result summary
"""
from core.services.schedule_service import ScheduleService
# Check global pause
settings = GlobalSettings.get_settings()
if settings.schedules_paused:
logger.info(f"Scheduled run for script {script_id} skipped - schedules paused")
return {"success": False, "error": "Schedules globally paused"}
try:
script = Script.objects.get(id=UUID(script_id))
except Script.DoesNotExist:
logger.error(f"Script {script_id} not found for scheduled run")
return {"success": False, "error": "Script not found"}
except ValueError as e:
logger.error(f"Invalid script_id format: {script_id} - {e}")
return {"success": False, "error": f"Invalid UUID format: {e}"}
# Check if script can run (enabled and not archived)
if not script.can_run:
reason = "archived" if script.is_archived else "disabled"
logger.info(f"Scheduled run for script {script.name} skipped - script {reason}")
return {"success": False, "error": f"Script {reason}"}
# Check if schedule is active
try:
schedule = script.schedule
if not schedule.is_active:
logger.info(
f"Scheduled run for script {script.name} skipped - schedule inactive"
)
return {"success": False, "error": "Schedule inactive"}
except ScriptSchedule.DoesNotExist:
logger.error(f"No schedule found for script {script.name}")
return {"success": False, "error": "No schedule"}
# Create the run
run = Run.objects.create(
script=script,
status=Run.Status.PENDING,
triggered_by=None, # System-triggered
trigger_type=Run.TriggerType.SCHEDULED,
code_snapshot=script.code,
)
# Update schedule tracking
schedule.last_scheduled_run = timezone.now()
schedule.save(update_fields=["last_scheduled_run"])
# Queue for execution
queue_script_run(run)
# Update next_run cache
schedule.next_run = ScheduleService._calculate_next_run(schedule)
schedule.save(update_fields=["next_run"])
logger.info(f"Scheduled run {run.id} created for script {script.name}")
return {
"success": True,
"run_id": str(run.id),
"script_id": script_id,
}
def execute_package_operation(operation_id: str) -> dict:
"""
Execute a package operation (install/uninstall) asynchronously.
Called by django-q2 workers.
Args:
operation_id: The UUID of the PackageOperation record (as string)
Returns:
dict: Operation result summary
"""
from core.services.environment_service import EnvironmentService
try:
operation = PackageOperation.objects.select_related("environment").get(
id=UUID(operation_id)
)
except PackageOperation.DoesNotExist:
logger.error(f"PackageOperation {operation_id} not found")
return {"success": False, "error": "Operation not found"}
except ValueError as e:
logger.error(f"Invalid operation_id format: {operation_id} - {e}")
return {"success": False, "error": f"Invalid UUID format: {e}"}
# Update to running
operation.status = PackageOperation.Status.RUNNING
operation.started_at = timezone.now()
operation.save(update_fields=["status", "started_at"])
environment = operation.environment
try:
if operation.operation == PackageOperation.Operation.INSTALL:
success, stdout, stderr = EnvironmentService.install_package(
environment, operation.package_spec
)
elif operation.operation == PackageOperation.Operation.UNINSTALL:
success, stdout, stderr = EnvironmentService.uninstall_package(
environment, operation.package_spec
)
elif operation.operation == PackageOperation.Operation.BULK_INSTALL:
success, stdout, stderr = EnvironmentService.install_requirements(
environment, operation.package_spec
)
else:
success, stdout, stderr = False, "", "Unknown operation type"
operation.output = stdout
operation.error = stderr
operation.status = (
PackageOperation.Status.SUCCESS
if success
else PackageOperation.Status.FAILED
)
# Update environment requirements cache on success
if success:
environment.requirements = EnvironmentService.pip_freeze(environment)
environment.save(update_fields=["requirements", "updated_at"])
except Exception as e:
operation.status = PackageOperation.Status.FAILED
operation.error = str(e)
logger.exception(f"Package operation {operation_id} failed with exception")
operation.completed_at = timezone.now()
operation.save()
logger.info(
f"Package operation {operation_id} completed with status {operation.status}"
)
return {
"success": operation.status == PackageOperation.Status.SUCCESS,
"operation_id": operation_id,
"status": operation.status,
}
def cleanup_old_runs_task() -> dict:
"""
Clean up old runs based on retention settings.
Called by django-q2 scheduler or manually.
Returns:
dict: Cleanup result summary with count of deleted runs
"""
from core.services.retention_service import RetentionService
try:
deleted_count = RetentionService.cleanup_all_runs()
# Update last_cleanup_at timestamp
settings = GlobalSettings.get_settings()
settings.last_cleanup_at = timezone.now()
settings.save(update_fields=["last_cleanup_at"])
logger.info(f"Cleanup task completed: {deleted_count} runs deleted")
return {"success": True, "deleted_count": deleted_count}
except Exception as e:
logger.exception("Cleanup task failed")
return {"success": False, "error": str(e)}
def worker_heartbeat_task() -> dict:
"""
Heartbeat task that runs periodically to indicate workers are alive.
Updates the worker_heartbeat_at timestamp in GlobalSettings.
This task is scheduled to run every minute when qcluster starts.
It allows the system to detect when workers have stopped.
Returns:
dict: Result with timestamp of heartbeat
"""
try:
settings = GlobalSettings.get_settings()
settings.worker_heartbeat_at = timezone.now()
settings.save(update_fields=["worker_heartbeat_at"])
logger.debug("Worker heartbeat updated")
return {"success": True, "heartbeat_at": str(settings.worker_heartbeat_at)}
except Exception as e:
logger.exception("Worker heartbeat failed")
return {"success": False, "error": str(e)}
def scheduled_backup_task() -> dict:
"""
Execute a scheduled backup to S3.
Called by django-q2 scheduler.
Creates a backup, uploads to S3, and applies retention policy.
Returns:
dict: Backup result summary
"""
from core.services.backup_service import BackupService
from core.services.s3_service import S3Service
from core.services.backup_schedule_service import BackupScheduleService
settings = GlobalSettings.get_settings()
# Validate S3 is configured
if not settings.s3_enabled:
logger.warning("Scheduled backup skipped - S3 not enabled")
return {"success": False, "error": "S3 not enabled"}
if not S3Service.is_configured():
logger.warning("Scheduled backup skipped - S3 not configured")
return {"success": False, "error": "S3 not configured"}
try:
# Create backup
backup_data = BackupService.create_backup(
include_runs=settings.s3_backup_include_runs,
max_runs=settings.s3_backup_max_runs,
include_package_operations=False,
include_datastores=settings.s3_backup_include_datastores,
created_by_user=None, # System backup
)
# Serialize to gzip
file_bytes, _ = BackupService.serialize_backup(
backup_data,
format=BackupService.FORMAT_GZIP,
)
# Generate S3 key
key = S3Service.generate_backup_key()
# Upload to S3
result = S3Service.upload_file(file_bytes, key)
if result["success"]:
# Update tracking
settings.s3_backup_last_run_at = timezone.now()
settings.s3_backup_last_status = "success"
settings.s3_backup_last_error = ""
settings.s3_backup_last_size = result["size"]
settings.save(update_fields=[
"s3_backup_last_run_at",
"s3_backup_last_status",
"s3_backup_last_error",
"s3_backup_last_size",
])
# Apply retention cleanup
deleted = BackupScheduleService.apply_retention()
logger.info(f"Scheduled backup completed: {key} ({result['size']} bytes)")
return {
"success": True,
"key": key,
"size": result["size"],
"retention_deleted": deleted,
}
else:
# Update tracking with error
settings.s3_backup_last_run_at = timezone.now()
settings.s3_backup_last_status = "failed"
settings.s3_backup_last_error = result.get("error", "Unknown error")
settings.save(update_fields=[
"s3_backup_last_run_at",
"s3_backup_last_status",
"s3_backup_last_error",
])
logger.error(f"Scheduled backup failed: {result.get('error')}")
return {"success": False, "error": result.get("error")}
except Exception as e:
# Update tracking with error
settings.s3_backup_last_run_at = timezone.now()
settings.s3_backup_last_status = "failed"
settings.s3_backup_last_error = str(e)
settings.save(update_fields=[
"s3_backup_last_run_at",
"s3_backup_last_status",
"s3_backup_last_error",
])
logger.exception("Scheduled backup task failed")
return {"success": False, "error": str(e)}