| """ |
| Background Task Manager |
| |
| Manages async background task execution with progress tracking. |
| Uses pure async patterns for task execution. |
| """ |
|
|
| import asyncio |
| import uuid |
| from collections.abc import Callable |
| from datetime import datetime, timedelta |
| from typing import Any |
|
|
| from ..config.logfire_config import get_logger |
|
|
| logger = get_logger(__name__) |
|
|
|
|
| class BackgroundTaskManager: |
| """Manages async background task execution with progress tracking""" |
|
|
| def __init__(self, max_concurrent_tasks: int = 10, metadata_retention_hours: int = 1): |
| self.active_tasks: dict[str, asyncio.Task] = {} |
| self.task_metadata: dict[str, dict[str, Any]] = {} |
| self.max_concurrent_tasks = max_concurrent_tasks |
| self.metadata_retention_hours = metadata_retention_hours |
| self._task_semaphore = asyncio.Semaphore(max_concurrent_tasks) |
| self._cleanup_task: asyncio.Task | None = None |
| logger.info( |
| f"BackgroundTaskManager initialized with max {max_concurrent_tasks} concurrent tasks, {metadata_retention_hours}h metadata retention" |
| ) |
|
|
| def set_main_loop(self, loop: asyncio.AbstractEventLoop): |
| """Set the main event loop for the task manager""" |
| logger.info("BackgroundTaskManager uses pure async - main loop setting not required") |
|
|
| async def submit_task( |
| self, |
| async_task_func: Callable, |
| task_args: tuple, |
| task_id: str | None = None, |
| progress_callback: Callable | None = None, |
| ) -> str: |
| """Submit an async task for background execution""" |
| task_id = task_id or str(uuid.uuid4()) |
|
|
| |
| self.task_metadata[task_id] = { |
| "created_at": datetime.utcnow(), |
| "status": "running", |
| "progress": 0, |
| } |
|
|
| logger.info(f"Submitting async task {task_id} for background execution") |
|
|
| |
| if self._cleanup_task is None or self._cleanup_task.done(): |
| self._cleanup_task = asyncio.create_task(self._periodic_cleanup()) |
|
|
| |
| async_task = asyncio.create_task( |
| self._run_async_with_progress(async_task_func, task_args, task_id, progress_callback) |
| ) |
|
|
| self.active_tasks[task_id] = async_task |
| return task_id |
|
|
| async def _run_async_with_progress( |
| self, |
| async_task_func: Callable, |
| task_args: tuple, |
| task_id: str, |
| progress_callback: Callable | None = None, |
| ) -> Any: |
| """Wrapper to run async task with progress tracking and concurrency control""" |
| async with self._task_semaphore: |
| try: |
| logger.info(f"Starting execution of async task {task_id}") |
|
|
| |
| self.task_metadata[task_id].update({"status": "running", "progress": 0}) |
|
|
| |
| result = await async_task_func(*task_args) |
|
|
| |
| self.task_metadata[task_id].update( |
| { |
| "status": "complete", |
| "progress": 100, |
| "result": result, |
| } |
| ) |
|
|
| |
| if progress_callback: |
| try: |
| await progress_callback(task_id, {"status": "complete", "percentage": 100, "result": result}) |
| except Exception as callback_error: |
| logger.error(f"Progress callback error for completed task {task_id}: {callback_error}") |
|
|
| logger.info(f"Async task {task_id} completed successfully") |
| return result |
|
|
| except Exception as e: |
| logger.error(f"Async task {task_id} failed with error: {e}") |
|
|
| |
| self.task_metadata[task_id].update( |
| { |
| "status": "error", |
| "progress": -1, |
| "error": str(e), |
| } |
| ) |
|
|
| |
| if progress_callback: |
| try: |
| await progress_callback(task_id, {"status": "error", "percentage": -1, "error": str(e)}) |
| except Exception as callback_error: |
| logger.error(f"Progress callback error for failed task {task_id}: {callback_error}") |
|
|
| raise |
| finally: |
| |
| if task_id in self.active_tasks: |
| del self.active_tasks[task_id] |
|
|
| async def get_task_status(self, task_id: str) -> dict[str, Any]: |
| """Get current status of a task""" |
| metadata = self.task_metadata.get(task_id, {}) |
|
|
| if task_id not in self.active_tasks: |
| |
| if metadata: |
| return metadata |
| else: |
| return {"error": "Task not found"} |
|
|
| task = self.active_tasks[task_id] |
|
|
| if task.done(): |
| try: |
| result = task.result() |
| metadata["result"] = result |
| except Exception as e: |
| metadata["error"] = str(e) |
|
|
| return metadata |
|
|
| async def cancel_task(self, task_id: str) -> bool: |
| """Cancel a running async task""" |
| if task_id in self.active_tasks: |
| logger.info(f"Cancelling async task {task_id}") |
| task = self.active_tasks[task_id] |
| task.cancel() |
|
|
| |
| if task_id in self.task_metadata: |
| self.task_metadata[task_id]["status"] = "cancelled" |
|
|
| |
| del self.active_tasks[task_id] |
| return True |
| return False |
|
|
| async def _periodic_cleanup(self): |
| """Periodically clean up old task metadata to prevent memory leaks""" |
| while True: |
| try: |
| await asyncio.sleep(300) |
|
|
| current_time = datetime.utcnow() |
| retention_cutoff = current_time - timedelta(hours=self.metadata_retention_hours) |
|
|
| |
| tasks_to_remove = [] |
| for task_id, metadata in self.task_metadata.items(): |
| |
| if metadata.get("status") in ["complete", "error", "cancelled"]: |
| created_at = metadata.get("created_at") |
| if created_at and created_at < retention_cutoff: |
| tasks_to_remove.append(task_id) |
|
|
| |
| for task_id in tasks_to_remove: |
| del self.task_metadata[task_id] |
| logger.debug(f"Cleaned up metadata for old task {task_id}") |
|
|
| if tasks_to_remove: |
| logger.info(f"Cleaned up metadata for {len(tasks_to_remove)} old tasks") |
|
|
| except asyncio.CancelledError: |
| logger.info("Periodic cleanup task cancelled") |
| break |
| except Exception as e: |
| logger.error(f"Error in periodic cleanup: {e}", exc_info=True) |
| await asyncio.sleep(60) |
|
|
| async def cleanup(self): |
| """Cleanup resources and cancel remaining tasks""" |
| logger.info("Shutting down BackgroundTaskManager") |
|
|
| |
| if self._cleanup_task and not self._cleanup_task.done(): |
| self._cleanup_task.cancel() |
| try: |
| await self._cleanup_task |
| except asyncio.CancelledError: |
| pass |
|
|
| |
| for task_id, task in list(self.active_tasks.items()): |
| logger.info(f"Cancelling active task {task_id} during cleanup") |
| task.cancel() |
|
|
| |
| if task_id in self.task_metadata: |
| self.task_metadata[task_id]["status"] = "cancelled" |
|
|
| |
| if self.active_tasks: |
| await asyncio.gather(*self.active_tasks.values(), return_exceptions=True) |
|
|
| |
| self.active_tasks.clear() |
| self.task_metadata.clear() |
|
|
| logger.info("BackgroundTaskManager shutdown complete") |
|
|
|
|
| |
| _task_manager: BackgroundTaskManager | None = None |
|
|
|
|
| def get_task_manager() -> BackgroundTaskManager: |
| """Get or create the global task manager instance""" |
| global _task_manager |
| if _task_manager is None: |
| _task_manager = BackgroundTaskManager() |
| return _task_manager |
|
|
|
|
| async def cleanup_task_manager(): |
| """Cleanup the global task manager instance""" |
| global _task_manager |
| if _task_manager: |
| await _task_manager.cleanup() |
| _task_manager = None |
|
|