Files
MoviePilot/app/chain/subscribe/searchtask.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

430 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
订阅搜索队列单任务执行。
"""
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from functools import partial
from typing import Callable, Optional
from app.application.subscription.contract import (
SubscriptionRepository,
SubscriptionSnapshot,
)
from app.application.subscription.execution import (
SearchTaskSnapshot,
SubscriptionExecutionAdmission,
SubscriptionExecutionContext,
SubscriptionExecutionLease,
SubscriptionSearchRepository,
SubscriptionSiteSearchFailed,
handle_subscription_search_deferred,
)
from app.application.subscription.observability import (
SearchExecutionSummary,
finish_returned_search_task,
subscription_log_scope,
)
from app.application.subscription.sitebudget import (
SubscriptionSearchCancelled,
SubscriptionSearchDeferred,
SubscriptionSiteBudget,
)
from app.chain.search.facade import SearchChain
from app.runtime.log import logger
from app.runtime.stop import StopState
_FOREGROUND_RETRY_SECONDS = 5
_BACKGROUND_RETRY_SECONDS = 10
def retry_at_after(seconds: int) -> str:
"""
返回指定秒数后的 UTC 时间,供短暂等待任务重新入队。
"""
return (datetime.now(timezone.utc) + timedelta(seconds=max(1, seconds))).isoformat(
timespec="seconds"
)
def skip_search_task(
queue: SubscriptionSearchRepository,
task: SearchTaskSnapshot,
reason: str,
) -> bool:
"""
以 skipped 终态收口未执行任务,并保留可见原因。
"""
if task.lease_token is None:
return False
return queue.finish_task(
task_id=task.task_id,
lease_token=task.lease_token,
state="skipped",
error=reason,
)
def subscription_search_cancelled(
repository: SubscriptionRepository,
subscription_id: int,
allow_paused_search: bool,
cancelled: Callable[[], bool],
) -> bool:
"""
自动搜索在安全边界重读暂停状态;显式补搜仍遵守取消和系统停止。
"""
if cancelled():
return True
if allow_paused_search:
return False
current = repository.get(subscription_id)
return current is None or current.state == "S"
@dataclass(slots=True)
class SubscriptionSearchTaskRunner:
"""
执行一条已认领的订阅搜索任务,并负责完整收尾。
:param queue: 保存任务状态、租约和站点预算的搜索队列仓库
:param task: 当前已经认领、包含任务租约的搜索任务快照
:param owner: 本批执行者标识,与任务标识组合为站点预算持有者
:param searchchain: 本任务使用的资源搜索链,执行期间装配站点预算
:param index: 当前任务在进度批次中的序号
:param limit: 本批次计划处理的任务总数
:param progress_callback: 接收批次进度的回调,None 表示没有外部进度接收方
:param summary: 累计本批任务结局、站点访问与资源释放结果的统计
:param subscription_repository: 读取当前订阅和暂停状态的仓库
:param execution_admission: 为同一订阅授予并回收互斥执行权的准入服务
:param execution_ttl: 订阅搜索执行权的有效秒数
:param recent_retry_at: 根据订阅和任务来源计算延后时间,返回 UTC 时间文本或 None
:param process_subscription: 执行单条订阅搜索,接收搜索链和执行上下文并返回最新快照或 None
:param reset_subscription: 把搜索中的订阅恢复为正常状态并返回更新后快照
:param report_progress: 调用进度接收方,接收订阅、当前序号、总数及完成标志
:param stop_state: 系统停止信号,关闭时归还任务以便下次继续
"""
queue: SubscriptionSearchRepository
task: SearchTaskSnapshot
owner: str
searchchain: SearchChain
index: int
limit: int
progress_callback: Optional[Callable[..., None]]
summary: SearchExecutionSummary
subscription_repository: SubscriptionRepository
execution_admission: SubscriptionExecutionAdmission
execution_ttl: int
recent_retry_at: Callable[[SubscriptionSnapshot, str], Optional[str]]
process_subscription: Callable[..., Optional[SubscriptionSnapshot]]
reset_subscription: Callable[[SubscriptionSnapshot], SubscriptionSnapshot]
report_progress: Callable[..., None]
stop_state: StopState
def execute(self) -> Optional[int]:
"""
执行当前任务,返回实际进入搜索处理的订阅 ID。
"""
with subscription_log_scope(self.task.subscription_id, self.task.source, str(self.task.task_id)):
logger.scoped_info("开始处理订阅搜索任务")
return self._execute()
def _execute(self) -> Optional[int]:
"""
在已绑定日志的任务作用域内执行搜索及等待判断。
"""
lease_token = self.task.lease_token
if lease_token is None:
logger.error("这次订阅搜索暂时无法开始,系统稍后会重新处理")
self.summary.record("failed", "missing_lease")
return None
task_id = str(self.task.task_id)
cancelled = partial(self.queue.is_cancel_requested, task_id)
if cancelled():
self.queue.release_task(task_id=task_id, lease_token=lease_token, cancelled=True)
self.summary.record("cancelled", "cancelled")
logger.scoped_info("本轮搜索已取消")
return None
subscribe = self.subscription_repository.get(self.task.subscription_id)
if subscribe is None:
self._finish_missing_subscription(task_id, lease_token)
return None
if self._skip_paused_subscription(subscribe):
return None
self.report_progress(
self.progress_callback,
subscribe,
self.index,
self.limit,
)
recent_retry_at = self.recent_retry_at(subscribe, self.task.source)
if recent_retry_at:
self.queue.defer_task(
task_id=task_id,
lease_token=lease_token,
available_at=recent_retry_at,
phase="scheduled",
message="订阅刚刚创建,保存好设置后会自动开始搜索",
)
self.summary.record("requeued", "recent_subscription")
logger.scoped_info(f"订阅刚刚创建,搜索已安排在 {recent_retry_at} 继续")
return None
execution_lease = self.execution_admission.try_acquire(
subscription_id=subscribe.id,
operation="search",
ttl_seconds=self.execution_ttl,
)
if execution_lease is None:
self._handle_active_subscription(subscribe, task_id, lease_token)
return None
return self._execute_owned_task(
subscribe=subscribe,
task_id=task_id,
lease_token=lease_token,
execution_lease=execution_lease,
cancelled=cancelled,
)
def _finish_missing_subscription(self, task_id: str, lease_token: str) -> None:
"""
把已经删除的订阅任务标记为取消。
"""
self.queue.finish_task(
task_id=task_id,
lease_token=lease_token,
state="cancelled",
error="订阅已删除",
)
self.summary.record("cancelled", "missing_subscription")
def _skip_paused_subscription(self, subscribe: SubscriptionSnapshot) -> bool:
"""
暂停后收口已入队的自动任务,保留显式补搜的执行权限。
"""
if subscribe.state != "S" or self.task.source in {"manual", "targeted"}:
return False
skip_search_task(self.queue, self.task, "订阅已暂停,这次没有自动搜索")
self.summary.record("skipped", "paused")
logger.scoped_info("订阅已暂停,本轮不执行自动搜索")
return True
def _handle_active_subscription(
self,
subscribe: SubscriptionSnapshot,
task_id: str,
lease_token: str,
) -> None:
"""
根据搜索来源延后或跳过正在处理的订阅。
"""
if self.task.source in {"manual", "targeted", "new"}:
retry_seconds = (
_FOREGROUND_RETRY_SECONDS
if self.task.source in {"manual", "targeted"}
else _BACKGROUND_RETRY_SECONDS
)
self.queue.defer_task(
task_id=task_id,
lease_token=lease_token,
available_at=retry_at_after(retry_seconds),
phase="waiting_subscription",
message="这个订阅正在处理,结束后会自动继续搜索",
)
self.summary.record("requeued", "admission_conflict")
logger.debug(f"订阅《{subscribe.name}》正在处理,搜索会在结束后自动继续")
return
skip_search_task(
self.queue,
self.task,
"这个订阅正在处理,本次自动检查无需重复执行",
)
self.summary.record("skipped", "admission_conflict")
logger.scoped_info("订阅正在处理,本轮自动检查无需重复执行")
def _execute_owned_task(
self,
*,
subscribe: SubscriptionSnapshot,
task_id: str,
lease_token: str,
execution_lease: SubscriptionExecutionLease,
cancelled: Callable[[], bool],
) -> Optional[int]:
"""
在持有订阅执行权时完成搜索、异常处理和资源释放。
"""
phase_changed = partial(self._update_phase, task_id, lease_token)
allow_paused_search = self.task.source in {"manual", "targeted"}
execution_context = SubscriptionExecutionContext(
lease=execution_lease,
admission=self.execution_admission,
task_id=task_id,
source=self.task.source,
task_lease=lease_token,
cancel_requested=partial(
subscription_search_cancelled,
self.subscription_repository,
self.task.subscription_id,
allow_paused_search,
lambda: cancelled() or self.stop_state.is_system_stopped,
),
allow_paused_search=allow_paused_search,
phase_changed=phase_changed,
resuming_sites=self.task.pending_site_ids is not None,
)
current: Optional[SubscriptionSnapshot] = subscribe
try:
current = self.subscription_repository.get(self.task.subscription_id)
if current is None:
self._finish_missing_subscription(task_id, lease_token)
return None
if self._skip_paused_subscription(current):
return None
self.searchchain.configure_subscription_site_budget(
SubscriptionSiteBudget(
repository=self.queue,
owner=f"{self.owner}:{task_id}",
cancelled=execution_context.should_stop,
stop_state=self.stop_state,
phase_changed=phase_changed,
metrics=self.summary.site_metrics,
pending_site_ids=self.task.pending_site_ids,
)
)
current = self.process_subscription(
current,
self.searchchain,
execution_context=execution_context,
)
system_stopped = self.stop_state.is_system_stopped
cancel_requested = False if system_stopped else execution_context.is_cancel_requested()
subscription_id, outcome, reason = finish_returned_search_task(
queue=self.queue,
task_id=task_id,
lease_token=lease_token,
subscription_id=self.task.subscription_id,
execution_context=execution_context,
system_stopped=system_stopped,
cancel_requested=cancel_requested,
)
self.summary.record(outcome, reason)
logger.scoped_info(f"本轮搜索任务结束:{outcome},原因:{reason or '正常结束'};订阅是否完成以缺失内容检查为准")
return subscription_id
except SubscriptionSearchCancelled:
self._handle_cancelled_search(task_id, lease_token, execution_context)
except SubscriptionSearchDeferred as deferred:
logger.scoped_info(f"站点暂时忙,本轮搜索延后继续:{deferred}")
handle_subscription_search_deferred(
self.queue,
task_id,
lease_token,
deferred,
self.summary.record,
)
except Exception as err:
logger.error(
f"订阅《{subscribe.name}》搜索失败:{str(err)}",
exc_info=not isinstance(err, SubscriptionSiteSearchFailed),
)
self.queue.finish_task(
task_id=task_id,
lease_token=lease_token,
state="failed",
error=str(err),
)
self.summary.record("failed", "error")
finally:
self._cleanup(subscribe, current, execution_lease)
return None
def _update_phase(
self,
task_id: str,
lease_token: str,
phase: str,
current_site_id: Optional[int] = None,
) -> None:
"""
保存当前任务的用户可见阶段和正在访问的站点。
"""
logger.debug(f"执行阶段:{phase},当前站点:{current_site_id or '-'}")
self.queue.update_task_phase(
task_id=task_id,
lease_token=lease_token,
phase=phase,
current_site_id=current_site_id,
)
def _handle_cancelled_search(
self,
task_id: str,
lease_token: str,
execution_context: SubscriptionExecutionContext,
) -> None:
"""
区分搜索超时、用户停止和系统关闭。
"""
logger.scoped_info("本轮搜索已停止,将根据取消、超时或系统关闭状态收尾")
if execution_context.is_expired() and not execution_context.is_cancel_requested():
self.queue.finish_task(
task_id=task_id,
lease_token=lease_token,
state="failed",
error="这次搜索用时过长,已停止,可稍后重试",
)
self.summary.record("failed", "ttl_timeout")
return
system_stopped = self.stop_state.is_system_stopped
self.queue.release_task(
task_id=task_id,
lease_token=lease_token,
cancelled=not system_stopped,
)
self.summary.record(
"requeued" if system_stopped else "cancelled",
"system_stop" if system_stopped else "cancelled",
)
def _cleanup(
self,
subscribe: SubscriptionSnapshot,
current: Optional[SubscriptionSnapshot],
execution_lease: SubscriptionExecutionLease,
) -> None:
"""
清理站点访问、订阅状态和本轮执行权。
"""
try:
self.searchchain.configure_subscription_site_budget(None)
except Exception as err:
logger.error(
f"订阅《{subscribe.name}》结束站点访问时遇到问题,"
f"系统稍后会继续处理:{str(err)}",
exc_info=True,
)
try:
if current and current.state == "N":
self.reset_subscription(current)
except Exception as err:
logger.error(
f"订阅《{subscribe.name}》搜索结束后未能恢复正常状态:{str(err)}",
exc_info=True,
)
finally:
released = self.execution_admission.release(execution_lease)
if not released:
self.summary.release_failures += 1
logger.error(f"订阅《{subscribe.name}》的搜索状态没有正常恢复,系统稍后会继续处理")
self.report_progress(
self.progress_callback,
subscribe,
self.index,
self.limit,
finished=True,
)