feat(deepdata): 引入修改密码、文件预览组件与清洗算子增强,deepflow 改名为 deep-flow

本次同步 09-12 至 09-28 的代码变更,主要内容:

新增功能
- 修改密码:后端新增 ChangePasswordRequest DTO 与UserController/UserService
  对应接口,前端新增 ChangePasswordDialog 并在 Header 接入入口
- 文件预览组件重构:file-preview 目录拆分出 VideoPreview(支持 blobUrl 直传)、
  ImagePreview、AudioPreview,index.tsx 统一调度各类型预览器
- 新增 my_ops 算子模块:markitdown_parser、mineru_pdf_parser、text_quality_evaluator
- 新增 tools/minio-file-server MinIO 文件服务

功能增强
- 数据清洗:cleaning_task_service/scheduler/routes 重构,
  data_juicer_executor 与 wrapper 增强,OperatorTable/LogsTable/TaskDetail 同步调整
- 数据标注:annotation sync.py、project.py 接口与schema 更新
- 数据采集、算子市场、首页与菜单路由相关页面调整
- DataJuicer 算子接入(useOperatorOperations、dj_ops_schema)

结构调整
- deepflow/ 更名为 deep-flow/(419 个文件重命名)
- 新增 helm 部署编排、GitHub Actions 之外的自建镜像构建脚本

未纳入本次提交
- scripts-backup/、runtime/python-executor-backup/、components_backup/(备份目录)
- *.backup*、__pycache__ 产物
- zh_core_web_sm-3.8.0-py3-none-any.whl(46MB 构建产物)
- DataX/(内嵌独立仓库,远程亦未收录其源码)
This commit is contained in:
沃巴蒂
2026-10-03 22:44:56 +08:00
parent cbb1048e18
commit ff22ed3a8b
1156 changed files with 129912 additions and 21716 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ LABEL_STUDIO_BASE_URL=http://localhost:30001
LABEL_STUDIO_USER_TOKEN="demo_dev_token"
DEEPDATA_JWT_ENABLE=false
DEEPDATA_jwt_enable=false
# Milvus settings (Vector Database)
# Development: use localhost
+107 -107
View File
@@ -1,107 +1,107 @@
# DeepData Python Service (DeepData)
这是 DeepData 的 Python 服务,负责DeepData的数据合成、数据标注、数据评估等功能。
## 简要说明
- 框架:FastAPI
- 异步数据库/ORM:SQLAlchemy (async)
- 数据库迁移:Alembic
- 运行器:uvicorn
## 快速开始(开发)
### 前置条件
- Python 3.11+
- poetry 包管理器
1. 克隆仓库
```bash
git clone https://gitea.apescale.com/figmar/deepdata.git
```
2. 进入项目目录
```bash
cd runtime/deepdata-python
```
3. 安装依赖
由于项目使用poetry管理依赖,你可以使用以下命令安装::
git clone https://gitea.apescale.com/figmar/deepdata.git
poetry install
```
或者直接使用pip安装(如果poetry不可用):
git clone https://gitea.apescale.com/figmar/deepdata.git
pip install -e .
```
4. 配置环境变量
复制环境变量示例文件并配置:
git clone https://gitea.apescale.com/figmar/deepdata.git
cp .env.example .env
```
编辑.env文件,设置必要的环境变量,如数据库连接、Label Studio配置等。
5. 数据库迁移(开发环境):
git clone https://gitea.apescale.com/figmar/deepdata.git
alembic upgrade head
```
6. 启动开发服务器(示例与常用参数):
- 本地开发(默认 host/port,自动重载):
git clone https://gitea.apescale.com/figmar/deepdata.git
set -a && source .env && set +a && poetry run uvicorn app.main:app --port 18000 --log-level debug --reload
```
或者
git clone https://gitea.apescale.com/figmar/deepdata.git
poetry run python -m app.main
```
- 指定主机与端口并打开调试日志:
git clone https://gitea.apescale.com/figmar/deepdata.git
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --log-level debug
```
- 在生产环境使用多个 worker(不使用 --reload):
git clone https://gitea.apescale.com/figmar/deepdata.git
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 --log-level info --proxy-headers
```
- 使用环境变量启动(示例):
git clone https://gitea.apescale.com/figmar/deepdata.git
HOST=0.0.0.0 PORT=8000 uvicorn app.main:app --reload
```
注意:
- `--reload` 仅用于开发,会监视文件变化并重启进程;不要在生产中使用。
- `--workers` 提供并发处理能力,但会增加内存占用;生产时通常配合进程管理或容器编排(Kubernetes)使用。
- 若需要完整的生产部署建议使用 ASGI 服务器(如 gunicorn + uvicorn workers / 或直接使用 uvicorn 在容器中配合进程管理)。
访问 API 文档:
- Swagger UI: http://127.0.0.1:8000/docs
- ReDoc: http://127.0.0.1:8000/redoc (推荐使用)
## 开发新功能
- 安装开发依赖:
git clone https://gitea.apescale.com/figmar/deepdata.git
poetry add xxx
```
## 使用(简要)
- 所有 API 路径以 `/api` 前缀注册(见 `app/main.py` 中 `app.include_router(api_router, prefix="/api")`)。
- 根路径 `/` 返回服务信息和文档链接。
更多细节请查看 `doc/usage.md`(接口使用)和 `doc/development.md`(开发说明)。
# DeepData Python Service (DeepData)
这是 DeepData 的 Python 服务,负责DeepData的数据合成、数据标注、数据评估等功能。
## 简要说明
- 框架:FastAPI
- 异步数据库/ORM:SQLAlchemy (async)
- 数据库迁移:Alembic
- 运行器:uvicorn
## 快速开始(开发)
### 前置条件
- Python 3.11+
- poetry 包管理器
1. 克隆仓库
```bash
git clone git@github.com:ModelEngine-Group/DeepData.git
```
2. 进入项目目录
```bash
cd runtime/deepdata-python
```
3. 安装依赖
由于项目使用poetry管理依赖,你可以使用以下命令安装::
```bash
poetry install
```
或者直接使用pip安装(如果poetry不可用):
```bash
pip install -e .
```
4. 配置环境变量
复制环境变量示例文件并配置:
```bash
cp .env.example .env
```
编辑.env文件,设置必要的环境变量,如数据库连接、Label Studio配置等。
5. 数据库迁移(开发环境):
```bash
alembic upgrade head
```
6. 启动开发服务器(示例与常用参数):
- 本地开发(默认 host/port,自动重载):
```bash
set -a && source .env && set +a && poetry run uvicorn app.main:app --port 18000 --log-level debug --reload
```
或者
```bash
poetry run python -m app.main
```
- 指定主机与端口并打开调试日志:
```bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --log-level debug
```
- 在生产环境使用多个 worker(不使用 --reload):
```bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 --log-level info --proxy-headers
```
- 使用环境变量启动(示例):
```bash
HOST=0.0.0.0 PORT=8000 uvicorn app.main:app --reload
```
注意:
- `--reload` 仅用于开发,会监视文件变化并重启进程;不要在生产中使用。
- `--workers` 提供并发处理能力,但会增加内存占用;生产时通常配合进程管理或容器编排(Kubernetes)使用。
- 若需要完整的生产部署建议使用 ASGI 服务器(如 gunicorn + uvicorn workers / 或直接使用 uvicorn 在容器中配合进程管理)。
访问 API 文档:
- Swagger UI: http://127.0.0.1:8000/docs
- ReDoc: http://127.0.0.1:8000/redoc (推荐使用)
## 开发新功能
- 安装开发依赖:
```bash
poetry add xxx
```
## 使用(简要)
- 所有 API 路径以 `/api` 前缀注册(见 `app/main.py` 中 `app.include_router(api_router, prefix="/api")`)。
- 根路径 `/` 返回服务信息和文档链接。
更多细节请查看 `doc/usage.md`(接口使用)和 `doc/development.md`(开发说明)。
+2 -2
View File
@@ -1,4 +1,4 @@
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings
from pydantic import model_validator
from typing import Optional
@@ -83,7 +83,7 @@ class Settings(BaseSettings):
# DeepData
dm_file_path_prefix: str = "/dataset" # DM存储文件夹前缀
deepdata_jwt_enable: bool = False
DEEPDATA_jwt_enable: bool = False
# Milvus 配置
milvus_uri: str = "http://milvus:19530"
@@ -80,9 +80,13 @@ class BaseError(Exception):
def to_dict(self) -> dict:
"""将异常转换为响应字典"""
msg = self.message
if self.data and isinstance(self.data, str):
msg = f"{msg},detail:{self.data}"
return {
"code": self.code,
"message": self.message,
"message": msg, #self.message,
"data": self.data
}
+3 -3
View File
@@ -1,4 +1,4 @@
import logging
import logging
import re
import sys
from logging.handlers import RotatingFileHandler
@@ -49,13 +49,13 @@ def _rotated_log_namer(default_name: str) -> str:
def _remove_managed_handlers(logger: logging.Logger) -> None:
for handler in list(logger.handlers):
if getattr(handler, "_deepdata_managed", False):
if getattr(handler, "_DeepData_managed", False):
logger.removeHandler(handler)
handler.close()
def _mark_managed(handler: logging.Handler) -> logging.Handler:
setattr(handler, "_deepdata_managed", True)
setattr(handler, "_DeepData_managed", True)
return handler
@@ -5,6 +5,7 @@ from app.db.models.base_entity import BaseEntity, Base
class CleaningTask(BaseEntity):
"""Data cleaning task entity"""
__tablename__ = "t_clean_task"
__ignore_data_scope__ = True
id = Column(String(36), primary_key=True, comment="Task ID")
name = Column(String(255), nullable=False, comment="Task name")
+2 -2
View File
@@ -1,4 +1,4 @@
"""
"""
Application middleware.
Note: Exception handling has been moved to app.core.exception.ExceptionHandlingMiddleware
@@ -26,7 +26,7 @@ class UserContextMiddleware(BaseHTTPMiddleware):
def __init__(self, app):
super().__init__(app)
self.jwt_enable = settings.deepdata_jwt_enable
self.jwt_enable = settings.DEEPDATA_jwt_enable
async def dispatch(self, request: Request, call_next):
user: Optional[str] = request.headers.get("User")
@@ -1,4 +1,4 @@
from typing import Optional
from typing import Optional
import math
import uuid
@@ -47,7 +47,7 @@ async def login_label_studio_project(
- 不负责跳转
"""
user = request.headers.get("User") if request else None
if settings.deepdata_jwt_enable and (not user or not user.strip()):
if settings.DEEPDATA_jwt_enable and (not user or not user.strip()):
logger.warning(
f"Unauthorized access attempt to annotation login: mapping_id={mapping_id}"
)
@@ -113,7 +113,7 @@ async def login_label_studio_project(
"""登录 Label Studio 项目 — 需要认证且校验项目归属"""
# 检查用户认证(仅在 JWT 启用时强制要求)
user = request.headers.get("User") if request else None
if settings.deepdata_jwt_enable and (not user or not user.strip()):
if settings.DEEPDATA_jwt_enable and (not user or not user.strip()):
logger.warning(f"Unauthorized access attempt to annotation login: mapping_id={mapping_id}")
raise HTTPException(status_code=401, detail="Authentication required")
@@ -1,4 +1,4 @@
from typing import Literal, List, Dict, Any, Optional
from typing import Literal, List, Dict, Any, Optional
from datetime import datetime
from pydantic import Field, ConfigDict
@@ -1,4 +1,4 @@
from typing import Optional, List, Dict, Any, Tuple, Set
from typing import Optional, List, Dict, Any, Tuple, Set
import json
import os
File diff suppressed because it is too large Load Diff
@@ -42,6 +42,7 @@ class RuntimeClient:
logger.error(f"Unexpected error stopping task {task_id}: {e}")
return False
'''
async def get_task_status(self, task_id: str) -> Optional[dict]:
"""Get task status from runtime"""
try:
@@ -55,6 +56,48 @@ class RuntimeClient:
except Exception as e:
logger.error(f"Unexpected error getting task status {task_id}: {e}")
return None
'''
async def get_task_status(self, task_id: str) -> Optional[dict]:
"""Get task status from runtime"""
url = f"{self.base_url}/api/task/{task_id}/status"
try:
logger.info(f"[RuntimeClient] Requesting status for task {task_id} from {url}")
response = await self.client.get(url)
# [调试] 打印 HTTP 状态码
logger.info(f"[RuntimeClient] Response status: {response.status_code}")
# 尝试读取响应体,无论状态码是多少
try:
resp_json = response.json()
logger.info(f"[RuntimeClient] Response body: {resp_json}")
# 如果 HTTP 状态码不是 2xx,我们也打印出来,但不抛异常(视具体业务逻辑而定)
# 这里保持原逻辑:raise_for_status
response.raise_for_status()
return resp_json
except httpx.HTTPError as e:
logger.error(f"[RuntimeClient] HTTP Error: {e}")
logger.debug(f"[RuntimeClient] Response text: {response.text}")
return None
except json.JSONDecodeError as e:
logger.error(f"[RuntimeClient] JSON Decode Error: {e}")
logger.debug(f"[RuntimeClient] Raw response: {response.text}")
return None
except httpx.ConnectError as e:
# [调试] 区分连接超时和连接被拒绝
logger.error(f"[RuntimeClient] Connect Error: {e}")
return None
except httpx.TimeoutException as e:
# [调试] 明确记录超时
logger.error(f"[RuntimeClient] Timeout waiting for status of {task_id}: {e}")
return None
except Exception as e:
logger.error(f"[RuntimeClient] Unexpected error: {e}")
return None
async def close(self):
"""Close HTTP client"""
@@ -21,7 +21,7 @@ class OperatorInstanceDto(BaseResponseModel):
inputs: Optional[str] = Field(None, description="Input types: text/image/audio/video/multimodal")
outputs: Optional[str] = Field(None, description="Output types: text/image/audio/video/multimodal")
categories: Optional[List[str]] = Field(None, description="Category IDs")
settings: Optional[str] = Field(None, description="算子设置(JSON)")
settings: Optional[Any] = Field(None, description="算子设置(JSON)")
overrides: Dict[str, Any] = Field(default_factory=dict, description="Operator parameter overrides")
@@ -6,12 +6,10 @@ from app.module.cleaning.runtime_client import RuntimeClient
logger = get_logger(__name__)
# 模块级单例:进程内所有请求共享同一个调度器
# 模块级单例
_scheduler: "CleaningTaskScheduler | None" = None
def get_scheduler() -> "CleaningTaskScheduler":
"""获取全局调度器单例,不存在则创建"""
global _scheduler
if _scheduler is None:
_scheduler = CleaningTaskScheduler(
@@ -20,23 +18,22 @@ def get_scheduler() -> "CleaningTaskScheduler":
)
return _scheduler
class CleaningTaskScheduler:
"""Scheduler for executing cleaning tasks"""
def __init__(self, task_repo: CleaningTaskRepository, runtime_client: RuntimeClient):
self.task_repo = task_repo
self.runtime_client = runtime_client
self._polling_task_ids: set[str] = set() # 待轮询的任务 ID 集合
self._polling_task_ids: set[str] = set()
self._polling_started: bool = False
self._poll_failure_count: dict[str, int] = {} # runtime 不可达连续失败计数
self._MAX_POLL_FAILURES = 1 # 一次超时(60s) → 标记 FAILED
self._poll_failure_count: dict[str, int] = {}
self._not_found_counts: dict[str, int] = {} # 🔴 新增:记录 NOT_FOUND 次数
self._MAX_POLL_FAILURES = 1
async def execute_task(self, db: AsyncSession, task_id: str, retry_count: int) -> bool:
"""Execute cleaning task"""
from app.module.cleaning.schema import CleaningTaskDto, CleaningTaskStatus
from datetime import datetime
logger.info(f"🚀 [Scheduler] 任务 {task_id} 开始执行...")
task = CleaningTaskDto()
task.id = task_id
task.status = CleaningTaskStatus.RUNNING
@@ -53,108 +50,130 @@ class CleaningTaskScheduler:
task.status = CleaningTaskStatus.FAILED
task.finished_at = datetime.now()
await self.task_repo.update_task(db, task)
return submitted
async def stop_task(self, db: AsyncSession, task_id: str) -> bool:
"""Stop cleaning task"""
from app.module.cleaning.schema import CleaningTaskDto, CleaningTaskStatus
self._polling_task_ids.discard(task_id)
self._poll_failure_count.pop(task_id, None)
self._not_found_counts.pop(task_id, None) # 🔴 清理计数器
await self.runtime_client.stop_task(task_id)
task = CleaningTaskDto()
task.id = task_id
task.status = CleaningTaskStatus.STOPPED
await self.task_repo.update_task(db, task)
return True
async def startup(self):
"""进程启动时调用:恢复未完成任务的轮询 + 启动全局轮询协程"""
from app.module.cleaning.schema import CleaningTaskStatus
from app.db.session import AsyncSessionLocal
# 从数据库恢复所有 RUNNING 状态的任务
try:
async with AsyncSessionLocal() as db:
tasks = await self.task_repo.find_tasks(db, status=CleaningTaskStatus.RUNNING)
for task in tasks:
logger.info(f"[Polling] Recovered RUNNING task from DB: {task.id}")
logger.info(f"[Polling] Recovered RUNNING task: {task.id}")
self._polling_task_ids.add(task.id)
except Exception as e:
logger.error(f"[Polling] Failed to recover tasks from DB: {e}")
logger.error(f"[Polling] Failed to recover tasks: {e}")
# 启动全局轮询协程
if not self._polling_started:
self._polling_started = True
asyncio.create_task(self._poll_all_tasks())
logger.info("[Polling] Global polling loop started")
async def _poll_all_tasks(self):
"""全局轮询协程:每 2 秒轮询所有 RUNNING 任务的 runtime 状态"""
from app.module.cleaning.schema import CleaningTaskDto, CleaningTaskStatus
from app.db.session import AsyncSessionLocal
from datetime import datetime
logger.info("[Polling] Global status polling loop started")
terminal_statuses = {"completed", "failed", "cancelled", "stopped", "not_found"}
logger.info("[Polling] Global polling loop started")
terminal_statuses = {"completed", "failed", "cancelled", "stopped"}
while True:
task_ids = list(self._polling_task_ids)
if not task_ids:
# 没有待轮询任务,休眠后继续等
await asyncio.sleep(2)
continue
for task_id in task_ids:
try:
status_data = await self.runtime_client.get_task_status(task_id)
if status_data is None:
# runtime 不可达,累计失败计数
count = self._poll_failure_count.get(task_id, 0) + 1
self._poll_failure_count[task_id] = count
logger.warning(
f"[Polling] Task {task_id} unreachable ({count}/{self._MAX_POLL_FAILURES})"
)
if count >= self._MAX_POLL_FAILURES:
logger.error(
f"[Polling] Task {task_id} marked FAILED: "
f"runtime unreachable after {count} attempts"
)
logger.error(f"[Polling] Task {task_id} FAILED (runtime unreachable)")
async with AsyncSessionLocal() as db:
task_dto = CleaningTaskDto()
task_dto.id = task_id
task_dto.status = CleaningTaskStatus.FAILED
task_dto.finished_at = datetime.now()
await self.task_repo.update_task(db, task_dto)
t = CleaningTaskDto()
t.id = task_id
t.status = CleaningTaskStatus.FAILED
t.finished_at = datetime.now()
await self.task_repo.update_task(db, t)
await db.commit()
self._polling_task_ids.discard(task_id)
self._poll_failure_count.pop(task_id, None)
continue
# runtime 可达,重置失败计数
self._poll_failure_count.pop(task_id, None)
current_status = (status_data.get("status", "") or "").lower()
logger.debug(f"[Polling] Task {task_id} status: {current_status}")
# 🔴 核心修复:处理 NOT_FOUND
if current_status == 'not_found':
cnt = self._not_found_counts.get(task_id, 0) + 1
self._not_found_counts[task_id] = cnt
# 阶段 1:启动容忍期 (10秒内认为是在初始化)
if cnt <= 5:
continue # 继续轮询,不报错
# 阶段 2:检查数据库状态 (判断是真丢失还是已完成)
try:
async with AsyncSessionLocal() as check_db:
db_task = await self.task_repo.find_task_by_id(check_db, task_id)
db_status = db_task.status if db_task else "UNKNOWN"
# ✅ 成功收尾:如果数据库已经是终态,说明任务其实跑完了
# 此时 Runtime 清理了内存状态返回 NOT_FOUND,我们应停止轮询
if db_status in ["COMPLETED", "FAILED", "STOPPED"]:
logger.info(f"[Scheduler] 任务 {task_id} 已结束 (DB: {db_status}),停止轮询。")
self._polling_task_ids.discard(task_id)
self._not_found_counts.pop(task_id, None)
continue
# ✅ 失败收尾:DB 还是 RUNNING,但 Runtime 彻底找不到它了 (>60秒)
if cnt > 30:
logger.error(f"[Scheduler] 任务 {task_id} 在 Runtime 丢失,标记 FAILED。")
t = CleaningTaskDto()
t.id = task_id
t.status = CleaningTaskStatus.FAILED
t.finished_at = datetime.now()
await self.task_repo.update_task(check_db, t)
await check_db.commit()
self._polling_task_ids.discard(task_id)
self._not_found_counts.pop(task_id, None)
except Exception:
pass
continue # 继续等待
# 如果找到了,重置计数器
self._not_found_counts.pop(task_id, None)
# ==========================================
if current_status in terminal_statuses:
logger.info(f"[Scheduler] 任务 {task_id} 结束: {current_status}")
async with AsyncSessionLocal() as db:
task = CleaningTaskDto()
task.id = task_id
if current_status == "completed":
task.status = CleaningTaskStatus.COMPLETED
else:
task.status = CleaningTaskStatus.FAILED
task.finished_at = datetime.now()
await self.task_repo.update_task(db, task)
t = CleaningTaskDto()
t.id = task_id
t.status = CleaningTaskStatus.COMPLETED if current_status == "completed" else CleaningTaskStatus.FAILED
t.finished_at = datetime.now()
await self.task_repo.update_task(db, t)
await db.commit()
logger.info(
f"[Polling] Task {task_id} finished: {current_status}"
)
self._polling_task_ids.discard(task_id)
self._poll_failure_count.pop(task_id, None)
@@ -131,6 +131,7 @@ class CleaningTaskService:
"""Count cleaning tasks using SQL COUNT"""
return await self.task_repo.count_tasks(db, status, keyword)
'''
async def get_task(self, db: AsyncSession, task_id: str) -> CleaningTaskDto:
"""Get task by ID"""
task = await self.task_repo.find_task_by_id(db, task_id)
@@ -168,6 +169,64 @@ class CleaningTaskService:
task.instance.append(OperatorInstanceDto(id=inst.operator_id))
return task
'''
async def get_task(self, db: AsyncSession, task_id: str) -> CleaningTaskDto:
"""Get task by ID"""
task = await self.task_repo.find_task_by_id(db, task_id)
if not task:
raise BusinessError(ErrorCodes.CLEANING_TASK_NOT_FOUND, task_id)
await self._set_process(db, task)
instances = await self.operator_instance_repo.find_operator_by_instance_id(
db, task_id
)
# Batch query operators
all_operators = await self.operator_service.get_operators(
db=db, page=0, size=1000, categories=[], keyword=None, is_star=None
)
operator_map = {op.id: op for op in all_operators}
task.instance = []
for inst in instances:
operator = operator_map.get(inst.operator_id)
if operator:
# 创建 DTO 并填充基本信息
operator_dto = OperatorInstanceDto(
id=operator.id,
name=operator.name,
description=operator.description,
inputs=operator.inputs,
outputs=operator.outputs,
settings=json.loads(operator.settings) if operator.settings else {},
categories=operator.categories,
)
# 🔴 核心修复:解析并应用 overrides
if inst.settings_override:
try:
operator_dto.overrides = json.loads(inst.settings_override)
except (json.JSONDecodeError, TypeError) as e:
logger.error(
f"Failed to parse settings_override for {inst.operator_id}: {e}"
)
task.instance.append(operator_dto)
else:
# 容错:如果找不到算子元数据
operator_dto = OperatorInstanceDto(id=inst.operator_id)
if inst.settings_override:
try:
operator_dto.overrides = json.loads(inst.settings_override)
except (json.JSONDecodeError, TypeError):
pass
task.instance.append(operator_dto)
return task
async def create_task(
self, db: AsyncSession, request: CreateCleaningTaskRequest
@@ -355,6 +414,7 @@ class CleaningTaskService:
logger.error(f"Failed to parse runtime config: {e}")
return {}
'''
async def scan_dataset(
self,
db: AsyncSession,
@@ -399,6 +459,72 @@ class CleaningTaskService:
"metadata": metadata_dict,
}
f.write(json.dumps(file_info, ensure_ascii=False) + "\n")
'''
async def scan_dataset(
self,
db: AsyncSession,
task_id: str,
src_dataset_id: str,
succeed_files: Set[str] | None = None,
) -> None:
"""Scan source dataset and create dataset.jsonl"""
target_file_path = Path(f"{FLOW_PATH}/{task_id}/dataset.jsonl")
target_file_path.parent.mkdir(parents=True, exist_ok=True)
logger.info(f"🔍 [Scan] 开始扫描数据集 {src_dataset_id} ...")
query = text("""
SELECT id, file_name, file_path, file_type, file_size, metadata
FROM t_dm_dataset_files
WHERE dataset_id = :dataset_id
ORDER BY created_at
""")
result = await db.execute(query, {"dataset_id": src_dataset_id})
files = result.fetchall()
logger.info(f"🔥 [Scan] 任务所属数据集: {src_dataset_id}")
logger.info(f"🔥 [Scan] 数据库中文件数: {len(files)}") # 🔴 加这行
total_found = len(files)
skipped_count = 0
written_count = 0
if total_found == 0:
logger.warning("⚠️ [Scan] 数据库中没有找到文件!请检查数据集是否正确上传了文件。")
with open(target_file_path, "w", encoding="utf-8") as f:
for file in files:
# 核心逻辑:如果文件已处理过,则跳过
if succeed_files and file.id in succeed_files:
skipped_count += 1
continue
written_count += 1
metadata_dict = {}
if file.metadata:
try:
parsed = json.loads(file.metadata)
if isinstance(parsed, dict):
metadata_dict = parsed
except (json.JSONDecodeError, TypeError):
pass
file_info = {
"fileId": file.id,
"fileName": file.file_name,
"filePath": file.file_path,
"fileType": file.file_type,
"fileSize": file.file_size,
"metadata": metadata_dict,
}
f.write(json.dumps(file_info, ensure_ascii=False) + "\n")
logger.info(f"✅ [Scan] 扫描完成: 共 {total_found} 个 | 跳过 {skipped_count} 个 | 写入 {written_count} 个。")
if written_count == 0 and total_found > 0:
logger.warning("⚠️ [Scan] 所有文件都被跳过(因为之前已处理成功)。如果修改了参数,请清空历史结果或添加新文件。")
async def get_task_results(
self, db: AsyncSession, task_id: str
@@ -406,17 +532,44 @@ class CleaningTaskService:
"""Get task results"""
return await self.result_repo.find_by_instance_id(db, task_id)
def _resolve_log_path(self, task_id: str, retry_count: int) -> Path | None:
"""
解析日志路径(严格模式):
只查找特定的 output.log 或 output.log.N。
如果文件不存在,返回 None,让调用者(前端或路由)处理等待逻辑。
"""
safe_task_id = _sanitize_task_id(task_id)
flow_root = Path(FLOW_PATH).resolve()
task_dir = flow_root / safe_task_id
# 构建目标文件名
target_name = "output.log" if retry_count == 0 else f"output.log.{retry_count}"
target_path = task_dir / target_name
# 严格检查:存在则返回,不存在则返回 None
if target_path.exists():
return target_path
return None
async def get_task_log(
self, db: AsyncSession, task_id: str, retry_count: int
) -> List[CleaningTaskLog]:
"""Get task log"""
"""Get task log (严格匹配模式,禁止兜底旧日志)"""
safe_task_id = _sanitize_task_id(task_id)
safe_retry_count = _sanitize_retry_count(retry_count)
flow_root = Path(FLOW_PATH).resolve()
log_path = flow_root / safe_task_id / "output.log"
if safe_retry_count > 0:
log_path = flow_root / safe_task_id / f"output.log.{safe_retry_count}"
task_dir = flow_root / safe_task_id
# 1. 严格定位指定的日志文件路径
log_path = task_dir / ("output.log" if safe_retry_count == 0 else f"output.log.{safe_retry_count}")
# 🔴 核心修改:移除 glob 兜底逻辑
# 如果文件不存在,说明 Runtime 尚未生成或正在初始化写入。
# 直接返回空列表,由前端根据 TaskStatus 显示 Loading/等待状态。
if not log_path.exists():
return []
# 防止路径穿越:规范化后校验仍在 FLOW_PATH 下
resolved_log_path = log_path.resolve()
@@ -424,6 +577,7 @@ class CleaningTaskService:
logger.warning(f"Path traversal attempt detected: task_id={task_id}")
return []
# 二次校验(安全冗余)
if not resolved_log_path.exists():
return []
@@ -444,6 +598,7 @@ class CleaningTaskService:
return logs
def _get_log_level(
self, line: str, default_level: str, std_pattern, ex_pattern
) -> str:
@@ -503,9 +658,16 @@ class CleaningTaskService:
async def execute_task(self, db: AsyncSession, task_id: str) -> bool:
"""Execute task"""
logger.info(f"🚀 [Service] 任务 {task_id} 开始执行...")
# 目前是全量扫描源数据集,并不考虑已经处理过的文件
succeed_set = None
'''
succeeded = await self.result_repo.find_by_instance_id(db, task_id, "COMPLETED")
succeed_set = {res.src_file_id for res in succeeded}
logger.info(f"📊 [Execute] 发现 {len(succeed_set)} 个历史成功文件,将采用增量模式(跳过已处理文件)。")
'''
task = await self.task_repo.find_task_by_id(db, task_id)
if not task:
raise BusinessError(ErrorCodes.CLEANING_TASK_NOT_FOUND, task_id)
@@ -516,13 +678,16 @@ class CleaningTaskService:
task.file_count = src_dataset.fileCount
await self.task_repo.update_task(db, task)
await self.scan_dataset(db, task_id, task.src_dataset_id, succeed_set)
await self.scan_dataset(db, task_id, task.src_dataset_id, succeed_files=succeed_set)
await self.result_repo.delete_by_instance_id(db, task_id, "FAILED")
try:
return await self.scheduler.execute_task(
logger.info(f"[Service] Calling scheduler.execute_task for {task_id}, retry: {(task.retry_count or 0) + 1}")
result = await self.scheduler.execute_task(
db, task_id, (task.retry_count or 0) + 1
)
logger.info(f"[Service] Scheduler returned result for {task_id}: {result}")
return result
except Exception as e:
logger.error(
f"execute_task failed, task_id={task_id}, retry_count={(task.retry_count or 0) + 1}: {e}"
@@ -576,3 +741,109 @@ class CleaningTaskService:
result.append(operator_dto)
return result
async def update_task_config(self, db: AsyncSession, task_id: str, data: dict) -> None:
"""
更新任务配置(全量更新算子列表和参数)
"""
# 1. 检查任务是否存在
task = await self.task_repo.find_task_by_id(db, task_id)
if not task:
raise BusinessError(ErrorCodes.CLEANING_TASK_NOT_FOUND, task_id)
# 2. 运行中的任务不可修改
if task.status == CleaningTaskStatus.RUNNING:
raise BusinessError(
ErrorCodes.CLEANING_TASK_STATUS_INVALID,
"任务正在运行中,无法修改配置。请先停止任务。"
)
# 3. 更新任务基本信息 (名称、描述)
if "name" in data:
task.name = data["name"]
if "description" in data:
task.description = data["description"]
# 🔴 修复:删除 task.updated_at = datetime.now()
# 因为 CleaningTaskDto 中没有 updated_at 字段
await self.task_repo.update_task(db, task)
# 4. 更新算子配置 (先删后插)
instances_data = data.get("instance", [])
# 4a. 删除旧的算子关联
await self.operator_instance_repo.delete_by_instance_id(db, task_id)
if instances_data:
# 4b. 准备写入数据
wrapped_instances = []
for d in instances_data:
op_id = d.get("id")
if not op_id:
logger.warning(f"算子 ID 缺失,跳过")
continue
# 确保 overrides 不为 None
overrides = d.get("overrides")
if overrides is None:
overrides = {}
# 创建一个简单的对象适配 Repository 的 insert_instance 方法
class InstanceWrapper:
def __init__(self, oid, over):
self.id = oid
self.overrides = over
wrapped_instances.append(InstanceWrapper(op_id, overrides))
# 写入数据库
if wrapped_instances:
await self.operator_instance_repo.insert_instance(db, task_id, wrapped_instances)
# 5. 重新生成 Process YAML 文件
try:
all_operators = await self.operator_service.get_operators(
db=db, page=0, size=1000, categories=[], keyword=None, is_star=None
)
op_map = {op.id: op for op in all_operators}
enriched_instances = []
for d in instances_data:
op_id = d.get("id")
if not op_id: continue
op = op_map.get(op_id)
if op:
enriched_instances.append(
OperatorInstanceDto(
id=op_id,
name=d.get("name", op.name),
inputs=op.inputs,
outputs=op.outputs,
settings=op.settings,
overrides=d.get("overrides", {}),
categories=op.categories,
)
)
else:
# 容错:如果找不到元数据,使用基础信息
enriched_instances.append(
OperatorInstanceDto(
id=op_id,
name=d.get("name", "Unknown Operator"),
inputs="",
outputs="",
overrides=d.get("overrides", {}),
)
)
executor_type = self.validator.check_and_get_executor_type(enriched_instances)
# 复用已有的 prepare_task 生成 YAML
await self.prepare_task(task.dest_dataset_id, task_id, enriched_instances, op_map, executor_type)
except Exception as e:
logger.error(f"生成 YAML 配置失败: {e}", exc_info=True)
raise BusinessError(ErrorCodes.CLEANING_FILE_SYSTEM_ERROR, f"生成配置文件失败: {str(e)}")
@@ -1,4 +1,4 @@
import re
import re
from sqlalchemy.ext.asyncio import AsyncSession
@@ -1,124 +1,114 @@
import math
import os
from pathlib import Path
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.db.models.data_collection import TaskExecution
from app.db.session import get_db
from app.module.collection.schema.collection import TaskExecutionBase, converter_execution_to_response
from app.module.shared.schema import StandardResponse, PaginatedData
router = APIRouter(
prefix="/executions",
tags=["data-collection/executions"],
)
logger = get_logger(__name__)
@router.get("", response_model=StandardResponse[PaginatedData[TaskExecutionBase]])
async def list_executions(
page: int = 1,
size: int = 20,
task_id: Optional[str] = Query(None, description="Task ID"),
task_name: Optional[str] = Query(None, description="Fuzzy search by task name"),
status: Optional[str] = Query(None, description="Filter by execution status"),
start_time: Optional[datetime] = Query(None, description="Start time range from (started_at >= start_time)"),
end_time: Optional[datetime] = Query(None, description="Start time range to (started_at <= end_time)"),
db: AsyncSession = Depends(get_db)
):
"""分页查询归集任务执行记录"""
try:
query = select(TaskExecution)
if task_id:
query = query.where(TaskExecution.task_id == task_id)
if task_name:
query = query.where(TaskExecution.task_name.ilike(f"%{task_name}%"))
if status:
query = query.where(TaskExecution.status == status)
if start_time:
query = query.where(TaskExecution.started_at >= start_time)
if end_time:
query = query.where(TaskExecution.started_at <= end_time)
count_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_query)).scalar_one()
offset = (page - 1) * size
executions = (await db.execute(
query.order_by(TaskExecution.created_at.desc())
.offset(offset)
.limit(size)
)).scalars().all()
items = [converter_execution_to_response(exe) for exe in executions]
total_pages = math.ceil(total / size) if total > 0 else 0
return StandardResponse(
code="0",
message="Success",
data=PaginatedData(
content=items,
total_elements=total,
total_pages=total_pages,
page=page,
size=size,
)
)
except Exception as e:
logger.error(f"Failed to list task executions: {str(e)}", e)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/{execution_id}/log")
async def get_execution_log(
execution_id: str,
db: AsyncSession = Depends(get_db)
):
"""Get log file content for execution record"""
try:
execution = await db.get(TaskExecution, execution_id)
if not execution:
raise HTTPException(status_code=404, detail="Execution record not found")
log_path = getattr(execution, "log_path", None)
if not log_path:
raise HTTPException(status_code=404, detail="Log path not found")
path = Path(str(log_path))
if not path.is_absolute():
path = Path(os.getcwd()) / path
path = path.resolve()
if not path.exists() or not path.is_file():
raise HTTPException(status_code=404, detail="Log file not found")
filename = path.name
headers = {
"Content-Disposition": f'inline; filename={filename}'
}
return FileResponse(
path=str(path),
media_type="text/plain; charset=utf-8",
filename=filename,
headers=headers,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get execution log: {str(e)}", e)
raise HTTPException(status_code=500, detail="Internal server error")
import math
import os
from pathlib import Path
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse, PlainTextResponse # 👈 核心修复:确保导入了 PlainTextResponse
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.db.models.data_collection import TaskExecution
from app.db.session import get_db
from app.module.collection.schema.collection import TaskExecutionBase, converter_execution_to_response
from app.module.shared.schema import StandardResponse, PaginatedData
router = APIRouter(
prefix="/executions",
tags=["data-collection/executions"],
)
logger = get_logger(__name__)
@router.get("", response_model=StandardResponse[PaginatedData[TaskExecutionBase]])
async def list_executions(
page: int = 1,
size: int = 20,
task_id: Optional[str] = Query(None, description="Task ID"),
task_name: Optional[str] = Query(None, description="Fuzzy search by task name"),
status: Optional[str] = Query(None, description="Filter by execution status"),
start_time: Optional[datetime] = Query(None, description="Start time range from (started_at >= start_time)"),
end_time: Optional[datetime] = Query(None, description="Start time range to (started_at <= end_time)"),
db: AsyncSession = Depends(get_db)
):
"""分页查询归集任务执行记录"""
try:
query = select(TaskExecution)
if task_id:
query = query.where(TaskExecution.task_id == task_id)
if task_name:
query = query.where(TaskExecution.task_name.ilike(f"%{task_name}%"))
if status:
query = query.where(TaskExecution.status == status)
if start_time:
query = query.where(TaskExecution.started_at >= start_time)
if end_time:
query = query.where(TaskExecution.started_at <= end_time)
count_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_query)).scalar_one()
offset = (page - 1) * size
executions = (await db.execute(
query.order_by(TaskExecution.created_at.desc()).offset(offset).limit(size)
)).scalars().all()
items = [converter_execution_to_response(exe) for exe in executions]
total_pages = math.ceil(total / size) if total > 0 else 0
return StandardResponse(
code="0", message="Success",
data=PaginatedData(content=items, total_elements=total, total_pages=total_pages, page=page, size=size)
)
except Exception as e:
logger.error(f"Failed to list task executions: {str(e)}", e)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/{execution_id}/log")
async def get_execution_log(
execution_id: str,
db: AsyncSession = Depends(get_db)
):
"""Get log file content for execution record"""
try:
execution = await db.get(TaskExecution, execution_id)
if not execution:
raise HTTPException(status_code=404, detail="Execution record not found")
log_path = getattr(execution, "log_path", None)
if not log_path:
raise HTTPException(status_code=404, detail="Log path not found")
path = Path(str(log_path))
if not path.is_absolute():
path = Path(os.getcwd()) / path
path = path.resolve()
# ✅ 核心修复:文件尚未生成时,返回友好提示(HTTP 200),而不是崩溃(HTTP 500)
if not path.exists() or not path.is_file():
return PlainTextResponse(
content="⏳ 日志文件尚未生成。任务正在排队或 Runtime 初始化中,请稍后刷新查看...",
media_type="text/plain; charset=utf-8"
)
filename = path.name
headers = {
"Content-Disposition": f'inline; filename="{filename}"'
}
return FileResponse(
path=str(path),
media_type="text/plain; charset=utf-8",
filename=filename,
headers=headers,
)
except HTTPException:
raise
except Exception as e:
# 🛡️ 防御性编程:万一还有其他错误,返回文本提示,不让浏览器报 500 白屏
logger.error(f"Failed to get execution log: {str(e)}", e)
return PlainTextResponse(
content=f"Error reading log: {str(e)}",
media_type="text/plain; charset=utf-8",
status_code=500
)
@@ -1,281 +1,281 @@
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()
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()
@@ -1,4 +1,4 @@
"""
"""
Operator Market Constants
算子市场常量定义
"""
@@ -539,7 +539,8 @@ class OperatorService:
setting_type = setting.get("type")
match setting_type:
case "slider" | "switch" | "select" | "input" | "radio":
# xuhuanqing
case "slider" | "switch" | "select" | "input" | "radio" | "textarea":
setting["defaultVal"] = value
case "checkbox":
setting["defaultVal"] = self._convert_to_list_string(value)
@@ -1,4 +1,4 @@
from __future__ import annotations
from __future__ import annotations
import logging
import os
@@ -10,9 +10,9 @@ from app.core.config import settings
from app.core.logging import setup_logging
def _remove_deepdata_handlers(logger: logging.Logger) -> None:
def _remove_DeepData_handlers(logger: logging.Logger) -> None:
for handler in list(logger.handlers):
if getattr(handler, "_deepdata_managed", False):
if getattr(handler, "_DeepData_managed", False):
logger.removeHandler(handler)
handler.close()
@@ -40,7 +40,7 @@ def test_setup_logging_uses_rotating_file_handler_without_duplicates(
file_handlers = [
handler
for handler in root_logger.handlers
if getattr(handler, "_deepdata_managed", False)
if getattr(handler, "_DeepData_managed", False)
and isinstance(handler, RotatingFileHandler)
]
assert len(file_handlers) == 1
@@ -50,11 +50,11 @@ def test_setup_logging_uses_rotating_file_handler_without_duplicates(
"python-backend.1.log"
)
finally:
_remove_deepdata_handlers(root_logger)
_remove_deepdata_handlers(logging.getLogger("uvicorn"))
_remove_deepdata_handlers(logging.getLogger("uvicorn.access"))
_remove_deepdata_handlers(logging.getLogger("uvicorn.error"))
_remove_deepdata_handlers(logging.getLogger("sqlalchemy.engine"))
_remove_DeepData_handlers(root_logger)
_remove_DeepData_handlers(logging.getLogger("uvicorn"))
_remove_DeepData_handlers(logging.getLogger("uvicorn.access"))
_remove_DeepData_handlers(logging.getLogger("uvicorn.error"))
_remove_DeepData_handlers(logging.getLogger("sqlalchemy.engine"))
def test_copytruncate_log_rotation_script_rotates_only_log_like_files(tmp_path: Path) -> None:
@@ -1,4 +1,4 @@
import pytest
import pytest
from app.core.exception import BusinessError
from app.module.cleaning.schema.cleaning import OperatorInstanceDto
@@ -39,7 +39,7 @@ def test_check_and_get_executor_type_raises_when_mixed_categories() -> None:
CleaningTaskValidator.check_and_get_executor_type(instances)
def test_check_and_get_executor_type_defaults_to_deepdata() -> None:
def test_check_and_get_executor_type_defaults_to_DeepData() -> None:
instances = [_op("a", None, None, None)]
executor = CleaningTaskValidator.check_and_get_executor_type(instances)