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

443 lines
16 KiB
Python

"""
订阅治理轮次日志的聚合与格式化。
"""
from __future__ import annotations
import time
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Collection, Literal, Mapping, Optional
from uuid import uuid4
from app.application.subscription.execution import (
SearchBatchSnapshot,
SubscriptionExecutionContext,
SubscriptionSearchRepository,
)
from app.application.subscription.sitebudget import SubscriptionSiteBudgetMetrics
from app.runtime.log import bind_log_scope, logger
SearchTaskOutcome = Literal["completed", "skipped", "failed", "cancelled", "requeued"]
_SEARCH_SOURCE_NAMES = {
"manual": "手动订阅搜索",
"targeted": "指定订阅搜索",
"new": "新订阅自动搜索",
"fallback": "订阅定时检查",
"resume": "等待中的订阅搜索",
"inline": "订阅搜索",
}
_SEARCH_STATE_NAMES = {
"completed": "已完成",
"failed": "部分订阅没有完成",
"cancelled": "已停止",
"skipped": "部分订阅这次未搜索",
"queued": "部分订阅稍后继续",
"running": "仍在处理中",
"cancelling": "正在停止",
"stopped": "部分订阅稍后继续",
}
_MATCH_STATE_NAMES = {
"completed": "检查完成",
"failed": "部分订阅没有完成",
"cancelled": "已停止",
"skipped": "部分订阅这次未检查",
"stopped": "已停止,部分订阅这次未检查",
}
def _search_source_name(source: str) -> str:
"""
返回搜索来源对应的可读名称。
"""
return _SEARCH_SOURCE_NAMES.get(source, "订阅搜索")
def _search_state_name(state: str) -> str:
"""
返回搜索结束状态对应的可读说明。
"""
return _SEARCH_STATE_NAMES.get(state, "已结束")
def _match_state_name(state: str) -> str:
"""
返回订阅资源检查状态对应的可读说明。
"""
return _MATCH_STATE_NAMES.get(state, "已结束")
def batch_progress_text(batch: Optional[SearchBatchSnapshot]) -> str:
"""
把批次聚合终态转为兼容进度文案。
"""
if batch is None:
return "搜索已安排"
if batch.state == "failed":
return "搜索结束,部分订阅没有完成"
if batch.state == "cancelled":
return "搜索已停止"
if batch.state == "skipped":
return "搜索结束,部分订阅这次未搜索"
if batch.state in {"queued", "running", "cancelling"}:
return "搜索已安排,系统正在依次处理"
if batch.skipped_count:
return "搜索结束,部分订阅这次未搜索"
return "搜索完成"
def inline_search_result(total: int, finished: int) -> tuple[str, dict[str, int]]:
"""
返回兼容搜索的真实终态文案与计数。
"""
text = (
"搜索完成"
if finished == total
else "搜索结束,部分订阅这次没有完成"
)
return text, {"total": total, "finished": finished}
def batch_finished_count(
batch: Optional[SearchBatchSnapshot],
fallback: int,
) -> int:
"""
返回批次所有终态任务数;批次暂不可读时使用本轮实际完成数。
"""
if batch is None:
return fallback
return (
batch.finished_count
+ batch.failed_count
+ batch.cancelled_count
+ batch.skipped_count
)
def finish_returned_search_task(
*,
queue: SubscriptionSearchRepository,
task_id: str,
lease_token: str,
subscription_id: int,
execution_context: SubscriptionExecutionContext,
system_stopped: bool,
cancel_requested: bool,
) -> tuple[Optional[int], SearchTaskOutcome, Optional[str]]:
"""
按 TTL、停机和取消边界收口正常返回的搜索任务。
"""
download_started = execution_context.download_started and (
not execution_context.incremental_search or execution_context.scan_finished
)
if execution_context.is_expired() and not (system_stopped or cancel_requested):
if download_started:
queue.finish_task(
task_id=task_id,
lease_token=lease_token,
state="completed",
error="下载已经提交,虽然搜索用时较长,结果仍然有效",
)
return subscription_id, "completed", "ttl_timeout"
queue.finish_task(
task_id=task_id,
lease_token=lease_token,
state="failed",
error="这次搜索用时过长,已停止,可稍后重试",
)
return None, "failed", "ttl_timeout"
if system_stopped:
if download_started:
queue.finish_task(
task_id=task_id,
lease_token=lease_token,
state="completed",
error="下载已经提交,系统停止后仍保留这次结果",
)
return subscription_id, "completed", "system_stop"
queue.release_task(task_id=task_id, lease_token=lease_token)
return None, "requeued", "system_stop"
if cancel_requested:
if download_started:
queue.finish_task(
task_id=task_id,
lease_token=lease_token,
state="completed",
error="下载已经提交,无法撤回,已保留这次结果",
)
return subscription_id, "completed", "cancelled"
queue.release_task(task_id=task_id, lease_token=lease_token, cancelled=True)
return None, "cancelled", "cancelled"
queue.finish_task(task_id=task_id, lease_token=lease_token, state="completed")
return subscription_id, "completed", None
@dataclass(slots=True)
class MatchExecutionSummary:
"""
保存一次 Match 批次的实际订阅执行结果。
:param total: 本轮计划检查的订阅总数
:param candidate_count: 本轮看到的候选资源数量
:param site_count: 本轮涉及的不同站点数量
:param run_id: 本轮观测的随机标识,默认自动生成
:param started_at: 本轮开始时的性能计数器值,单位为秒,用于统计耗时
:param completed: 本轮成功完成的订阅数量
:param skipped: 本轮跳过的订阅数量
:param failed: 本轮失败的订阅数量
:param admission_conflicts: 因同订阅已有执行者而未获得准入的次数
:param cancelled: 本轮取消的订阅数量
:param ttl_timeouts: 超过协作执行截止时间的次数
:param release_failures: 释放订阅执行所有权失败的次数
"""
total: int = 0
candidate_count: int = 0
site_count: int = 0
run_id: str = field(default_factory=lambda: uuid4().hex)
started_at: float = field(default_factory=time.perf_counter)
completed: int = 0
skipped: int = 0
failed: int = 0
admission_conflicts: int = 0
cancelled: int = 0
ttl_timeouts: int = 0
release_failures: int = 0
@classmethod
def from_candidates(
cls,
candidates: Mapping[str, Collection[object]],
) -> MatchExecutionSummary:
"""
按站点分组的完整候选快照初始化轮次计数。
"""
return cls(
candidate_count=sum(len(contexts) for contexts in candidates.values()),
site_count=len(candidates),
)
@property
def finished(self) -> int:
"""
返回已经收口的订阅数量。
"""
return self.completed + self.skipped + self.failed
def record(self, outcome: Optional[str], reason: Optional[str] = None) -> None:
"""
将单订阅结果归入稳定的完成、跳过或失败计数。
"""
if outcome == "completed":
self.completed += 1
elif outcome == "skipped":
self.skipped += 1
else:
self.failed += 1
if reason == "admission_conflict":
self.admission_conflicts += 1
elif reason == "cancelled":
self.cancelled += 1
elif reason == "ttl_timeout":
self.ttl_timeouts += 1
@property
def state(self) -> str:
"""
按已处理数量和任务终态生成轮次状态。
"""
if self.finished < self.total:
return "stopped"
if self.failed:
return "failed"
if self.skipped:
return "skipped"
return "completed"
def start_log(self) -> str:
"""
构造不暴露内部标记的资源检查开始日志。
"""
return (
f"开始检查订阅资源,共 {self.total} 个订阅、{self.candidate_count} 个资源,"
f"来自 {self.site_count} 个站点。"
)
def finish_log(self) -> str:
"""
构造可直接阅读的资源检查结束日志。
"""
elapsed_seconds = time.perf_counter() - self.started_at
message = (
f"订阅资源检查结束:{_match_state_name(self.state)}。"
f"本次检查 {self.finished}/{self.total} 个订阅,完成 {self.completed} 个,"
f"这次未检查 {self.skipped} 个,失败 {self.failed} 个;"
f"共检查 {self.candidate_count} 个资源,来自 {self.site_count} 个站点,"
f"用时 {elapsed_seconds:.1f} 秒。"
)
if self.admission_conflicts:
message += f"其中 {self.admission_conflicts} 个订阅正在处理中,本次没有重复检查。"
if self.cancelled:
message += f"另有 {self.cancelled} 个订阅已停止。"
if self.ttl_timeouts:
message += f"另有 {self.ttl_timeouts} 个订阅检查时间过长,已停止。"
if self.release_failures:
message += f"另有 {self.release_failures} 个订阅的搜索状态没有正常恢复,系统稍后会继续检查。"
return message
def as_data(self, current: Optional[int] = None) -> dict[str, int]:
"""
构造供进度消费者读取的实际计数。
"""
data = {
"total": self.total,
"finished": self.finished,
"completed": self.completed,
"skipped": self.skipped,
"failed": self.failed,
}
if current is not None:
data["current"] = current
return data
@dataclass(slots=True)
class SearchExecutionSummary:
"""
聚合一次 Search 消费轮次的任务、站点与候选观测。
:param source: 触发本轮执行的入口,例如手工搜索或自动调度
:param requested: 本次请求涉及的订阅数量
:param run_id: 本轮观测的随机标识,默认自动生成
:param batch_id: 持久搜索批次的稳定标识
:param coalesced: 已与现有任务合并的请求数量
:param started_at: 本轮开始时的性能计数器值,单位为秒,用于统计耗时
:param completed: 本轮成功完成的订阅数量
:param skipped: 本轮跳过的订阅数量
:param failed: 本轮失败的订阅数量
:param cancelled: 本轮取消的订阅数量
:param requeued: 本轮已退回队列等待后续处理的任务数
:param admission_conflicts: 因同订阅已有执行者而未获得准入的次数
:param ttl_timeouts: 超过协作执行截止时间的次数
:param release_failures: 释放订阅执行所有权失败的次数
:param consumer_conflicts: 已有搜索消费者运行导致本轮未取得消费权的次数
:param stopped: 本轮是否因系统停止而提前结束
:param round_failed: 本轮整体是否失败,用于确定汇总状态
:param site_metrics: 本轮共享的站点请求、冷却和候选统计器
"""
source: str
requested: int
run_id: str = field(default_factory=lambda: uuid4().hex)
batch_id: Optional[str] = None
coalesced: int = 0
started_at: float = field(default_factory=time.perf_counter)
completed: int = 0
skipped: int = 0
failed: int = 0
cancelled: int = 0
requeued: int = 0
admission_conflicts: int = 0
ttl_timeouts: int = 0
release_failures: int = 0
consumer_conflicts: int = 0
stopped: bool = False
round_failed: bool = False
site_metrics: SubscriptionSiteBudgetMetrics = field(
default_factory=SubscriptionSiteBudgetMetrics
)
@property
def processed(self) -> int:
"""
返回本轮实际取得并收口或退回队列的任务数。
"""
return self.completed + self.skipped + self.failed + self.cancelled + self.requeued
def record(self, outcome: SearchTaskOutcome, reason: Optional[str] = None) -> None:
"""
记录一个任务结果,并单独统计冲突、取消和 TTL。
"""
if outcome == "completed":
self.completed += 1
elif outcome == "skipped":
self.skipped += 1
elif outcome == "failed":
self.failed += 1
elif outcome == "cancelled":
self.cancelled += 1
else:
self.requeued += 1
if reason == "admission_conflict":
self.admission_conflicts += 1
elif reason == "ttl_timeout":
self.ttl_timeouts += 1
def start_log(self) -> str:
"""
构造不暴露内部术语的 Search 开始日志。
"""
message = f"开始{_search_source_name(self.source)},共 {self.requested} 个订阅"
if self.coalesced:
message += f",其中 {self.coalesced} 个已经在处理中"
return f"{message}。"
def finish_log(self, batch: Optional[SearchBatchSnapshot] = None) -> str:
"""
构造可直接阅读的 Search 结束日志。
"""
sites = self.site_metrics.snapshot()
if self.round_failed:
state = "failed"
elif batch is not None:
state = batch.state
elif self.consumer_conflicts:
state = "skipped"
elif self.requeued or self.stopped:
state = "stopped"
elif self.failed:
state = "failed"
elif self.cancelled:
state = "cancelled"
elif self.skipped:
state = "skipped"
else:
state = "completed"
elapsed_seconds = time.perf_counter() - self.started_at
message = (
f"{_search_source_name(self.source)}结束:{_search_state_name(state)}。"
f"本次处理 {self.processed}/{self.requested} 个,完成 {self.completed} 个,"
f"这次未搜索 {self.skipped} 个,失败 {self.failed} 个,"
f"稍后继续 {self.requeued} 个,已停止 {self.cancelled} 个;"
f"访问 {sites.site_count} 个站点,发出 {sites.request_count} 次请求,"
f"找到 {sites.candidate_count} 个资源,用时 {elapsed_seconds:.1f} 秒。"
)
unfinished_cleanup = self.release_failures + sites.release_failure_count
if unfinished_cleanup:
message += f"另有 {unfinished_cleanup} 个搜索状态没有正常恢复,系统稍后会继续处理。"
return message
@contextmanager
def subscription_log_scope(
subscription_id: int,
source: str,
operation_id: Optional[str] = None,
) -> Iterator[None]:
"""
隔离单订阅执行日志,并在所有退出路径汇总被限流的诊断详情。
"""
identity = operation_id or uuid4().hex
with bind_log_scope("subscriptions", subscription_id, identity, source) as scope:
before = scope.suppressed
try:
yield
finally:
if scope.operation_id == identity and scope.suppressed > before:
logger.scoped_info(f"本轮合并或省略重复诊断详情 {scope.suppressed - before} 条")