from datetime import datetime from typing import List, Optional, Dict, Any, Tuple import random import json import os import shutil import asyncio from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.logging import get_logger from app.core.config import settings from app.db.models.base_entity import LineageNode, LineageEdge from app.db.models.ratio_task import RatioInstance, RatioRelation from app.db.models import Dataset, DatasetFiles from app.db.session import AsyncSessionLocal from app.module.dataset.schema.dataset_file import DatasetFileTag from app.module.shared.common.lineage import LineageService from app.module.shared.schema import TaskStatus, NodeType, EdgeType from app.module.ratio.schema.ratio_task import FilterCondition from app.module.shared.util.resource_utils import get_concurrent_for_ratio_copy, get_system_resource_info logger = get_logger(__name__) class RatioTaskService: """Service for Ratio Task DB operations.""" def __init__(self, db: AsyncSession): self.db = db async def create_task( self, *, name: str, description: Optional[str], totals: int, config: List[Dict[str, Any]], target_dataset_id: Optional[str] = None, ) -> RatioInstance: """Create a ratio task instance and its relations. config item format: {"dataset_id": str, "counts": int, "filter_conditions": str} """ logger.info(f"Creating ratio task: name={name}, totals={totals}, items={len(config or [])}") instance = RatioInstance( name=name, description=description, totals=totals, target_dataset_id=target_dataset_id, status="PENDING", ) self.db.add(instance) await self.db.flush() # populate instance.id for item in config or []: relation = RatioRelation( ratio_instance_id=instance.id, source_dataset_id=item.get("dataset_id"), counts=int(item.get("counts", 0)), filter_conditions=json.dumps({ 'date_range': item.get("filter_conditions").date_range, 'label': { "label":item.get("filter_conditions").label.label, "value":item.get("filter_conditions").label.value, } if item.get("filter_conditions").label else None, }) ) logger.info(f"Relation created: {relation.id}, {relation}, {item}, {config}") self.db.add(relation) await self.db.commit() await self.db.refresh(instance) logger.info(f"Ratio task created: {instance.id}") return instance # ========================= Execution (Background) ========================= # @staticmethod async def execute_dataset_ratio_task(instance_id: str) -> None: async with AsyncSessionLocal() as session: try: inst_res = await session.execute(select(RatioInstance).where(RatioInstance.id == instance_id)) instance: Optional[RatioInstance] = inst_res.scalar_one_or_none() if not instance: logger.error(f"Ratio instance not found: {instance_id}") return logger.info(f"Starting ratio task {instance_id}") logger.info(f"System resources: {get_system_resource_info()}") rel_res = await session.execute( select(RatioRelation).where(RatioRelation.ratio_instance_id == instance_id) ) relations: List[RatioRelation] = list(rel_res.scalars().all()) instance.status = TaskStatus.RUNNING.name ds_res = await session.execute(select(Dataset).where(Dataset.id == instance.target_dataset_id)) target_ds: Optional[Dataset] = ds_res.scalar_one_or_none() if not target_ds: logger.error(f"Target dataset not found for instance {instance_id}") instance.status = TaskStatus.FAILED.name return max_concurrent = get_concurrent_for_ratio_copy(settings) logger.info(f"Using {max_concurrent} concurrent workers for ratio task {instance_id}") added_count, added_size = await RatioTaskService.handle_ratio_relations_parallel( relations, session, target_ds, max_concurrent ) target_ds.file_count = (target_ds.file_count or 0) + added_count target_ds.size_bytes = (target_ds.size_bytes or 0) + added_size if (target_ds.file_count or 0) > 0: target_ds.status = "ACTIVE" instance.status = TaskStatus.COMPLETED.name logger.info(f"Ratio task completed: {instance_id}, files={added_count}, size={added_size}") await RatioTaskService._add_task_to_graph( session=session, src_relations=relations, task=instance, dst_dataset=target_ds, ) except Exception as e: logger.exception(f"Ratio task failed for {instance_id}: {e}") try: inst_res = await session.execute(select(RatioInstance).where(RatioInstance.id == instance_id)) instance = inst_res.scalar_one_or_none() if instance: instance.status = TaskStatus.FAILED.name finally: pass finally: await session.commit() @staticmethod async def handle_ratio_relations_parallel( relations: list[RatioRelation], session: AsyncSession, target_ds: Dataset, max_concurrent: int = 10 ) -> Tuple[int, int]: existing_path_rows = await session.execute( select(DatasetFiles.file_path).where(DatasetFiles.dataset_id == target_ds.id) ) existing_paths = set(p for p in existing_path_rows.scalars().all() if p) source_paths = set() all_copy_tasks: List[Tuple[DatasetFiles, str, str]] = [] for rel in relations: if not rel.source_dataset_id or not rel.counts or rel.counts <= 0: continue files = await RatioTaskService.get_files(rel, session) if not files: continue pick_n = min(rel.counts or 0, len(files)) chosen = random.sample(files, pick_n) if pick_n < len(files) else files for file in chosen: if file.file_path in source_paths: continue dst_prefix = f"/dataset/{target_ds.id}/" file_name = RatioTaskService.get_new_file_name(dst_prefix, existing_paths, file) new_path = dst_prefix + file_name file_record = DatasetFiles( dataset_id=target_ds.id, file_name=file_name, file_path=new_path, file_type=file.file_type, file_size=file.file_size, check_sum=file.check_sum, tags=file.tags, tags_updated_at=datetime.now(), dataset_filemetadata=file.dataset_filemetadata, status="ACTIVE", ) all_copy_tasks.append((file_record, file.file_path, new_path)) existing_paths.add(new_path) source_paths.add(file.file_path) if not all_copy_tasks: return 0, 0 dst_dir = f"/dataset/{target_ds.id}/" await asyncio.to_thread(os.makedirs, dst_dir, exist_ok=True) semaphore = asyncio.Semaphore(max_concurrent) successful_records: List[DatasetFiles] = [] added_count = 0 added_size = 0 async def copy_with_semaphore( file_record: DatasetFiles, src_path: str, dst_path: str ) -> Tuple[bool, DatasetFiles]: async with semaphore: try: file_dst_dir = os.path.dirname(dst_path) if file_dst_dir != dst_dir: await asyncio.to_thread(os.makedirs, file_dst_dir, exist_ok=True) try: await asyncio.to_thread(os.link, src_path, dst_path) except OSError: try: await asyncio.to_thread(os.symlink, src_path, dst_path) except OSError: await asyncio.to_thread(shutil.copy2, src_path, dst_path) return True, file_record except Exception as e: logger.error(f"Copy failed: {src_path} -> {dst_path}: {e}") return False, file_record tasks = [copy_with_semaphore(rec, src, dst) for rec, src, dst in all_copy_tasks] results = await asyncio.gather(*tasks, return_exceptions=True) for i, result in enumerate(results): if isinstance(result, Exception): logger.error(f"Copy task {i} raised exception: {result}") continue success, file_record = result if success: added_count += 1 added_size += int(file_record.file_size or 0) successful_records.append(file_record) if successful_records: session.add_all(successful_records) await session.flush() logger.info( f"Parallel copy completed: {added_count}/{len(all_copy_tasks)} files, " f"{added_size} bytes, {len(results) - added_count} failures" ) return added_count, added_size @staticmethod async def handle_ratio_relations(relations: list[RatioRelation], session, target_ds: Dataset) -> tuple[int, int]: # Preload existing target file paths for deduplication existing_path_rows = await session.execute( select(DatasetFiles.file_path).where(DatasetFiles.dataset_id == target_ds.id) ) existing_paths = set(p for p in existing_path_rows.scalars().all() if p) source_paths = set() added_count = 0 added_size = 0 for rel in relations: if not rel.source_dataset_id or not rel.counts or rel.counts <= 0: continue files = await RatioTaskService.get_files(rel, session) if not files: continue pick_n = min(rel.counts or 0, len(files)) chosen = random.sample(files, pick_n) if pick_n < len(files) else files # Copy into target dataset with de-dup by target path for file in chosen: if file.file_path in source_paths: continue await RatioTaskService.handle_selected_file(existing_paths, file, session, target_ds) source_paths.add(file.file_path) added_count += 1 added_size += int(file.file_size or 0) # Periodically flush to avoid huge transactions await session.flush() return added_count, added_size @staticmethod async def handle_selected_file(existing_paths: set[Any], f, session, target_ds: Dataset): src_path = f.file_path dst_prefix = f"/dataset/{target_ds.id}/" file_name = RatioTaskService.get_new_file_name(dst_prefix, existing_paths, f) new_path = dst_prefix + file_name dst_dir = os.path.dirname(new_path) await asyncio.to_thread(os.makedirs, dst_dir, exist_ok=True) await asyncio.to_thread(shutil.copy2, src_path, new_path) file_data = { "dataset_id": target_ds.id, # type: ignore "file_name": file_name, "file_path": new_path, "file_type": f.file_type, "file_size": f.file_size, "check_sum": f.check_sum, "tags": f.tags, "tags_updated_at": datetime.now(), "dataset_filemetadata": f.dataset_filemetadata, "status": "ACTIVE", } file_record = {k: v for k, v in file_data.items() if v is not None} session.add(DatasetFiles(**file_record)) existing_paths.add(new_path) @staticmethod def get_new_file_name(dst_prefix: str, existing_paths: set[Any], f) -> str: file_name = f.file_name new_path = dst_prefix + file_name # Handle file path conflicts by appending a number to the filename if new_path in existing_paths: file_name_base, file_ext = os.path.splitext(file_name) counter = 1 original_file_name = file_name while new_path in existing_paths: file_name = f"{file_name_base}_{counter}{file_ext}" new_path = f"{dst_prefix}{file_name}" counter += 1 if counter > 1000: # Safety check to prevent infinite loops logger.error(f"Could not find unique filename for {original_file_name} after 1000 attempts") break return file_name @staticmethod async def get_files(rel: RatioRelation, session) -> list[Any]: # Fetch all files for the source dataset (ACTIVE only) files_res = await session.execute( select(DatasetFiles).where( DatasetFiles.dataset_id == rel.source_dataset_id, DatasetFiles.status == "ACTIVE", ) ) files = list(files_res.scalars().all()) # TAG mode: filter by tags according to relation.filter_conditions conditions = RatioTaskService._parse_conditions(rel.filter_conditions) if conditions: files = [f for f in files if RatioTaskService._filter_file(f, conditions)] return files # ------------------------- helpers for TAG filtering ------------------------- # @staticmethod def _parse_conditions(conditions: Optional[str]) -> Optional[FilterCondition]: """Parse filter_conditions JSON string into a FilterCondition object. Args: conditions: JSON string containing filter conditions Returns: FilterCondition object if conditions is not None/empty, otherwise None """ if not conditions: return None try: data = json.loads(conditions) return FilterCondition(**data) except json.JSONDecodeError as e: logger.error(f"Failed to parse filter conditions: {e}") return None except Exception as e: logger.error(f"Error creating FilterCondition: {e}") return None @staticmethod def _filter_file(file: DatasetFiles, conditions: FilterCondition) -> bool: if not conditions: return True logger.info(f"start filter file: {file}, conditions: {conditions}") # Check data range condition if provided if conditions.date_range and len(conditions.date_range) == 2: try: from datetime import datetime, timedelta start_at = datetime.fromisoformat(conditions.date_range[0]) end_at = datetime.fromisoformat(conditions.date_range[1]) if file.tags_updated_at and (file.tags_updated_at < start_at or file.tags_updated_at > end_at): return False except (ValueError, TypeError) as e: logger.warning(f"Invalid data_range value: {conditions.date_range}", e) return False # Check label condition if provided if conditions.label: tags = file.tags if not tags: return False try: # tags could be a list of strings or list of objects with 'name' all_tags = RatioTaskService.get_all_tags(tags) for tag in all_tags: if conditions.label.label and tag.get("label") != conditions.label.label: continue if conditions.label.value is None or len(conditions.label.value) == 0: return True if tag.get("value") == conditions.label.value: return True return False except Exception as e: logger.exception(f"Failed to get tags for {file}", e) return False return True @staticmethod def get_all_tags(tags) -> list[dict]: """获取所有处理后的标签字符串列表""" all_tags = list() if not tags: return all_tags file_tags = [] for tag_data in tags: # 处理可能的命名风格转换(下划线转驼峰) processed_data = {} for key, value in tag_data.items(): # 将驼峰转为下划线以匹配 Pydantic 模型字段 processed_data[key] = value # 创建 DatasetFileTag 对象 file_tag = DatasetFileTag(**processed_data) file_tags.append(file_tag) for file_tag in file_tags: for tag_data in file_tag.get_tags(): all_tags.append(tag_data) return all_tags @staticmethod async def _add_task_to_graph( session: AsyncSession, src_relations: List[RatioRelation], task: RatioInstance, dst_dataset: Dataset, ) -> None: """ 在比例抽取任务完成后,将数据集加入血缘图。 ratio_task(DATASOURCE) -> dataset(DATASET) via DATA_RATIO edge """ try: if not src_relations: logger.warning("Source ratio relations is empty when building lineage graph") return lineage_service = LineageService(db=session) dst_node = LineageNode( id=dst_dataset.id, node_type=NodeType.DATASET.value, name=dst_dataset.name, description=dst_dataset.description, ) for rel in src_relations: ds: Optional[Dataset] = await session.get(Dataset, rel.source_dataset_id) src_node = LineageNode( id=rel.source_dataset_id, node_type=NodeType.DATASET.value, name=ds.name, description=ds.description, ) ratio_edge = LineageEdge( process_id=task.id, name=task.name, edge_type=EdgeType.DATA_RATIO.value, description=task.description, from_node_id=rel.source_dataset_id, to_node_id=dst_node.id, ) await lineage_service.generate_graph(src_node, ratio_edge, dst_node) logger.info("Add dataset lineage graph: %s -> %s -> %s", src_node.id, ratio_edge.id, dst_node.id) await session.commit() logger.info("Add dataset lineage graph success") except Exception as exc: logger.error("Failed to add dataset lineage graph: %s", exc) await session.rollback()