"""U8 — TaskIQ 异步任务集成 — 文档向量化与批量索引。 包装 TaskIQ broker,提供: - 任务参数 schema 校验(Pydantic 模型) - per-user 并发上限 - 任务状态机(PENDING → RUNNING → COMPLETED | FAILED | CANCELLED) - Worker liveness & sweeper(worker_heartbeat_at 超时检测) - 错误消息净化(剥离内部路径/连接串) - 降级模式:broker 未配置时同步执行 Redis 隔离:使用独立 db=1(与 bus/cache 的 db=0 分离),或 key 前缀 ``taskiq:``。 任务参数在 Redis 中带 TTL(默认 1 小时)。 ponytail: TaskIQ broker 为可选依赖 — 通过 TYPE_CHECKING 导入, 未安装时模块仍可加载,TaskManager 退化为同步执行。 """ from __future__ import annotations import asyncio import inspect import logging import re import uuid from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Protocol from pydantic import BaseModel, ConfigDict, Field, field_validator from agentkit.core.protocol import TaskStatus from agentkit.rag_platform.document_processor import ( DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE, DocumentProcessor, ) from agentkit.server.task_store import InMemoryTaskStore, TaskRecord if TYPE_CHECKING: from llama_index.core.embeddings import BaseEmbedding from llama_index.vector_stores.postgres import PGVectorStore from agentkit.rag_platform.store import KBStore logger = logging.getLogger(__name__) # 默认配置 DEFAULT_MAX_CONCURRENT_PER_USER = 3 DEFAULT_WORKER_TTL_SECONDS = 300 # worker 心跳超时阈值 DEFAULT_ERROR_MESSAGE_MAX_LENGTH = 500 DEFAULT_TASK_TTL_SECONDS = 3600 # 任务参数 TTL # Redis 隔离 — 独立 db=1,与 bus/cache 的 db=0 分离 TASKIQ_REDIS_DB = 1 TASKIQ_KEY_PREFIX = "taskiq:" async def _maybe_await(result: object) -> object: """统一处理 sync/async 调用结果 — InMemoryTaskStore 方法为 sync,RedisTaskStore 为 async。""" if inspect.isawaitable(result): return await result return result # --------------------------------------------------------------------------- # 任务参数模型 — schema 校验 # --------------------------------------------------------------------------- class VectorizeTaskParams(BaseModel): """向量化任务参数 — 单文档 parse → segment → vectorize → index。""" model_config = ConfigDict() document_id: str = Field(min_length=1, max_length=128) kb_id: str = Field(min_length=1, max_length=128) file_path: str = Field(min_length=1, max_length=1024) file_type: str = Field(min_length=1, max_length=64) chunk_size: int = Field(default=DEFAULT_CHUNK_SIZE, ge=50, le=8192) chunk_overlap: int = Field(default=DEFAULT_CHUNK_OVERLAP, ge=0, le=1024) user_id: str = Field(min_length=1, max_length=128) @field_validator("chunk_overlap") @classmethod def _overlap_less_than_size(cls, v: int, info: object) -> int: """chunk_overlap 必须小于 chunk_size。""" size = info.data.get("chunk_size", DEFAULT_CHUNK_SIZE) if v >= size: raise ValueError(f"chunk_overlap ({v}) must be < chunk_size ({size})") return v class BatchIndexTaskParams(BaseModel): """批量索引任务参数 — 多文档并行向量化。""" model_config = ConfigDict() kb_id: str = Field(min_length=1, max_length=128) document_ids: list[str] = Field(min_length=1, max_length=100) user_id: str = Field(min_length=1, max_length=128) file_paths: dict[str, str] = Field(default_factory=dict) # document_id -> file_path file_types: dict[str, str] = Field(default_factory=dict) # document_id -> file_type chunk_size: int = Field(default=DEFAULT_CHUNK_SIZE, ge=50, le=8192) chunk_overlap: int = Field(default=DEFAULT_CHUNK_OVERLAP, ge=0, le=1024) # --------------------------------------------------------------------------- # 错误消息净化 # --------------------------------------------------------------------------- # 匹配 Unix/Windows 文件路径 _PATH_RE = re.compile( r"(?:/[\w.\-]+)+|" # Unix 路径 /usr/local/... r"(?:[A-Za-z]:\\[\w.\-]+(?:\\[\w.\-]+)*)" # Windows 路径 C:\Users\... ) # 匹配 Redis/PostgreSQL 连接串 _CONN_STR_RE = re.compile( r"(redis://|postgresql://|postgres://|mysql://)" r"[^\s\"']+", re.IGNORECASE, ) # 匹配环境变量风格的密钥 _SECRET_RE = re.compile( r"(api[_-]?key|token|password|secret)[\s:=]+[\w\-]+", re.IGNORECASE, ) def sanitize_error_message(msg: str, max_length: int = DEFAULT_ERROR_MESSAGE_MAX_LENGTH) -> str: """净化错误消息 — 剥离内部路径、连接串、密钥。 Args: msg: 原始错误消息 max_length: 最大长度(截断) Returns: 净化后的错误消息 """ if not msg: return "" # 连接串优先于路径 — 避免 /localhost:6379/0 被路径正则部分匹配 cleaned = _CONN_STR_RE.sub("", msg) cleaned = _PATH_RE.sub("", cleaned) cleaned = _SECRET_RE.sub(r"\1=", cleaned) if len(cleaned) > max_length: cleaned = cleaned[: max_length - 3] + "..." return cleaned # --------------------------------------------------------------------------- # Worker liveness & sweeper # --------------------------------------------------------------------------- class TaskStoreProtocol(Protocol): """TaskStore 协议 — InMemoryTaskStore / RedisTaskStore 均满足。""" async def create( self, task_id: str, agent_name: str, input_data: dict, skill_name: str | None = None ) -> TaskRecord: ... def get(self, task_id: str) -> TaskRecord | None: ... async def update_status( self, task_id: str, status: TaskStatus, **kwargs: object ) -> TaskRecord: ... def list_tasks( self, status: TaskStatus | None = None, limit: int = 100 ) -> list[TaskRecord]: ... class WorkerSweeper: """检测超时任务并标记为 failed。 扫描 status=RUNNING 且 worker_heartbeat_at 超过 TTL 的任务, 将其状态置为 failed,error_message="worker_timeout"。 ponytail: 升级路径 — 当前依赖 list_tasks 全量扫描(O(n)), 生产环境可改为 Redis ZSET 按心跳时间戳排序(O(log n))。 """ def __init__( self, task_store: TaskStoreProtocol, ttl_seconds: int = DEFAULT_WORKER_TTL_SECONDS, ) -> None: self._store = task_store self._ttl = ttl_seconds async def sweep(self) -> int: """扫描超时任务,返回清理数量。""" now = datetime.now(timezone.utc) cutoff = now - timedelta(seconds=self._ttl) cleaned = 0 running_tasks = await _maybe_await( self._store.list_tasks(status=TaskStatus.RUNNING, limit=1000) ) for record in running_tasks: heartbeat = record.metadata.get("worker_heartbeat_at") if heartbeat is None: # 无心跳记录 — 使用 started_at 作为回退 heartbeat = record.started_at.isoformat() if record.started_at else None if heartbeat is None: continue try: hb_time = datetime.fromisoformat(heartbeat) except (ValueError, TypeError): continue # 确保 timezone-aware if hb_time.tzinfo is None: hb_time = hb_time.replace(tzinfo=timezone.utc) if hb_time < cutoff: try: await _maybe_await( self._store.update_status( record.task_id, TaskStatus.FAILED, error_message="worker_timeout", completed_at=now, metadata={**record.metadata, "swept_at": now.isoformat()}, ) ) cleaned += 1 logger.warning( "Swept timed-out task %s (heartbeat=%s, cutoff=%s)", record.task_id, heartbeat, cutoff.isoformat(), ) except KeyError: # 任务已被并发删除 continue return cleaned # --------------------------------------------------------------------------- # TaskManager — 任务提交、查询、取消 # --------------------------------------------------------------------------- class ConcurrencyLimitExceeded(Exception): """per-user 并发上限超出。""" class TaskNotFoundError(Exception): """任务不存在。""" class TaskManager: """异步任务管理器 — 包装 TaskIQ broker。 如果 broker 未配置或 TaskIQ 不可用,任务同步执行(降级模式)。 Args: broker: TaskIQ broker 实例(可选) task_store: 任务状态存储(默认 InMemoryTaskStore) max_concurrent_per_user: per-user 并发上限 task_ttl_seconds: 任务参数 TTL(Redis 模式) """ def __init__( self, broker: object | None = None, task_store: TaskStoreProtocol | None = None, max_concurrent_per_user: int = DEFAULT_MAX_CONCURRENT_PER_USER, task_ttl_seconds: int = DEFAULT_TASK_TTL_SECONDS, ) -> None: self._broker = broker self._store: TaskStoreProtocol = task_store or InMemoryTaskStore() self._max_concurrent = max_concurrent_per_user self._task_ttl = task_ttl_seconds # user_id -> 当前 RUNNING 任务数 self._running_counts: dict[str, int] = {} self._lock = asyncio.Lock() @property def store(self) -> TaskStoreProtocol: """暴露底层 task store(供 sweeper 使用)。""" return self._store @property def broker(self) -> object: """暴露底层 broker(供 startup/shutdown 集成)。""" return self._broker # ── 任务提交 ──────────────────────────────────────────────── async def submit_vectorize( self, params: VectorizeTaskParams, dependencies: dict[str, object] | None = None, ) -> str: """提交向量化任务,返回 task_id。 Args: params: 向量化任务参数(已通过 schema 校验) dependencies: 运行时依赖(store/vector_store/embed_model), 降级模式下直接使用;broker 模式下由 worker 注入 Raises: ConcurrencyLimitExceeded: per-user 并发上限超出 """ await self._check_concurrency(params.user_id) task_id = f"vec-{uuid.uuid4()}" input_data = params.model_dump() # 依赖对象不序列化 — 仅在降级模式内存中传递 deps = dependencies or {} await _maybe_await( self._store.create( task_id=task_id, agent_name="rag_vectorize", input_data=input_data, skill_name="vectorize", ) ) # 记录 user_id 到 metadata 便于按用户查询 record = await _maybe_await(self._store.get(task_id)) if record is not None: record.metadata["user_id"] = params.user_id logger.info( "Submitted vectorize task %s for document=%s user=%s", task_id, params.document_id, params.user_id, ) if self._broker is not None: # broker 模式 — 异步派发 await self._dispatch_to_broker("vectorize", input_data, task_id) else: # 降级模式 — 同步执行(不阻塞调用方,使用 asyncio.create_task) asyncio.create_task(self._run_vectorize_degraded(task_id, params, deps)) return task_id async def submit_batch_index( self, params: BatchIndexTaskParams, dependencies: dict[str, object] | None = None, ) -> str: """提交批量索引任务,返回 task_id。""" await self._check_concurrency(params.user_id) task_id = f"batch-{uuid.uuid4()}" input_data = params.model_dump() deps = dependencies or {} await _maybe_await( self._store.create( task_id=task_id, agent_name="rag_batch_index", input_data=input_data, skill_name="batch_index", ) ) record = await _maybe_await(self._store.get(task_id)) if record is not None: record.metadata["user_id"] = params.user_id logger.info( "Submitted batch index task %s for %d documents user=%s", task_id, len(params.document_ids), params.user_id, ) if self._broker is not None: await self._dispatch_to_broker("batch_index", input_data, task_id) else: asyncio.create_task(self._run_batch_index_degraded(task_id, params, deps)) return task_id # ── 任务查询 ──────────────────────────────────────────────── async def get_task_status(self, task_id: str) -> TaskRecord | None: """查询任务状态。""" return await _maybe_await(self._store.get(task_id)) async def list_tasks( self, user_id: str | None = None, status: TaskStatus | None = None, limit: int = 100, ) -> list[TaskRecord]: """查询任务历史 — 可按 user_id 和 status 过滤。""" tasks = await _maybe_await(self._store.list_tasks(status=status, limit=limit)) if user_id is not None: tasks = [t for t in tasks if t.metadata.get("user_id") == user_id] return tasks async def cancel_task(self, task_id: str) -> bool: """取消任务 — 仅 PENDING/RUNNING 可取消。 Returns: True 如果取消成功 Raises: TaskNotFoundError: 任务不存在 """ record = await _maybe_await(self._store.get(task_id)) if record is None: raise TaskNotFoundError(f"Task {task_id} not found") if record.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED): return False await _maybe_await( self._store.update_status( task_id, TaskStatus.CANCELLED, completed_at=datetime.now(timezone.utc), ) ) # 释放并发计数 user_id = record.metadata.get("user_id") if user_id and self._running_counts.get(user_id, 0) > 0: self._running_counts[user_id] -= 1 return True # ── 心跳更新 ──────────────────────────────────────────────── async def update_heartbeat(self, task_id: str) -> None: """更新 worker 心跳时间戳 — 由 worker 周期性调用。""" record = await _maybe_await(self._store.get(task_id)) if record is None: return now = datetime.now(timezone.utc).isoformat() record.metadata["worker_heartbeat_at"] = now # ── 内部方法 ──────────────────────────────────────────────── async def _check_concurrency(self, user_id: str) -> None: """检查 per-user 并发上限。""" async with self._lock: current = self._running_counts.get(user_id, 0) if current >= self._max_concurrent: raise ConcurrencyLimitExceeded( f"User {user_id} has {current} running tasks (max={self._max_concurrent})" ) self._running_counts[user_id] = current + 1 async def _release_concurrency(self, user_id: str) -> None: """释放并发计数。""" async with self._lock: if self._running_counts.get(user_id, 0) > 0: self._running_counts[user_id] -= 1 async def _dispatch_to_broker(self, task_type: str, params: dict, task_id: str) -> None: """派发任务到 TaskIQ broker。""" # broker.kiq 返回 TaskiqTask — 实际执行由 worker 进程完成 try: await self._broker.kiq(task_type=task_type, params=params, task_id=task_id) except Exception as e: logger.error("Failed to dispatch task %s to broker: %s", task_id, e) raise async def _run_vectorize_degraded( self, task_id: str, params: VectorizeTaskParams, deps: dict[str, object], ) -> None: """降级模式 — 同步执行向量化任务(在 asyncio 任务中)。""" now = datetime.now(timezone.utc) try: await _maybe_await( self._store.update_status(task_id, TaskStatus.RUNNING, started_at=now) ) await self.update_heartbeat(task_id) await run_vectorize_task(params, **deps) await _maybe_await( self._store.update_status( task_id, TaskStatus.COMPLETED, completed_at=datetime.now(timezone.utc), progress=1.0, ) ) except Exception as e: sanitized = sanitize_error_message(str(e)) await _maybe_await( self._store.update_status( task_id, TaskStatus.FAILED, error_message=sanitized, completed_at=datetime.now(timezone.utc), ) ) logger.error("Vectorize task %s failed: %s", task_id, sanitized) finally: await self._release_concurrency(params.user_id) async def _run_batch_index_degraded( self, task_id: str, params: BatchIndexTaskParams, deps: dict[str, object], ) -> None: """降级模式 — 同步执行批量索引任务。""" now = datetime.now(timezone.utc) total = len(params.document_ids) succeeded: list[str] = [] failed: list[str] = [] try: await _maybe_await( self._store.update_status(task_id, TaskStatus.RUNNING, started_at=now) ) await self.update_heartbeat(task_id) for i, doc_id in enumerate(params.document_ids): await self.update_heartbeat(task_id) try: single_params = VectorizeTaskParams( document_id=doc_id, kb_id=params.kb_id, file_path=params.file_paths.get(doc_id, ""), file_type=params.file_types.get(doc_id, "txt"), chunk_size=params.chunk_size, chunk_overlap=params.chunk_overlap, user_id=params.user_id, ) await run_vectorize_task(single_params, **deps) succeeded.append(doc_id) except Exception as e: logger.warning("Batch item %s failed: %s", doc_id, e) failed.append(doc_id) # 更新进度 progress = (i + 1) / total await _maybe_await( self._store.update_status( task_id, TaskStatus.RUNNING, progress=progress, progress_message=f"Processed {i + 1}/{total}", ) ) final_status = TaskStatus.COMPLETED if not failed else TaskStatus.PARTIALLY_COMPLETED await _maybe_await( self._store.update_status( task_id, final_status, completed_at=datetime.now(timezone.utc), progress=1.0, output_data={ "succeeded": succeeded, "failed": failed, "total": total, }, ) ) except Exception as e: sanitized = sanitize_error_message(str(e)) await _maybe_await( self._store.update_status( task_id, TaskStatus.FAILED, error_message=sanitized, completed_at=datetime.now(timezone.utc), ) ) logger.error("Batch index task %s failed: %s", task_id, sanitized) finally: await self._release_concurrency(params.user_id) # --------------------------------------------------------------------------- # 任务执行函数 — 由 worker 调用 # --------------------------------------------------------------------------- async def run_vectorize_task( params: VectorizeTaskParams, store: "KBStore", vector_store: "PGVectorStore", embed_model: "BaseEmbedding", ) -> None: """向量化任务 — 由 worker 执行。 封装 DocumentProcessor.process() — parse → segment → vectorize → index。 状态转换由 DocumentProcessor 内部管理(pending → parsing → ... → indexed | failed)。 Args: params: 向量化任务参数 store: KBStore 实例(用于文档状态更新) vector_store: LlamaIndex PGVectorStore 实例 embed_model: LlamaIndex embedding 模型 Raises: Exception: 管道任一阶段失败 """ processor = DocumentProcessor( chunk_size=params.chunk_size, chunk_overlap=params.chunk_overlap, ) await processor.process( params.file_path, params.file_type, params.kb_id, params.document_id, vector_store, embed_model, store, ) async def run_batch_index_task( params: BatchIndexTaskParams, store: "KBStore", vector_store: "PGVectorStore", embed_model: "BaseEmbedding", ) -> dict[str, list[str]]: """批量索引任务 — 顺序处理多个文档。 Returns: {"succeeded": [...], "failed": [...]} """ succeeded: list[str] = [] failed: list[str] = [] for doc_id in params.document_ids: try: single_params = VectorizeTaskParams( document_id=doc_id, kb_id=params.kb_id, file_path=params.file_paths.get(doc_id, ""), file_type=params.file_types.get(doc_id, "txt"), chunk_size=params.chunk_size, chunk_overlap=params.chunk_overlap, user_id=params.user_id, ) await run_vectorize_task(single_params, store, vector_store, embed_model) succeeded.append(doc_id) except Exception as e: logger.warning("Batch item %s failed: %s", doc_id, e) failed.append(doc_id) return {"succeeded": succeeded, "failed": failed} # --------------------------------------------------------------------------- # TaskIQ broker 工厂(可选 — 仅 Redis 可用时使用) # --------------------------------------------------------------------------- def create_broker(redis_url: str) -> object: """创建 TaskIQ Redis broker — 独立 db=1 隔离。 Args: redis_url: Redis 连接 URL(db=0 用于 bus/cache,此处强制改为 db=1) Returns: 配置好的 TaskiqBroker 实例 Raises: ImportError: taskiq 未安装 """ from urllib.parse import urlparse, urlunparse from taskiq_redis import ListQueueBroker, RedisAsyncResultBackend from taskiq import TaskiqBroker # 强制使用 db=1 — 与 bus/cache 的 db=0 隔离 parsed = urlparse(redis_url) # 替换 path 为 /1 new_path = "/1" isolated_url = urlunparse(parsed._replace(path=new_path)) broker = TaskiqBroker(ListQueueBroker(url=isolated_url)) broker.with_result_backend( RedisAsyncResultBackend(redis_url=isolated_url, result_ex_time=DEFAULT_TASK_TTL_SECONDS) ) logger.info("TaskIQ broker created with Redis db=1 isolation") return broker __all__ = [ "BatchIndexTaskParams", "ConcurrencyLimitExceeded", "DEFAULT_MAX_CONCURRENT_PER_USER", "DEFAULT_WORKER_TTL_SECONDS", "TaskManager", "TaskNotFoundError", "TaskStoreProtocol", "VectorizeTaskParams", "WorkerSweeper", "create_broker", "run_batch_index_task", "run_vectorize_task", "sanitize_error_message", ]