Files

526 lines
19 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 __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from typing import Callable, Mapping, Optional, Protocol
from uuid import uuid4
from app.application.search.session import SearchSessionRepository
from app.application.subscription.sitebudget import (
SiteBudgetClaim,
SubscriptionSearchDeferred,
SubscriptionSiteBudgetDeferral,
)
from app.schemas.common import JsonData
@dataclass(frozen=True, slots=True)
class SubscriptionExecutionLease:
"""
一次订阅执行的进程内所有权及协作截止时间。
:param subscription_id: 订阅记录的数据库主键
:param operation: 持有者执行的操作类型,区分搜索和资源匹配
:param owner_token: 标识本次进程内所有权的随机令牌
:param expires_at: 协作执行截止的单调时钟时间,单位为秒
"""
subscription_id: int
operation: str
owner_token: str
expires_at: float
class SubscriptionExecutionAdmission:
"""
按订阅 ID 控制 Search 与 Match 的进程内互斥。
"""
def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
"""
保存单调时钟和当前活跃持有者。
"""
self._clock = clock
self._lock = threading.Lock()
self._owners: dict[int, SubscriptionExecutionLease] = {}
def try_acquire(
self,
*,
subscription_id: int,
operation: str,
ttl_seconds: float,
) -> Optional[SubscriptionExecutionLease]:
"""
无等待取得订阅所有权;已有持有者 时直接返回空。
"""
with self._lock:
if subscription_id in self._owners:
return None
lease = SubscriptionExecutionLease(
subscription_id=subscription_id,
operation=operation,
owner_token=uuid4().hex,
expires_at=self._clock() + max(1.0, ttl_seconds),
)
self._owners[subscription_id] = lease
return lease
def release(self, lease: SubscriptionExecutionLease) -> bool:
"""
仅允许当前持有者 释放订阅所有权。
"""
with self._lock:
current = self._owners.get(lease.subscription_id)
if current is None or current.owner_token != lease.owner_token:
return False
self._owners.pop(lease.subscription_id)
return True
def is_expired(self, lease: SubscriptionExecutionLease) -> bool:
"""
判断持有者 是否已超过协作执行截止时间。
"""
return self._clock() >= lease.expires_at
class SubscriptionSiteSearchFailed(RuntimeError):
"""
表示本轮没有搜索源成功完成,无需输出内部异常堆栈。
"""
@dataclass(slots=True)
class SubscriptionExecutionContext:
"""
一次订阅执行的显式取消、阶段和副作用边界。
:param lease: 本次执行持有的进程内租约
:param admission: 负责检验与释放该租约的准入管理器
:param task_id: 持久搜索任务的稳定标识;无队列任务时为 None
:param cancel_requested: 检查外部取消请求的回调;None 表示没有外部取消源
:param phase_changed: 阶段变化时接收阶段名及当前站点标识的回调;None 表示不报告
:param download_started: 是否已开始产生下载提交副作用
:param resuming_sites: 是否从已保存的站点进度恢复
:param incremental_search: 是否按增量分页方式推进候选收集
:param scan_finished: 本轮候选扫描是否已完成
:param task_lease: 持久搜索任务的租约令牌;非队列执行时为 None
:param allow_paused_search: 是否允许手工或指定订阅补搜在暂停状态下提交下载
:param source: 触发本轮执行的入口,例如手工搜索或自动调度
:param refresh_sites: 本轮需要更新进度的站点摘要
:param refresh_candidates: 刷新站点进度时累计的候选资源数
"""
lease: SubscriptionExecutionLease
admission: SubscriptionExecutionAdmission
task_id: Optional[str] = None
cancel_requested: Optional[Callable[[], bool]] = None
phase_changed: Optional[Callable[[str, Optional[int]], None]] = None
download_started: bool = False
resuming_sites: bool = False
incremental_search: bool = False
scan_finished: bool = False
task_lease: Optional[str] = None
allow_paused_search: bool = False
source: str = "automatic"
refresh_sites: tuple[dict[str, object], ...] = ()
refresh_candidates: int = 0
def is_cancel_requested(self) -> bool:
"""
判断调用入口是否请求在下一个安全边界退出。
"""
return bool(self.cancel_requested and self.cancel_requested())
def is_expired(self) -> bool:
"""
判断本次执行是否已经超过协作截止时间。
"""
return self.admission.is_expired(self.lease)
def should_stop(self) -> bool:
"""
在安全边界合并用户取消和执行 TTL。
"""
return self.is_expired() or self.is_cancel_requested()
def report_phase(self, phase: str, current_site_id: Optional[int] = None) -> None:
"""
向当前搜索任务报告业务阶段。
"""
if self.phase_changed:
self.phase_changed(phase, current_site_id)
def mark_download_started(self) -> None:
"""
标记执行已越过下载器副作用边界。
"""
self.download_started = True
self.report_phase("submitting")
def raise_subscription_site_budget_failures(failures: tuple[str, ...]) -> None:
"""
在所有实际搜索源均未成功时暴露站点聚合失败。
"""
if failures:
raise SubscriptionSiteSearchFailed(";".join(failures))
def raise_subscription_site_budget_deferral(
deferrals: tuple[SubscriptionSiteBudgetDeferral, ...],
execution_context: Optional[SubscriptionExecutionContext],
) -> None:
"""
在没有下载副作用时,将临时站点冲突转换为持久队列延后。
"""
if not deferrals or (execution_context and execution_context.download_started and not execution_context.incremental_search):
return
retry_at = min(deferrals, key=lambda item: item.retry_at).retry_at
site_ids = tuple(dict.fromkeys(item.site_id for item in deferrals))
wait_reason = "cooldown" if all(item.wait_reason == "cooldown" for item in deferrals) else "busy"
raise SubscriptionSearchDeferred(retry_at=retry_at, site_ids=site_ids, wait_reason=wait_reason)
def handle_subscription_search_deferred(
queue: SubscriptionSearchRepository,
task_id: str,
lease_token: str,
deferred: SubscriptionSearchDeferred,
record: Callable[..., None],
) -> None:
"""
把站点暂时不可用的任务重新入队,而不是记录为搜索失败。
"""
requeued = queue.defer_task(
task_id=task_id,
lease_token=lease_token,
available_at=deferred.retry_at,
phase="waiting_site_budget",
message=str(deferred),
pending_site_ids=deferred.site_ids,
)
if requeued:
record("requeued", "site_budget_deferred")
@dataclass(frozen=True, slots=True)
class SearchBatchSnapshot:
"""
订阅搜索批次的持久业务状态快照。
:param batch_id: 持久搜索批次的稳定标识
:param source: 触发本轮执行的入口,例如手工搜索或自动调度
:param state: 当前任务或批次的业务状态
:param priority: 队列调度优先级,数值较高者优先认领
:param total_count: 批次包含的任务总数
:param finished_count: 成功完成的任务数量
:param failed_count: 失败终止的任务数量
:param cancelled_count: 已取消的任务数量
:param cancel_requested: 是否已请求在安全边界取消执行
:param created_at: 记录创建时间文本
:param updated_at: 最近状态更新时间文本
:param started_at: 开始执行的时间文本;尚未执行时为 None
:param finished_at: 完成执行的时间文本;尚未完成时为 None
:param last_error: 最近失败的错误摘要;无错误时为 None
:param skipped_count: 本轮无需执行而跳过的任务数量
"""
batch_id: str
source: str
state: str
priority: int
total_count: int
finished_count: int
failed_count: int
cancelled_count: int
cancel_requested: bool
created_at: str
updated_at: str
started_at: Optional[str] = None
finished_at: Optional[str] = None
last_error: Optional[str] = None
skipped_count: int = 0
@dataclass(frozen=True, slots=True)
class SearchTaskSnapshot:
"""
一个可认领、恢复和取消的订阅搜索任务快照。
:param task_id: 持久搜索任务的稳定标识
:param batch_id: 持久搜索批次的稳定标识
:param subscription_id: 订阅记录的数据库主键
:param source: 触发本轮执行的入口,例如手工搜索或自动调度
:param priority: 队列调度优先级,数值较高者优先认领
:param position: 任务在所属批次中的零基排列位置
:param state: 当前任务或批次的业务状态
:param phase: 当前执行阶段,用于展示进度与恢复位置
:param attempt_count: 任务累计被认领执行的次数
:param cancel_requested: 是否已请求在安全边界取消执行
:param lease_token: 当前认领者的租约令牌;未取得租约时为 None
:param created_at: 记录创建时间文本
:param updated_at: 最近状态更新时间文本
:param available_at: 任务最早可被认领的时间文本;None 表示无额外延迟
:param started_at: 开始执行的时间文本;尚未执行时为 None
:param finished_at: 完成执行的时间文本;尚未完成时为 None
:param last_error: 最近失败的错误摘要;无错误时为 None
:param current_site_id: 正在处理的站点标识;尚未进入站点时为 None
:param download_counts: 任务活动累计的成功提交分类计数;旧活动为空
:param downloads: 本任务活动中保留的成功下载安全摘要;最多 100 条
:param pending_site_ids: 等待恢复的站点标识;None 表示尚未限定站点范围
"""
task_id: str
batch_id: str
subscription_id: int
source: str
priority: int
position: int
state: str
phase: str
attempt_count: int
cancel_requested: bool
lease_token: Optional[str]
created_at: str
updated_at: str
available_at: Optional[str] = None
started_at: Optional[str] = None
finished_at: Optional[str] = None
last_error: Optional[str] = None
current_site_id: Optional[int] = None
pending_site_ids: Optional[tuple[int, ...]] = None
download_counts: dict[str, int] = field(default_factory=dict)
downloads: tuple[dict[str, JsonData], ...] = ()
@dataclass(frozen=True, slots=True)
class SearchEnqueueResult:
"""
一次批次入队结果,区分新任务与 同订阅并发请求合并。
:param batch: 本次入队对应的新建或既有批次快照
:param created_count: 本次新建的任务数量
:param coalesced_count: 与已有活动任务合并而未重复创建的数量
:param active_batch_ids: 本次请求涉及的全部活动批次标识
"""
batch: SearchBatchSnapshot
created_count: int
coalesced_count: int
active_batch_ids: tuple[str, ...]
class SubscriptionSearchRepository(Protocol):
"""
订阅搜索批次与任务的持久队列端口。
实现方在独立短事务中提交队列状态,并以租约令牌隔离过期执行者。
:ivar sessions: 保存和恢复搜索分页检查点的会话仓库
"""
sessions: SearchSessionRepository
def enqueue(
self,
*,
subscription_ids: tuple[int, ...],
source: str,
priority: int,
available_at_by_subscription: Optional[Mapping[int, str]] = None,
refresh_pending: bool = False,
) -> SearchEnqueueResult:
"""
建立或合并活动任务;新自动周期可恢复等待任务的完整搜索范围。
:param subscription_ids: 目标订阅主键,保持调用方指定的顺序
:param source: 搜索触发来源,用于调度和活动展示
:param priority: 任务调度优先级,数值较高者优先认领
:param available_at_by_subscription: 各订阅最早可执行时间;未指定的订阅立即具备调度资格
:param refresh_pending: 是否为新周期重置尚未运行任务的站点搜索范围
:return: 新建批次、创建及合并数量,以及涉及的活动批次标识
"""
...
async def async_enqueue(
self,
*,
subscription_ids: tuple[int, ...],
source: str,
priority: int,
available_at_by_subscription: Optional[Mapping[int, str]] = None,
refresh_pending: bool = False,
) -> SearchEnqueueResult:
"""
异步建立或合并活动任务,可为新周期刷新尚未运行的站点游标。
:param subscription_ids: 目标订阅主键,保持调用方指定的顺序
:param source: 搜索触发来源,用于调度和活动展示
:param priority: 任务调度优先级,数值较高者优先认领
:param available_at_by_subscription: 各订阅最早可执行时间;未指定的订阅立即具备调度资格
:param refresh_pending: 是否为新周期重置尚未运行任务的站点搜索范围
:return: 新建批次、创建及合并数量,以及涉及的活动批次标识
"""
...
def claim_next(self, *, owner: str, lease_seconds: int = 900) -> Optional[SearchTaskSnapshot]:
"""
按优先级和稳定游标认领下一条可执行任务。
:param owner: 本次认领者的稳定标识
:param lease_seconds: 租约有效时长,单位为秒
:return: 已认领且带租约令牌的任务;当前没有可执行任务时返回 None
"""
...
def finish_task(
self,
*,
task_id: str,
lease_token: str,
state: str,
error: Optional[str] = None,
) -> bool:
"""
以租约令牌收口任务,并推进所属批次聚合状态(含 skipped)。
:param task_id: 持久搜索任务的稳定标识
:param lease_token: 认领时取得的租约令牌,防止旧执行者覆盖新状态
:param state: 拟写入的终止状态,例如 completed、failed、cancelled 或 skipped
:param error: 可展示的失败摘要;无错误时为 None
:return: 任务仍归该租约且成功收口时返回 True,否则返回 False
"""
...
def update_task_phase(
self,
*,
task_id: str,
lease_token: str,
phase: str,
current_site_id: Optional[int] = None,
) -> bool:
"""
以当前租约令牌更新用户可见阶段和正在处理的站点。
:param task_id: 持久搜索任务的稳定标识
:param lease_token: 认领时取得的租约令牌,防止旧执行者覆盖新状态
:param phase: 任务当前阶段名称,用于进度展示和恢复
:param current_site_id: 当前处理的站点主键;None 表示未进入站点
:return: 任务仍归该租约且阶段写入成功时返回 True,否则返回 False
"""
...
def release_task(
self,
*,
task_id: str,
lease_token: str,
cancelled: bool = False,
) -> bool:
"""
释放尚未完成的任务租约,供停止或取消后恢复。
:param task_id: 持久搜索任务的稳定标识
:param lease_token: 认领时取得的租约令牌,防止旧执行者覆盖新状态
:param cancelled: 是否将任务按取消状态结束;False 表示重新排队
:return: 当前租约成功释放或取消收口时返回 True,否则返回 False
"""
...
def defer_task(
self,
*,
task_id: str,
lease_token: str,
available_at: str,
phase: str = "waiting_site_budget",
message: Optional[str] = None,
pending_site_ids: Optional[tuple[int, ...]] = None,
) -> bool:
"""
延后任务并保存等待原因与待搜站点;省略站点时保留已有游标。
:param task_id: 持久搜索任务的稳定标识
:param lease_token: 认领时取得的租约令牌,防止旧执行者覆盖新状态
:param available_at: 下一次允许认领任务的 UTC 时间文本
:param phase: 任务当前阶段名称,用于进度展示和恢复
:param message: 延后原因的可读说明;None 表示无附加说明
:param pending_site_ids: 尚未完成的站点标识;None 保留已有站点进度
:return: 当前租约成功转为等待或因取消请求结束时返回 True;租约失效时返回 False
"""
...
def is_cancel_requested(self, task_id: str) -> bool:
"""
判断任务或所属批次是否已请求取消。
:param task_id: 持久搜索任务的稳定标识
:return: 任务或所属批次已经请求取消时返回 True
"""
...
def request_cancel(self, batch_id: str) -> bool:
"""
请求取消批次,并立即终止尚未发出的排队任务。
:param batch_id: 持久搜索批次的稳定标识
:return: 找到可取消的批次并接受请求时返回 True;批次不存在或已完成、失败、取消时返回 False
"""
...
def get_batch(self, batch_id: str) -> Optional[SearchBatchSnapshot]:
"""
按稳定批次 ID 返回当前聚合状态。
:param batch_id: 持久搜索批次的稳定标识
:return: 对应批次的最新快照;不存在时返回 None
"""
...
def claim_site(
self,
*,
site_id: int,
owner: str,
lease_seconds: int,
minimum_interval: float = 0,
) -> SiteBudgetClaim:
"""
认领一个站点的唯一在途搜索预算。
:param site_id: 目标站点配置的主键
:param owner: 本次认领者的稳定标识
:param lease_seconds: 租约有效时长,单位为秒
:param minimum_interval: 两次站点请求的最小间隔,单位为秒,默认 0 不加额外间隔
:return: 包含认领结果、当前租约或等待原因与重试时间的快照
"""
...
def finish_site(
self,
*,
site_id: int,
lease_token: str,
outcome: str,
next_allowed_at: str,
error: Optional[str] = None,
) -> bool:
"""
释放站点预算并写入错误冷却和恢复状态。
:param site_id: 目标站点配置的主键
:param lease_token: 认领时取得的租约令牌,防止旧执行者覆盖新状态
:param outcome: 本次站点请求的结果分类,用于决定失败累计和冷却
:param next_allowed_at: 站点下一次允许请求的 UTC 时间文本
:param error: 可展示的失败摘要;无错误时为 None
:return: 租约仍匹配且完成释放时返回 True,否则返回 False
"""
...