import os import yaml import asyncio from pathlib import Path from typing import Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.logging import get_logger from app.db.models import Dataset # ✅ 确保引入 TaskExecution from app.db.models.data_collection import CollectionTask, CollectionTemplate, TaskExecution from app.db.session import AsyncSessionLocal from app.module.collection.client.datax_client import DataxClient from app.module.collection.schema.collection import SyncMode, create_execute_record from app.module.collection.schedule import validate_schedule_expression from app.module.dataset.service.service import Service from app.module.shared.schema import TaskStatus, NodeType, EdgeType from app.module.shared.common.lineage import LineageService from app.db.models.base_entity import LineageNode, LineageEdge # ✅ 确保引入 RuntimeClient from app.module.shared.runtime_client import RuntimeClient logger = get_logger(__name__) # 执行模式开关:'local' (默认) | 'runtime' COLLECTION_EXECUTION_MODE = os.getenv("COLLECTION_EXECUTION_MODE", "local") class _TempExecution: """用于生成配置时的临时占位符""" def __init__(self, task_id): self.id = task_id class CollectionTaskService: def __init__(self, db: AsyncSession): self.db = db async def create_task(self, task: CollectionTask, dataset: Dataset) -> CollectionTask: self.db.add(task) if task.sync_mode == SyncMode.ONCE: task.status = TaskStatus.RUNNING.name await self.db.commit() asyncio.create_task(CollectionTaskService.run_async(task.id, dataset.id if dataset else None)) elif task.sync_mode == SyncMode.SCHEDULED: if not task.schedule_expression: raise ValueError("schedule_expression is required for scheduled tasks") validate_schedule_expression(task.schedule_expression) return task @staticmethod async def run_async(task_id: str, dataset_id: str = None): """任务执行入口""" logger.info(f"Start to execute task {task_id}") # 1. 获取任务基础信息 async with AsyncSessionLocal() as session: task_result = await session.execute(select(CollectionTask).where(CollectionTask.id == task_id)) task = task_result.scalar_one_or_none() if not task: logger.error(f"task {task_id} not exist") return template_result = await session.execute(select(CollectionTemplate).where(CollectionTemplate.id == task.template_id)) template = template_result.scalar_one_or_none() if not template: logger.error(f"template {task.template_name} not exist") return # 2. 创建执行记录 (此时 task_execution.log_path 已经根据 schema 规则被设置好) task_execution = create_execute_record(task) session.add(task_execution) await session.commit() await session.refresh(task_execution) exec_id = task_execution.id # 3. 根据模式分发 try: if COLLECTION_EXECUTION_MODE == "runtime": # ✅ 关键修改:将 task_execution 对象传入,以便提取 execution_id await CollectionTaskService._execute_via_runtime( task_id, dataset_id, task, template, task_execution ) # 提交成功后更新状态为 SUBMITTED async with AsyncSessionLocal() as update_session: exec_record = await update_session.get(TaskExecution, exec_id) if exec_record: exec_record.status = "SUBMITTED" await update_session.commit() logger.info(f"Task {task_id} submitted to Runtime. Starting background poller.") # 启动后台轮询 asyncio.create_task( CollectionTaskService._poll_runtime_status(task_id, dataset_id, exec_id) ) else: # Local 模式 await CollectionTaskService._execute_locally(task_id, dataset_id, task, template, exec_id) except Exception as e: logger.error(f"Task execution failed for {task_id}: {e}") async with AsyncSessionLocal() as fail_session: exec_record = await fail_session.get(TaskExecution, exec_id) if exec_record: exec_record.status = TaskStatus.FAILED.name exec_record.error_message = str(e) await fail_session.commit() # ========================================== # 🟢 模式 A:本地执行逻辑 # ========================================== @staticmethod async def _execute_locally(task_id, dataset_id, task, template, exec_id): """本地直接运行 DataX""" logger.info(f"[Local Mode] Executing DataX for task {task_id}") async with AsyncSessionLocal() as session: exec_record = await session.get(TaskExecution, exec_id) # DataX 运行是阻塞的,放在线程池中运行 datax_client = DataxClient(task=task, execution=exec_record, template=template) await asyncio.to_thread(datax_client.run_datax_job) # 同步 Task 状态 task.status = exec_record.status session.add(task) if exec_record.status == TaskStatus.COMPLETED.name: await CollectionTaskService._finalize_task(session, task_id, dataset_id, task) await session.commit() # ========================================== # 🔵 模式 B:Runtime -> Ray 执行逻辑 # ========================================== @staticmethod async def _execute_via_runtime(task_id, dataset_id, task, template, task_execution): """ 生成配置并提交给 Runtime。 不需要 session,因为 task_execution 对象已包含所有必要信息。 """ logger.info(f"[Runtime Mode] Submitting task {task_id} to deepdata-runtime") # ✅ 关键步骤:提取 execution_id,这是日志文件名的关键 execution_id = task_execution.id log_path = task_execution.log_path # 1. 生成 DataX JSON 配置 datax_client = DataxClient(task=task, execution=_TempExecution(task_id), template=template) datax_json_path = datax_client.generate_config() # 2. 构造 process.yaml 指令文件 flow_dir = Path(f"/flow/{task_id}") flow_dir.mkdir(parents=True, exist_ok=True) process_cfg = { "executor_type": "deepdata", "instance_id": task_id, "execution_id": execution_id, "log_path": log_path, "dataset_id": dataset_id, "cli_config": { "command": "python", "args": ["/opt/datax/bin/datax.py", datax_json_path], "timeout": 7200 } } # 写入指令文件 with open(flow_dir / "process.yaml", "w", encoding="utf-8") as f: yaml.safe_dump(process_cfg, f) # 3. 提交给 Runtime runtime_client = RuntimeClient() success = await runtime_client.submit_task(task_id) if not success: raise RuntimeError("Runtime submission returned False") @staticmethod async def _poll_runtime_status(task_id, dataset_id, exec_id): """后台轮询 Runtime 状态""" runtime_client = RuntimeClient() for _ in range(2000): try: status_info = await runtime_client.get_task_status(task_id) if status_info: current_status = (status_info.get("status") or "").upper() async with AsyncSessionLocal() as session: exec_record = await session.get(TaskExecution, exec_id) if not exec_record: return task_result = await session.execute(select(CollectionTask).where(CollectionTask.id == task_id)) task = task_result.scalar_one_or_none() if current_status in ["COMPLETED", "SUCCEEDED", "SUCCESS"]: exec_record.status = TaskStatus.COMPLETED.name await session.commit() logger.info(f"Task {task_id} completed in Runtime. Finalizing.") if task: await CollectionTaskService._finalize_task(session, task_id, dataset_id, task) return elif current_status in ["FAILED", "ERROR"]: exec_record.status = TaskStatus.FAILED.name exec_record.error_message = status_info.get("error", "Runtime task failed") await session.commit() logger.error(f"Task {task_id} failed in Runtime: {exec_record.error_message}") return await asyncio.sleep(5) except Exception as e: logger.warning(f"Polling error for {task_id}: {e}") await asyncio.sleep(5) # ========================================== # 🛠️ 公共收尾逻辑 # ========================================== @staticmethod async def _finalize_task(session, task_id, dataset_id, task): """任务执行成功后的后续处理""" if not dataset_id: return # 🐛 修复:对于 S3/远端写入器,本地 target_path 可能不存在 # 应当检查 task.target_path 是否为本地路径,或者跳过本地扫描逻辑 target_path = Path(task.target_path) if target_path.exists() and target_path.is_dir(): service = Service(db=session) source_paths = [] for file_path in target_path.rglob('*'): if file_path.is_file(): source_paths.append(str(file_path.absolute())) if source_paths: await service.add_files_to_dataset(dataset_id=dataset_id, source_paths=source_paths) await CollectionTaskService._add_dataset_to_graph(session, dataset_id, task) else: logger.warning(f"[Finalize] Target path {target_path} does not exist locally. " f"Skipping dataset import. (This is expected for s3writer/nfswriter)") @staticmethod async def _add_dataset_to_graph(session, dataset_id, task): """将数据集加入血缘图""" try: dataset = await session.get(Dataset, dataset_id) if not dataset: logger.warning(f"dataset {dataset_id} not found when building lineage graph") return dataset_node = LineageNode( id=dataset.id, node_type=NodeType.DATASET.value, name=dataset.name, description=dataset.description ) collection_node = LineageNode( id=task.id, node_type=NodeType.DATASOURCE.value, name=task.name, description=task.description ) collection_edge = LineageEdge( process_id=task.id, name=task.name, edge_type=EdgeType.DATA_COLLECTION.value, description=dataset.description, from_node_id=task.id, to_node_id=dataset.id ) lineage_service = LineageService(db=session) await lineage_service.generate_graph(collection_node, collection_edge, dataset_node) await session.commit() except Exception as exc: logger.error(f"Failed to add dataset lineage graph: {exc}") await session.rollback()