"""ARQQueueAdapter:实现 QueuePort,复用现有 arq.enqueue_job。 - enqueue 必须复用 arq.enqueue_job - 不得另起重试 Worker - ARQ 不可用时 Outbox 必须暂停重试并触发优雅降级 依赖边界:只依赖 yuxi.channels.contract(端口 + DTO + 错误)。 ARQ 连接池与 ``LoggerPort`` 通过构造函数注入,由外部 factory 管理生命周期, 不依赖 yuxi.services(ADP-005)。 """ from __future__ import annotations import asyncio from typing import TYPE_CHECKING, Any from yuxi.channels.contract.dtos.health import WorkerStatus from yuxi.channels.contract.dtos.queue import EnqueueCmd, JobId, JobStatus from yuxi.channels.contract.errors import DependencyError, NotFoundError, ValidationError from yuxi.channels.contract.errors.base import Error from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from yuxi.channels.contract.ports.driven.queue_port import QueuePort if TYPE_CHECKING: from arq import ArqRedis __all__ = ["ARQQueueAdapter"] class ARQQueueAdapter(QueuePort): """实现 QueuePort,复用现有 arq.enqueue_job。 通过构造函数注入的 ``ArqRedis`` 连接池复用 ``arq.enqueue_job`` 入队, 通过 ``arq.jobs.Job`` 查询状态与取消任务。ARQ 不可用时抛出 ``DependencyError``,由调用方暂停 Outbox 重试并触发优雅降级。 ARQ 连接池与 ``LoggerPort`` 由外部 factory 创建并注入,适配器仅持有 引用,不负责其生命周期管理(参见 ``close``)。 """ def __init__(self, arq_pool: ArqRedis, logger: LoggerPort) -> None: """初始化适配器,注入 ARQ 连接池与日志端口。 Args: arq_pool: ARQ 连接池实例(``arq.ArqRedis``),由外部 factory 创建并管理生命周期。适配器仅持有引用用于入队、状态查询 与取消任务。 logger: 日志被驱动端口,用于 ``ping`` 故障时记录告警 (硬约束:所有适配器必须使用注入的 ``LoggerPort`` 而非 全局 logger 工具)。 """ self._arq_pool = arq_pool self._logger = logger async def enqueue(self, cmd: EnqueueCmd) -> JobId: """入队异步任务,复用 arq.enqueue_job。 校验 task_name 与 payload 非空后,通过 ``arq.enqueue_job`` 入队。 幂等键重复入队时返回首次入队的 JobId;ARQ 不可用时抛出 ``DependencyError``。 Args: cmd: 入队命令,携带任务名称、负载与幂等键。 Returns: 任务 ID。 Raises: ValidationError: task_name 或 payload 为空。 DependencyError: ARQ 不可用或 enqueue_job 返回 None 且无幂等键。 """ try: if not cmd.task_name: raise ValidationError("task_name", "must not be empty") if not cmd.payload: raise ValidationError("payload", "must not be empty") queue = self._arq_pool # 消费 scheduled_at 字段,非空时通过 _defer_until 交给 ARQ 延迟投递 enqueue_kwargs: dict[str, Any] = {"_job_id": cmd.idempotency_key} if cmd.scheduled_at is not None: enqueue_kwargs["_defer_until"] = cmd.scheduled_at job = await queue.enqueue_job(cmd.task_name, **cmd.payload, **enqueue_kwargs) if job is None: if cmd.idempotency_key: return JobId(cmd.idempotency_key) raise DependencyError("arq", Error("enqueue_job returned None")) return JobId(job.job_id) except (ValidationError, DependencyError): raise except Exception as exc: raise DependencyError("arq", exc) from exc async def getJobStatus(self, job_id: JobId) -> JobStatus: """查询任务状态。 通过 ``arq.jobs.Job.status()`` 获取 ARQ 状态并映射为契约层 ``JobStatus`` 枚举。任务不存在时抛出 ``NotFoundError``;ARQ 不可用 时抛出 ``DependencyError``。 状态映射: - arq ``deferred`` / ``queued`` → ``JobStatus.QUEUED`` - arq ``in_progress`` → ``JobStatus.RUNNING`` - arq ``complete`` → 根据 ``result_info.success`` 区分 ``COMPLETED`` / ``FAILED`` / ``CANCELLED`` Args: job_id: 任务 ID。 Returns: 任务状态枚举值。 Raises: NotFoundError: 任务不存在。 DependencyError: ARQ 不可用。 """ try: from arq.jobs import Job as ArqJob from arq.jobs import JobStatus as ArqJobStatus queue = self._arq_pool job = ArqJob( job_id.value, redis=queue, _queue_name=queue.default_queue_name, _deserializer=queue.job_deserializer, ) status = await job.status() if status == ArqJobStatus.not_found: raise NotFoundError("job", job_id.value) if status == ArqJobStatus.complete: result_info = await job.result_info() if result_info is not None and not result_info.success: if isinstance(result_info.result, asyncio.CancelledError): return JobStatus.CANCELLED return JobStatus.FAILED return JobStatus.COMPLETED if status == ArqJobStatus.in_progress: return JobStatus.RUNNING return JobStatus.QUEUED except NotFoundError: raise except Exception as exc: raise DependencyError("arq", Error(str(exc))) from exc async def cancelJob(self, job_id: JobId) -> bool: """取消任务,用于 Outbox 重试取消场景。 通过 ``arq.jobs.Job.abort()`` 取消任务。任务已取消返回 ``True``, 任务已完成或不存在返回 ``False``;ARQ 不可用时抛出 ``DependencyError``。 Args: job_id: 任务 ID。 Returns: 任务已取消返回 ``True``,已完成或不存在返回 ``False``。 Raises: DependencyError: ARQ 不可用。 """ try: from arq.jobs import Job as ArqJob queue = self._arq_pool job = ArqJob( job_id.value, redis=queue, _queue_name=queue.default_queue_name, _deserializer=queue.job_deserializer, ) return await job.abort() except Exception as exc: raise DependencyError("arq", Error(str(exc))) from exc async def getWorkerStatus(self) -> WorkerStatus: """获取 ARQ Worker 状态。 返回 ``WorkerStatus``,包含队列名称、可用性与队列深度(积压任务数)。 ``queue_depth`` 通过 ``zcard`` 查询 ARQ 队列 ZSET (``arq:queue:``)的成员数,供 ``DiagnosticsExporter`` 填充诊断包、``DashboardHandler`` 聚合实时面板。``worker_utilization`` 返回 ``None``——ARQ 客户端不暴露 worker 级别的利用率统计,该字段为 未采集占位,由调用方按需消费。 Raises: DependencyError: ARQ 不可用。 """ try: queue = self._arq_pool queue_key = f"arq:queue:{queue.default_queue_name}" depth = await queue.zcard(queue_key) return WorkerStatus( queue_name=queue.default_queue_name, available=True, queue_depth=depth, worker_utilization=None, ) except Exception as exc: raise DependencyError("arq", Error(str(exc))) from exc async def ping(self) -> bool: """主动探测 ARQ 连接可用性,故障时返回 False(降级不阻断)。 执行 ARQ 连接池 ``ping`` 验证连接可用性。故障时返回 False 并通过 注入的 ``LoggerPort`` 记录 warning 日志,不抛异常,供 ``HostBootstrap`` 启动期连通性检查使用。 Returns: True 表示连接可用,False 表示故障。 """ try: await self._arq_pool.ping() return True except Exception as exc: await self._logger.warn( "arq queue ping failed", error_type=type(exc).__name__, error=str(exc), ) return False