Merge pull request #6561 from InfinityPacer/codex/subscription-governance-logging

This commit is contained in:
jxxghp
2026-09-03 12:10:13 +08:00
committed by GitHub
11 changed files with 882 additions and 162 deletions

View File

@@ -0,0 +1,254 @@
"""订阅治理轮次日志的聚合与格式化。"""
from __future__ import annotations
import time
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
SearchTaskOutcome = Literal["completed", "skipped", "failed", "cancelled", "requeued"]
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
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 批次的实际订阅执行结果。"""
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:
"""构造 Match 轮次开始日志。"""
return (
"订阅治理轮次开始: operation=match "
f"run_id={self.run_id} subscriptions={self.total} "
f"sites={self.site_count} candidates={self.candidate_count}"
)
def finish_log(self) -> str:
"""构造 Match 轮次结束日志,completed 表示任务处理完成而非订阅成功。"""
elapsed_ms = (time.perf_counter() - self.started_at) * 1000
return (
"订阅治理轮次结束: operation=match "
f"run_id={self.run_id} state={self.state} subscriptions={self.total} "
f"processed={self.finished} task_completed={self.completed} "
f"task_skipped={self.skipped} task_failed={self.failed} "
f"admission_conflicts={self.admission_conflicts} cancelled={self.cancelled} "
f"ttl_timeouts={self.ttl_timeouts} release_failures={self.release_failures} "
f"sites={self.site_count} candidates={self.candidate_count} "
f"duration_ms={elapsed_ms:.1f}"
)
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 消费轮次的任务、站点与候选观测。"""
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
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 轮次开始日志。"""
return (
"订阅治理轮次开始: operation=search "
f"run_id={self.run_id} batch_id={self.batch_id or '-'} source={self.source} "
f"subscriptions={self.requested} coalesced={self.coalesced}"
)
def finish_log(self, batch: Optional[SearchBatchSnapshot] = None) -> str:
"""构造 Search 轮次结束日志,任务完成与订阅完成保持分离。"""
sites = self.site_metrics.snapshot()
if 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_ms = (time.perf_counter() - self.started_at) * 1000
return (
"订阅治理轮次结束: operation=search "
f"run_id={self.run_id} batch_id={self.batch_id or '-'} source={self.source} "
f"state={state} subscriptions={self.requested} processed={self.processed} "
f"task_completed={self.completed} task_skipped={self.skipped} "
f"task_failed={self.failed} task_cancelled={self.cancelled} task_requeued={self.requeued} "
f"admission_conflicts={self.admission_conflicts} cancelled={self.cancelled} "
f"ttl_timeouts={self.ttl_timeouts} consumer_conflicts={self.consumer_conflicts} "
f"sites={sites.site_count} site_requests={sites.request_count} "
f"site_failures={sites.failure_count} site_cooldown_skips={sites.cooldown_skip_count} "
f"candidates={sites.candidate_count} cooldown_seconds={sites.cooldown_seconds:.1f} "
f"release_failures={self.release_failures + sites.release_failure_count} "
f"duration_ms={elapsed_ms:.1f}"
)

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Callable, Optional, Protocol
@@ -35,6 +36,74 @@ class SiteBudgetClaim:
lease_token: Optional[str] = None
@dataclass(frozen=True, slots=True)
class SubscriptionSiteBudgetMetricsSnapshot:
"""一次订阅搜索轮次内的站点预算观测快照。"""
site_count: int
request_count: int
candidate_count: int
failure_count: int
cooldown_skip_count: int
release_failure_count: int
cooldown_seconds: float
class SubscriptionSiteBudgetMetrics:
"""线程安全聚合站点请求、失败、冷却与租约收口结果。"""
def __init__(self) -> None:
"""初始化一次搜索轮次共享的计数器。"""
self._lock = threading.Lock()
self._site_ids: set[int] = set()
self._request_count = 0
self._candidate_count = 0
self._failure_count = 0
self._cooldown_skip_count = 0
self._release_failure_count = 0
self._cooldown_seconds = 0.0
def record_unavailable(self, site_id: int) -> None:
"""记录因在途租约或错误冷却而未发出的站点请求。"""
with self._lock:
self._site_ids.add(site_id)
self._cooldown_skip_count += 1
def record_request(self, site_id: int, candidate_count: int) -> None:
"""记录一个真实站点请求及其返回候选数。"""
with self._lock:
self._site_ids.add(site_id)
self._request_count += 1
self._candidate_count += max(0, candidate_count)
def record_finish(self, *, outcome: str, delay: float, released: bool) -> None:
"""记录外站结果、错误冷却时间和租约释放结果。"""
with self._lock:
if outcome not in {"success", "skipped"}:
self._failure_count += 1
self._cooldown_seconds += max(0.0, delay)
if not released:
self._release_failure_count += 1
def record_release_failure(self) -> None:
"""记录未进入正常结果收口的站点租约释放失败。"""
with self._lock:
self._release_failure_count += 1
def snapshot(self) -> SubscriptionSiteBudgetMetricsSnapshot:
"""返回可安全跨线程读取的不可变观测结果。"""
with self._lock:
return SubscriptionSiteBudgetMetricsSnapshot(
site_count=len(self._site_ids),
request_count=self._request_count,
candidate_count=self._candidate_count,
failure_count=self._failure_count,
cooldown_skip_count=self._cooldown_skip_count,
release_failure_count=self._release_failure_count,
cooldown_seconds=round(self._cooldown_seconds, 3),
)
class SubscriptionSiteBudgetRepository(Protocol):
"""站点预算租约和冷却状态的持久化端口。"""
@@ -79,6 +148,7 @@ class SubscriptionSiteBudget:
lease_seconds: int = 900,
clock: Callable[[], datetime] = _utc_now,
phase_changed: Optional[Callable[[str, Optional[int]], None]] = None,
metrics: Optional[SubscriptionSiteBudgetMetrics] = None,
) -> None:
"""保存持久化端口及可注入的时钟和阶段回调。"""
self._repository = repository
@@ -88,6 +158,7 @@ class SubscriptionSiteBudget:
self._lease_seconds = max(1, lease_seconds)
self._clock = clock
self._phase_changed = phase_changed
self._metrics = metrics
def acquire(self, site_id: int) -> SiteBudgetClaim:
"""只认领一次指定站点,未就绪时留待下一次正常调度。"""
@@ -100,6 +171,8 @@ class SubscriptionSiteBudget:
if claim.acquired:
self._report_phase("searching", site_id)
return claim
if self._metrics:
self._metrics.record_unavailable(site_id)
self._report_phase("waiting_site_budget", site_id)
raise SubscriptionSiteBudgetUnavailable(
site_id=site_id,
@@ -111,20 +184,38 @@ class SubscriptionSiteBudget:
if self._phase_changed:
self._phase_changed(phase, site_id)
def record_request(self, site_id: int, candidate_count: int) -> None:
"""把真实请求及返回候选数写入轮次聚合器。"""
if self._metrics:
self._metrics.record_request(site_id, candidate_count)
def record_release_failure(self) -> None:
"""把站点租约释放异常写入轮次聚合器。"""
if self._metrics:
self._metrics.record_release_failure()
def finish(self, claim: SiteBudgetClaim, observation: SiteSearchObservation) -> bool:
"""正常结果立即释放站点,错误结果按类别写入冷却。"""
if not claim.lease_token:
self.record_release_failure()
return False
outcome = observation.outcome if observation.attempted else "skipped"
delay = self._next_delay(outcome, claim.consecutive_failures)
next_allowed_at = (self._clock() + timedelta(seconds=delay)).isoformat(timespec="seconds")
return self._repository.finish_site(
released = self._repository.finish_site(
site_id=claim.site_id,
lease_token=claim.lease_token,
outcome=outcome,
next_allowed_at=next_allowed_at,
error=observation.error,
)
if self._metrics:
self._metrics.record_finish(
outcome=outcome,
delay=delay,
released=released,
)
return released
def _next_delay(self, outcome: str, consecutive_failures: int) -> float:
"""正常或本地跳过立即恢复,错误按连续失败次数退避。"""

View File

@@ -59,7 +59,7 @@ def _wait_for_site_request(site: SiteIndexer) -> None:
"""同步等待当前站点预约时间,不阻塞其他站点的线程任务。"""
delay = _reserve_site_request(site)
if delay > 0:
logger.info(f"{site.get('name')} 站点流控等待 {delay:.1f} 秒 ...")
logger.debug(f"{site.get('name')} 站点流控等待 {delay:.1f} 秒 ...")
time.sleep(delay)
@@ -67,7 +67,7 @@ async def _async_wait_for_site_request(site: SiteIndexer) -> None:
"""异步等待当前站点预约时间,让其他站点继续并发搜索。"""
delay = _reserve_site_request(site)
if delay > 0:
logger.info(f"{site.get('name')} 站点流控等待 {delay:.1f} 秒 ...")
logger.debug(f"{site.get('name')} 站点流控等待 {delay:.1f} 秒 ...")
await asyncio.sleep(delay)
@@ -216,7 +216,7 @@ class _SearchProviderSyncOwner(_SearchOwnerBase):
claim = budget.acquire(site_id)
except SubscriptionSiteBudgetUnavailable as error:
self.record_subscription_site_budget_failure(str(error))
logger.info(str(error))
logger.debug(str(error))
return []
with capture_site_search_observation() as observation:
try:
@@ -228,6 +228,7 @@ class _SearchProviderSyncOwner(_SearchOwnerBase):
page=page,
)
except Exception as error:
budget.record_request(site_id, 0)
report_site_search_outcome(
attempted=True,
outcome="error",
@@ -235,6 +236,7 @@ class _SearchProviderSyncOwner(_SearchOwnerBase):
)
raise
else:
budget.record_request(site_id, len(result or []))
if observation.attempted and observation.outcome not in {"success", "skipped"}:
failure = observation.error or observation.outcome
self.record_subscription_site_budget_failure(
@@ -243,8 +245,14 @@ class _SearchProviderSyncOwner(_SearchOwnerBase):
return result
finally:
try:
budget.finish(claim, observation)
released = budget.finish(claim, observation)
if not released:
logger.error(
f"订阅站点预算释放失败: site_id={site_id} "
f"site={site.get('name') or '-'}"
)
except Exception as error: # noqa: BLE001
budget.record_release_failure()
logger.error(
f"站点 {site.get('name') or site_id} 搜索预算收口失败:{str(error)}",
exc_info=True,
@@ -343,7 +351,7 @@ class _SearchProviderSyncOwner(_SearchOwnerBase):
search_keyword=search_keyword,
media_type=media_type,
)
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
logger.debug(f"站点搜索进度:{finish_count} / {total_num}")
progress.update(
value=finish_count / total_num * 100,
text=(f"正在搜索{keyword or ''},已完成 {finish_count} / {total_num} 个请求 ..."),
@@ -401,7 +409,12 @@ class _SearchProviderSyncOwner(_SearchOwnerBase):
value=100,
text=(f"站点搜索完成,有效资源数:{len(results)},总耗时 {elapsed} 秒"),
)
logger.info(f"站点搜索完成,有效资源数:{len(results)},总耗时 {elapsed} 秒")
log = (
logger.debug
if isinstance(getattr(self, "_subscription_site_budget", None), SubscriptionSiteBudget)
else logger.info
)
log(f"站点搜索完成,有效资源数:{len(results)},总耗时 {elapsed} 秒")
return results
finally:
progress.end()
@@ -550,7 +563,7 @@ class SearchProviderOwner(_SearchProviderSyncOwner):
logger.debug(f"{batch.site.get('name')} {label}第 {batch.page} 页返回 {len(batch.items)} 条,停止继续翻页")
progress_value = batch.finished / batch.total * 100
progress_text = f"正在搜索{label}{keyword or ''},已完成 {batch.finished} / {batch.total} 个请求 ..."
logger.info(f"站点{label}搜索进度:{batch.finished} / {batch.total}")
logger.debug(f"站点{label}搜索进度:{batch.finished} / {batch.total}")
await progress.update(value=progress_value, text=progress_text)
return {
"type": "append",

View File

@@ -1,7 +1,6 @@
"""订阅候选准备、身份复核与资源匹配"""
import copy
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Callable, Dict, List, Literal, Optional
@@ -12,8 +11,13 @@ from app.application.subscription.contract import (
build_subscribe_meta,
subscribe_media_key,
)
from app.application.subscription.execution import SubscriptionExecutionContext
from app.application.subscription.execution import (
SubscriptionExecutionAdmission,
SubscriptionExecutionContext,
SubscriptionExecutionLease,
)
from app.application.subscription.facts import FreshFactLease
from app.application.subscription.observability import MatchExecutionSummary
from app.application.subscription.sitebudget import SubscriptionSearchCancelled
from app.application.torrent.download import TorrentHelper
from app.chain.media import MediaChain
@@ -39,46 +43,9 @@ from app.schemas.types import (
_MatchOutcome = Literal["completed", "skipped", "failed"]
@dataclass(slots=True)
class _MatchExecutionSummary:
"""保存一次 Match 批次的实际订阅执行结果。"""
total: int
completed: int = 0
skipped: int = 0
failed: int = 0
@property
def finished(self) -> int:
"""返回已经收口的订阅数量。"""
return self.completed + self.skipped + self.failed
def record(self, outcome: Optional[str]) -> None:
"""将单订阅结果归入稳定的完成、跳过或失败计数。"""
if outcome == "completed":
self.completed += 1
elif outcome == "skipped":
self.skipped += 1
else:
self.failed += 1
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
def _report_match_progress(
progress_callback: Optional[Callable[..., None]],
summary: _MatchExecutionSummary,
summary: MatchExecutionSummary,
*,
value: float,
text: str,
@@ -93,6 +60,58 @@ def _report_match_progress(
)
def _match_stop_reason(context: SubscriptionExecutionContext) -> Optional[str]:
"""区分 Match 安全停止来自 TTL 还是取消请求。"""
if context.is_expired():
return "ttl_timeout"
if context.is_cancel_requested():
return "cancelled"
return None
def _match_result_reason(
outcome: Optional[str],
context: SubscriptionExecutionContext,
) -> Optional[str]:
"""为跳过结果补充停止边界或业务原因。"""
if outcome != "skipped":
return None
return _match_stop_reason(context) or "business_skip"
def _release_match_admission(
admission: SubscriptionExecutionAdmission,
lease: SubscriptionExecutionLease,
subscription_id: int,
summary: MatchExecutionSummary,
) -> None:
"""释放 Match 订阅准入,并让 token 冲突即时可见。"""
if admission.release(lease):
return
summary.release_failures += 1
logger.error(
"订阅准入释放失败: operation=match "
f"subscription_id={subscription_id} run_id={summary.run_id}"
)
def _report_match_finished(
progress_callback: Optional[Callable[..., None]],
summary: MatchExecutionSummary,
) -> None:
"""按实际计数发布 Match 最终进度。"""
if not progress_callback:
return
final_text = "订阅资源匹配完成"
if summary.finished < summary.total:
final_text = "订阅资源匹配已停止,部分订阅未执行"
elif summary.failed:
final_text = "订阅资源匹配完成,部分订阅失败"
elif summary.skipped:
final_text = "订阅资源匹配完成,部分订阅跳过"
_report_match_progress(progress_callback, summary, value=100, text=final_text)
def _is_title_match_allowed(
mediainfo: MediaInfo,
torrent_meta: MetaBase,
@@ -110,7 +129,7 @@ def _is_title_match_allowed(
torrent_meta=torrent_meta,
):
return True
logger.info(
logger.debug(
f"{torrent_info.site_name} - {torrent_info.title} 仅通过无年份别名命中且候选媒体身份无法确认,已跳过"
)
return False
@@ -225,7 +244,7 @@ def _prepare_subscription_match(
site_ids=set(sub_sites) if sub_sites else None,
)
if not routed_torrents:
logger.info(f"订阅 {subscribe.name} 本轮没有可能相关的资源,跳过资源匹配准备")
logger.debug(f"订阅 {subscribe.name} 本轮没有可能相关的资源,跳过资源匹配准备")
return None
mediainfo = fresh_fact_lease.get_or_load(
subscribe,
@@ -343,6 +362,8 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
progress_callback(value=0, text="正在预处理订阅资源 ...")
lock_acquired = False
summary = MatchExecutionSummary.from_candidates(torrents)
summary_started = False
try:
lock_acquired = self._acquire_run_lock("match", progress_callback)
if not lock_acquired:
@@ -354,7 +375,9 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
# 所有订阅
subscribes = self.subscription_repository.list(self.get_states_for_search("R"))
total_num = len(subscribes)
summary = _MatchExecutionSummary(total=total_num)
summary.total = total_num
logger.info(summary.start_log())
summary_started = True
if progress_callback:
_report_match_progress(
progress_callback,
@@ -375,13 +398,15 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
current=listed_subscribe.id,
)
outcome: Optional[str] = "skipped"
reason: Optional[str] = "not_eligible"
lease = self._subscription_execution_admission.try_acquire(
subscription_id=listed_subscribe.id,
operation="match",
ttl_seconds=self._SUBSCRIPTION_EXECUTION_TTL,
)
if lease is None:
logger.info(f"订阅 {listed_subscribe.name} 正在由其他通道处理,本轮匹配已跳过")
reason = "admission_conflict"
logger.debug(f"订阅 {listed_subscribe.name} 正在由其他通道处理,本轮匹配已跳过")
else:
current_subscribe = None
execution_context = SubscriptionExecutionContext(
@@ -392,13 +417,14 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
try:
current_subscribe = self.subscription_repository.get(listed_subscribe.id)
if current_subscribe is None:
logger.info(f"订阅 {listed_subscribe.id} 已不存在,本轮匹配跳过")
logger.debug(f"订阅 {listed_subscribe.id} 已不存在,本轮匹配跳过")
elif current_subscribe.state not in {"R", "P"}:
logger.info(
logger.debug(
f"订阅 {current_subscribe.name} 当前状态为 {current_subscribe.state},本轮匹配跳过"
)
elif execution_context.should_stop():
logger.info(f"订阅 {current_subscribe.name} 已取消或超时,本轮匹配跳过")
reason = _match_stop_reason(execution_context)
logger.debug(f"订阅 {current_subscribe.name} 已取消或超时,本轮匹配跳过")
else:
outcome = self._match_subscription(
subscribe=current_subscribe,
@@ -407,18 +433,26 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
fresh_fact_lease=fresh_fact_lease,
execution_context=execution_context,
)
reason = _match_result_reason(outcome, execution_context)
except SubscriptionSearchCancelled as err:
outcome = "skipped"
logger.info(f"订阅 {listed_subscribe.name} 匹配已取消:{str(err)}")
reason = _match_stop_reason(execution_context) or "cancelled"
logger.debug(f"订阅 {listed_subscribe.name} 匹配已取消:{str(err)}")
except Exception as err:
outcome = "failed"
reason = "error"
subscribe_name = (
current_subscribe.name if current_subscribe is not None else listed_subscribe.name
)
logger.error(f"订阅 {subscribe_name} 匹配失败:{str(err)}", exc_info=True)
finally:
self._subscription_execution_admission.release(lease)
summary.record(outcome)
_release_match_admission(
self._subscription_execution_admission,
lease,
listed_subscribe.id,
summary,
)
summary.record(outcome, reason)
_report_match_progress(
progress_callback,
summary,
@@ -430,21 +464,10 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
del processed_torrents
subscribes.clear()
del subscribes
if progress_callback:
final_text = "订阅资源匹配完成"
if summary.finished < summary.total:
final_text = "订阅资源匹配已停止,部分订阅未执行"
elif summary.failed:
final_text = "订阅资源匹配完成,部分订阅失败"
elif summary.skipped:
final_text = "订阅资源匹配完成,部分订阅跳过"
_report_match_progress(
progress_callback,
summary,
value=100,
text=final_text,
)
_report_match_finished(progress_callback, summary)
finally:
if summary_started:
logger.info(summary.finish_log())
if lock_acquired:
self._match_lock.release()
logger.debug(f"match Lock released at {datetime.now()}")
@@ -479,7 +502,7 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
"""在单条订阅准入期间完成匹配、下载和完成判定。"""
if execution_context.should_stop():
return "skipped"
logger.info(f"开始匹配订阅,标题:{subscribe.name} ...")
logger.debug(f"开始匹配订阅,标题:{subscribe.name} ...")
if subscribe.type == MediaType.MUSIC.value:
music_contexts = [context for contexts in processed_torrents.values() for context in contexts]
execution_context.report_phase("matching")
@@ -553,7 +576,7 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
torrent_meta.org_string, custom_words=custom_words_list
)
if apply_words:
logger.info(
logger.debug(
f"{torrent_info.site_name} - {torrent_info.title} 因订阅存在自定义识别词,重新识别元数据..."
)
# 重新识别元数据
@@ -595,7 +618,7 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
):
continue
# 匹配成功
logger.info(
logger.debug(
f"{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}"
)
torrent_mediainfo = mediainfo
@@ -635,7 +658,7 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
_context.candidate_recognized = True
_context.media_info_is_target = False
match_label = "媒体ID"
logger.info(
logger.debug(
f"{mediainfo.title_year} 通过{match_label}匹配到可选资源:"
f"{torrent_info.site_name} - {torrent_info.title}"
)
@@ -725,7 +748,7 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
context=_context,
priority=torrent_info.pri_order,
):
logger.info(
logger.debug(
f"{subscribe.name} 正在洗版,{torrent_info.title} "
f"优先级未达到当前模式的升级条件"
)
@@ -735,13 +758,13 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
and subscribe.current_priority
and torrent_info.pri_order <= subscribe.current_priority
):
logger.info(
logger.debug(
f"{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级"
)
continue
# 匹配成功
logger.info(f"{mediainfo.title_year} 匹配成功:{torrent_info.title}")
logger.debug(f"{mediainfo.title_year} 匹配成功:{torrent_info.title}")
# 自定义属性
torrent_mediainfo = apply_subscription_classification(
torrent_mediainfo,
@@ -757,7 +780,7 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
if not _match_context:
# 未匹配到资源
logger.info(f"{mediainfo.title_year} 未匹配到符合条件的资源")
logger.debug(f"{mediainfo.title_year} 未匹配到符合条件的资源")
self.finish_subscribe_or_not(
subscribe=subscribe, meta=meta, mediainfo=mediainfo, lefts=no_exists
)
@@ -767,7 +790,7 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
return "skipped"
# 开始批量择优下载
logger.info(f"{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源")
logger.debug(f"{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源")
downloads, lefts = self._SubscribeChain__download_best_version_with_full_pack_first(
contexts=_match_context,
no_exists=no_exists,

View File

@@ -20,6 +20,11 @@ from app.application.subscription.execution import (
SubscriptionExecutionContext,
SubscriptionSearchRepository,
)
from app.application.subscription.observability import (
SearchExecutionSummary,
SearchTaskOutcome,
finish_returned_search_task,
)
from app.application.subscription.query import SubscriptionQueryService
from app.application.subscription.sitebudget import (
SubscriptionSearchCancelled,
@@ -180,62 +185,8 @@ def _release_cancelled_or_stopped_search_task(
)
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,
) -> Optional[int]:
"""按 TTL、停机和取消边界收口正常返回的搜索任务。"""
download_started = execution_context.download_started
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
queue.finish_task(
task_id=task_id,
lease_token=lease_token,
state="failed",
error="订阅执行已超过协作截止时间",
)
return None
if system_stopped:
if download_started:
queue.finish_task(
task_id=task_id,
lease_token=lease_token,
state="completed",
error="停机请求晚于下载提交边界,已按实际结果完成",
)
return subscription_id
queue.release_task(task_id=task_id, lease_token=lease_token)
return None
if cancel_requested:
if download_started:
queue.finish_task(
task_id=task_id,
lease_token=lease_token,
state="completed",
error="取消请求晚于下载提交边界,已按实际结果完成",
)
return subscription_id
queue.release_task(task_id=task_id, lease_token=lease_token, cancelled=True)
return None
queue.finish_task(task_id=task_id, lease_token=lease_token, state="completed")
return subscription_id
class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
"""订阅主动搜索入口与持久队列消费 owner。"""
class _SubscribeSearchQueueCoordinator(_SubscribeOwnerBase):
"""订阅主动搜索入口、队列提交与消费协调 owner。"""
def _subscription_query(self) -> SubscriptionQueryService:
"""构造绑定订阅 Oper 的查询应用服务。"""
@@ -353,9 +304,12 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
return
subscribes = []
processed = []
summary: Optional[SearchExecutionSummary] = None
try:
subscribes = self._load_search_subscriptions(sid=sid, sids=sids, state=state)
total = len(subscribes)
summary = SearchExecutionSummary(source="inline", requested=total)
logger.info(summary.start_log())
if progress_callback:
progress_callback(
value=0,
@@ -365,9 +319,11 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
searchchain = SearchChain()
for index, subscribe in enumerate(subscribes, start=1):
if runtime_stop_state.is_system_stopped:
summary.stopped = True
break
self._report_search_progress(progress_callback, subscribe, index, total)
if self._defer_recent_subscription(subscribe):
summary.record("skipped", "recent_subscription")
continue
self._wait_before_scheduled_search(sid, sids, state, progress_callback)
lease = self._subscription_execution_admission.try_acquire(
@@ -376,7 +332,8 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
ttl_seconds=self._SUBSCRIPTION_EXECUTION_TTL,
)
if lease is None:
logger.info(f"订阅 {subscribe.name} 正在由其他通道处理,本轮搜索已跳过")
logger.debug(f"订阅 {subscribe.name} 正在由其他通道处理,本轮搜索已跳过")
summary.record("skipped", "admission_conflict")
continue
execution_context = SubscriptionExecutionContext(
lease=lease,
@@ -384,11 +341,13 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
cancel_requested=lambda: runtime_stop_state.is_system_stopped,
)
current = None
outcome: SearchTaskOutcome = "skipped"
reason: Optional[str] = "not_eligible"
try:
current = self.subscription_repository.get(subscribe.id)
if current is None or current.state == "S":
if current and current.state == "S":
logger.info(f"订阅 {current.name} 已暂停,本轮搜索已跳过")
logger.debug(f"订阅 {current.name} 已暂停,本轮搜索已跳过")
continue
processed_result = self._process_search_subscription(
current,
@@ -397,9 +356,15 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
)
processed.append(processed_result or current)
current = processed_result
outcome = "completed"
reason = None
except SubscriptionSearchCancelled:
logger.info(f"订阅 {subscribe.name} 搜索已在安全边界取消")
reason = "ttl_timeout" if execution_context.is_expired() else "cancelled"
outcome = "failed" if reason == "ttl_timeout" else "cancelled"
logger.debug(f"订阅 {subscribe.name} 搜索已在安全边界取消")
except Exception as err:
outcome = "failed"
reason = "error"
logger.error(f"订阅 {subscribe.name} 搜索失败:{str(err)}", exc_info=True)
finally:
try:
@@ -415,7 +380,14 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
exc_info=True,
)
finally:
self._subscription_execution_admission.release(lease)
released = self._subscription_execution_admission.release(lease)
if not released:
summary.release_failures += 1
logger.error(
"订阅准入释放失败: operation=search "
f"subscription_id={subscribe.id} run_id={summary.run_id}"
)
summary.record(outcome, reason)
self._report_search_progress(
progress_callback,
subscribe,
@@ -430,6 +402,8 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
finally:
subscribes.clear()
self._search_queue_lock.release()
if summary is not None:
logger.info(summary.finish_log())
logger.debug(f"search Lock released at {datetime.now()}")
def _execute_queued_search(
@@ -461,6 +435,13 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
),
)
total = len(subscribes)
summary = SearchExecutionSummary(
source=source,
requested=total,
batch_id=str(enqueued.batch.batch_id),
coalesced=enqueued.coalesced_count,
)
logger.info(summary.start_log())
if progress_callback:
progress_callback(
value=0,
@@ -476,11 +457,12 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
queue=queue,
limit=max(1, enqueued.created_count + enqueued.coalesced_count),
progress_callback=progress_callback,
summary=summary,
)
processed_subscribes = [item for item in subscribes if item.id in processed]
self._notify_manual_search(manual, sid, sids, subscribes, processed_subscribes)
batch = queue.get_batch(enqueued.batch.batch_id)
if progress_callback:
batch = queue.get_batch(enqueued.batch.batch_id)
progress_callback(
value=100,
text=_batch_progress_text(batch),
@@ -491,6 +473,7 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
"coalesced": enqueued.coalesced_count,
},
)
logger.info(summary.finish_log(batch))
return str(enqueued.batch.batch_id)
def _drain_search_queue(
@@ -499,10 +482,12 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
queue: SubscriptionSearchRepository,
limit: int,
progress_callback: Optional[Callable[..., None]],
summary: SearchExecutionSummary,
) -> set[int]:
"""有界消费可恢复任务;单任务失败不得阻止后续订阅。"""
if not self._search_queue_lock.acquire(blocking=False):
logger.debug("订阅搜索队列已有消费者,本轮仅保留持久任务")
summary.consumer_conflicts += 1
return set()
owner = f"subscribe-search:{uuid4().hex}"
processed: set[int] = set()
@@ -510,6 +495,7 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
searchchain = SearchChain()
for index in range(1, limit + 1):
if runtime_stop_state.is_system_stopped:
summary.stopped = True
break
task = queue.claim_next(owner=owner)
if task is None:
@@ -522,6 +508,7 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
index=index,
limit=limit,
progress_callback=progress_callback,
summary=summary,
)
if subscription_id is not None:
processed.add(subscription_id)
@@ -529,6 +516,10 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
self._search_queue_lock.release()
return processed
class _SubscribeSearchQueueOwner(_SubscribeSearchQueueCoordinator):
"""持久搜索任务执行与批次控制 owner。"""
def _execute_search_task(
self,
*,
@@ -539,10 +530,12 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
index: int,
limit: int,
progress_callback: Optional[Callable[..., None]],
summary: SearchExecutionSummary,
) -> Optional[int]:
"""执行一条已认领任务,返回实际进入搜索处理的订阅 ID。"""
if task.lease_token is None:
logger.error(f"订阅搜索任务 {task.task_id} 缺少租约令牌,跳过执行")
summary.record("failed", "missing_lease")
return None
task_id = str(task.task_id)
lease_token = task.lease_token
@@ -550,6 +543,7 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
stop_state = getattr(self, "stop_state", runtime_stop_state)
if cancelled():
queue.release_task(task_id=task_id, lease_token=lease_token, cancelled=True)
summary.record("cancelled", "cancelled")
return None
subscribe = self.subscription_repository.get(task.subscription_id)
if subscribe is None:
@@ -559,10 +553,12 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
state="cancelled",
error="订阅已不存在",
)
summary.record("cancelled", "missing_subscription")
return None
self._report_search_progress(progress_callback, subscribe, index, limit)
if self._defer_recent_subscription(subscribe):
_skip_search_task(queue, task, "订阅仍在新增保护期,本轮搜索已跳过")
summary.record("skipped", "recent_subscription")
return None
lease = self._subscription_execution_admission.try_acquire(
subscription_id=subscribe.id,
@@ -570,8 +566,9 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
ttl_seconds=self._SUBSCRIPTION_EXECUTION_TTL,
)
if lease is None:
logger.info(f"订阅 {subscribe.name} 正在由其他通道处理,本轮搜索已跳过")
logger.debug(f"订阅 {subscribe.name} 正在由其他通道处理,本轮搜索已跳过")
_skip_search_task(queue, task, "同一订阅正在由其他通道处理,本轮搜索已跳过")
summary.record("skipped", "admission_conflict")
return None
phase_changed = partial(_update_search_task_phase, queue, task_id, lease_token)
execution_context = SubscriptionExecutionContext(
@@ -591,9 +588,11 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
state="cancelled",
error="订阅已不存在",
)
summary.record("cancelled", "missing_subscription")
return None
if current.state == "S":
_skip_search_task(queue, task, "订阅已暂停,本轮搜索已跳过")
summary.record("skipped", "paused")
return None
searchchain.configure_subscription_site_budget(
SubscriptionSiteBudget(
@@ -602,6 +601,7 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
cancelled=execution_context.should_stop,
stop_state=stop_state,
phase_changed=phase_changed,
metrics=summary.site_metrics,
)
)
current = self._process_search_subscription(
@@ -611,7 +611,7 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
)
system_stopped = stop_state.is_system_stopped
cancel_requested = False if system_stopped else cancelled()
return _finish_returned_search_task(
subscription_id, outcome, reason = finish_returned_search_task(
queue=queue,
task_id=task_id,
lease_token=lease_token,
@@ -620,6 +620,8 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
system_stopped=system_stopped,
cancel_requested=cancel_requested,
)
summary.record(outcome, reason)
return subscription_id
except SubscriptionSearchCancelled:
if execution_context.is_expired() and not execution_context.is_cancel_requested():
queue.finish_task(
@@ -628,12 +630,18 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
state="failed",
error="订阅执行已超过协作截止时间",
)
summary.record("failed", "ttl_timeout")
else:
system_stopped = stop_state.is_system_stopped
_release_cancelled_or_stopped_search_task(
queue,
task_id,
lease_token,
stop_state.is_system_stopped,
system_stopped,
)
summary.record(
"requeued" if system_stopped else "cancelled",
"system_stop" if system_stopped else "cancelled",
)
except Exception as err:
logger.error(f"订阅 {subscribe.name} 搜索失败:{str(err)}", exc_info=True)
@@ -643,6 +651,7 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
state="failed",
error=str(err),
)
summary.record("failed", "error")
finally:
self._cleanup_search_task(
queue=queue,
@@ -653,6 +662,7 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
progress_callback=progress_callback,
index=index,
limit=limit,
summary=summary,
)
return None
@@ -667,6 +677,7 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
progress_callback: Optional[Callable[..., None]],
index: int,
limit: int,
summary: SearchExecutionSummary,
) -> None:
"""清理站点预算和订阅状态,并在所有异常路径释放 owner。"""
try:
@@ -683,7 +694,13 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
except Exception as err:
logger.error(f"订阅 {subscribe.name} 搜索后状态重置失败:{str(err)}", exc_info=True)
finally:
self._subscription_execution_admission.release(lease)
released = self._subscription_execution_admission.release(lease)
if not released:
summary.release_failures += 1
logger.error(
"订阅准入释放失败: operation=search "
f"subscription_id={subscribe.id} run_id={summary.run_id}"
)
self._report_search_progress(
progress_callback,
subscribe,
@@ -703,11 +720,15 @@ class _SubscribeSearchQueueOwner(_SubscribeOwnerBase):
)
if queue is None:
return
summary = SearchExecutionSummary(source="resume", requested=max(1, limit))
logger.info(summary.start_log())
self._drain_search_queue(
queue=queue,
limit=max(1, limit),
progress_callback=progress_callback,
summary=summary,
)
logger.info(summary.finish_log())
def cancel_search_batch(self, batch_id: str) -> bool:
"""请求取消持久搜索批次;未注入队列时返回失败。"""
@@ -764,7 +785,7 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner):
if sid or sids is not None or state not in {"R", "P"}:
return
sleep_time = random.randint(60, 300)
logger.info(f"订阅搜索随机休眠 {sleep_time} 秒 ...")
logger.debug(f"订阅搜索随机休眠 {sleep_time} 秒 ...")
if progress_callback:
progress_callback(text=f"订阅搜索随机休眠 {sleep_time} 秒后继续 ...")
time.sleep(sleep_time)
@@ -777,7 +798,7 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner):
) -> Optional[SubscriptionSnapshot]:
"""处理单个订阅,并返回下载后重新读取的状态快照。"""
_ensure_execution_active(execution_context)
logger.info(f"开始搜索订阅,标题:{subscribe.name} ...")
logger.debug(f"开始搜索订阅,标题:{subscribe.name} ...")
if subscribe.type == MediaType.MUSIC.value:
self._search_music_subscribe(subscribe, execution_context=execution_context)
return subscribe
@@ -830,7 +851,7 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner):
site_budget_failures = searchchain.consume_subscription_site_budget_failures()
_ensure_execution_active(execution_context)
if not contexts:
logger.warning(f"订阅 {subscribe.keyword or subscribe.name} 未搜索到资源")
logger.debug(f"订阅 {subscribe.keyword or subscribe.name} 未搜索到资源")
self.finish_subscribe_or_not(
subscribe=subscribe,
meta=meta,
@@ -841,7 +862,7 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner):
return subscribe
matched = self._filter_search_contexts(subscribe, contexts)
if not matched:
logger.warning(f"订阅 {subscribe.name} 没有符合过滤条件的资源")
logger.debug(f"订阅 {subscribe.name} 没有符合过滤条件的资源")
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, lefts=no_exists)
self._raise_site_budget_failures(site_budget_failures)
return subscribe
@@ -893,17 +914,17 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner):
media = context.media_info
if subscribe.best_version and media.type == MediaType.TV:
if not self._SubscribeChain__is_full_season_best_version_resource(torrent_meta, subscribe):
logger.info(f"{subscribe.name} 正在全集洗版,{torrent_info.title} 不是全集资源")
logger.debug(f"{subscribe.name} 正在全集洗版,{torrent_info.title} 不是全集资源")
continue
if not self._is_episode_range_covered(torrent_meta, subscribe):
logger.info(f"{subscribe.name} 正在洗版,{torrent_info.title} 不符合订阅集数范围")
logger.debug(f"{subscribe.name} 正在洗版,{torrent_info.title} 不符合订阅集数范围")
continue
if not self._SubscribeChain__prepare_best_version_tv_candidate(
subscribe,
context,
torrent_info.pri_order,
):
logger.info(f"{subscribe.name} 正在洗版,{torrent_info.title} 优先级未达到当前模式的升级条件")
logger.debug(f"{subscribe.name} 正在洗版,{torrent_info.title} 优先级未达到当前模式的升级条件")
continue
if (
subscribe.best_version
@@ -911,7 +932,7 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner):
and subscribe.current_priority
and torrent_info.pri_order <= subscribe.current_priority
):
logger.info(f"{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级")
logger.debug(f"{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级")
continue
media = apply_subscription_classification(
media,

View File

@@ -3,6 +3,7 @@
> 历史方案:本文件记录 2026-09-01 的上游接管与验证证据,不再作为当前实施合同。
> 其中 `CandidateBatch`、候选恢复账本、订阅下载提交持久账本和 `reconcile_required`
> 已由 2026-09-03 的简化方案废止;当前行为以代码、迁移和回归测试为准。
> 当前治理链路的运行日志验收以第 10 节为准。
>
> 状态:`completed(2026-09-01)`
> 当前叶:`none(全部叶已完成或按条件停止)`
@@ -417,7 +418,7 @@ RSS/Spider 保持现有逐站点串行刷新,不通过并发化换取几秒收
5. 24 小时兜底搜索无结果、部分结果、站点超时和 `429/403`;
6. 日常 Match 与兜底搜索重叠;
7. 手工刷新或手工搜索与后台任务重叠;
8. 下载器已接受但本地写回失败;
8. 下载器拒绝、超时或本地写回失败,并在下轮按普通订阅流程重新判断;
9. 停机、取消和进程重启后的恢复;
10. 单媒体服务器可用、慢响应和不可用。
@@ -432,9 +433,9 @@ RSS/Spider 保持现有逐站点串行刷新,不通过并发化换取几秒收
| 本地计算 | 候选预处理、每订阅匹配、完成对账、提交耗时 |
| TMDB | 请求数、缓存/租约命中、错误率、p50/p95 |
| 媒体服务器 | 请求数、p50/p95、超时和合并命中 |
| PT 站点 | 按站点请求数、峰值在途、最小间隔、429/403/超时、重试数 |
| PT 站点 | 按站点请求数、峰值在途、最小间隔、429/403/超时、错误冷却跳过数 |
| 正确性 | 命中集合、下载集合、缺集集合、完成/删除集合与基线一致 |
| 幂等 | 重复提交数、下载器不确定终态、恢复后重复任务数 |
| 并发 | 同订阅跨 Search/Match 准入冲突数、重复下载数、准入与站点租约释放失败数 |
| 生命周期 | 取消延迟、event-loop lag、遗留 task/worker/锁、shutdown 上限 |
### 7.3 门禁
@@ -475,8 +476,9 @@ RSS/Spider 保持现有逐站点串行刷新,不通过并发化换取几秒收
- 现有测试只证明了参数或局部调用,没有证明 TMDB 请求减少、订阅完成正确、站点压力不变和下载集合一致;
- 旧 checkpoint `c2d72280e` 与 10/100/500 数据只覆盖兜底搜索等待,不再是 A1B 的交付候选。
候选回写语义、完整缓存与本轮 delta、无损索引和真实性测试仍需由后续实现证明。治理文档现位于
`docs/refactor/` 并属于后端 Git 仓;文档状态与代码提交证据仍分别记录,不能用路线图状态替代行为验收。
本节所述候选回写、完整缓存与本轮 delta 属于已废止的历史实施路径,不再作为后续实现或日志验收指标。
治理文档现位于 `docs/refactor/` 并属于后端 Git 仓;文档状态与代码提交证据仍分别记录,不能用路线图
状态替代行为验收。
## 9. 停止条件
@@ -488,3 +490,36 @@ RSS/Spider 保持现有逐站点串行刷新,不通过并发化换取几秒收
4. 用户可以看到真实业务状态和失败终态;
5. 所有保留的并发和缓存都有可重放验证、版本级回滚路径和明确 owner;
6. 后续只剩最低优先级的重复订阅产品规则或无生产证据的推测项。
## 10. 当前运行日志验收
当前日志用于上线后确认新治理链路是否健康,不承担旧新链路 A/B 对照,也不引入独立日志聚合器。日志内容遵循
现有主日志输出方式,以稳定 `key=value` 字段提供机器可检索的轮次摘要。
### 10.1 轮次摘要
- Search 与 Match 每次实际执行各输出一条 `订阅治理轮次开始` 和一条 `订阅治理轮次结束` 的 `INFO`;
- 两类摘要均包含 `operation`、`run_id`、订阅数和耗时;Search 额外包含 `batch_id` 与来源,Match 包含输入
站点数和完整候选数;
- 结束摘要使用 `task_completed`、`task_skipped`、`task_failed`、`task_cancelled` 等任务终态字段;其中
`task_completed` 只表示本次处理任务正常收口,不表示订阅已经下载完成或已经删除;
- `admission_conflicts`、`cancelled`、`ttl_timeouts` 分开计数,避免把准入跳过、用户取消和协作 TTL 超时混为
同一种“未执行”;
- `release_failures` 汇总订阅准入与站点租约收口失败;任何单次释放失败同时立即输出 `ERROR`,不能只留在轮次
计数里。
### 10.2 站点与候选摘要
- Search 结束摘要聚合 `sites`、`site_requests`、`site_failures`、`site_cooldown_skips`、`candidates` 和
`cooldown_seconds`;正常成功不产生额外冷却,错误冷却和已有在途租约导致的未发请求统一计入跳过数;
- Match 的 `candidates` 表示本轮完整候选快照数量,不记录候选标题、下载地址或逐项匹配明细;
- 逐订阅开始、正常无结果、准入冲突,逐候选匹配/过滤,以及逐页站点进度降为 `DEBUG`;真实异常、媒体识别
失败和下载业务结果仍保留原有错误或业务日志级别。
### 10.3 验证门禁
- 日志合同测试必须覆盖 Search/Match 开始与结束摘要、任务完成语义、准入冲突、取消、TTL、站点请求、错误冷却
和两类释放失败;
- `100/10/1000` 与 `200/20/1200` 两档受控 Match 快照中,Match 模块的 `INFO` 数量固定为两条轮次摘要,
不得随订阅数、站点数或候选乘积增长;
- 固定快照只验证日志量、字段和本地治理合同,不把它解释为真实 PT/TMDB/媒体服务器的长期网络 SLO。

View File

@@ -782,8 +782,31 @@ def _run_match_execution_case(case: ScaleCase) -> dict[str, Any]:
settlement_ms: list[float] = []
recognition_calls = 0
progress_snapshots: list[dict[str, int]] = []
info_logs: list[str] = []
started = time.perf_counter()
class _ScaleMatchLogger:
"""只收集 Match 模块 INFO,用于验证日志量不随候选乘积增长。"""
@staticmethod
def debug(*_args: Any, **_kwargs: Any) -> None:
"""忽略受控样本的逐项诊断日志。"""
@staticmethod
def warning(*_args: Any, **_kwargs: Any) -> None:
"""忽略受控样本中预期不会出现的警告。"""
warn = warning
@staticmethod
def error(*_args: Any, **_kwargs: Any) -> None:
"""错误仍由集合和终态门禁暴露,不污染 INFO 计数。"""
@staticmethod
def info(message: Any, *_args: Any, **_kwargs: Any) -> None:
"""保存结构化批次摘要。"""
info_logs.append(str(message))
class _ScaleMediaChain:
"""用固定新鲜媒体事实替代目标规模下的真实 TMDB 请求。"""
@@ -905,6 +928,7 @@ def _run_match_execution_case(case: ScaleCase) -> dict[str, Any]:
"runtime_stop_state",
SimpleNamespace(is_system_stopped=False),
),
patch.object(subscribe_match, "logger", _ScaleMatchLogger()),
patch.object(
download_batch,
"runtime_stop_state",
@@ -936,6 +960,12 @@ def _run_match_execution_case(case: ScaleCase) -> dict[str, Any]:
"skipped": 0,
"failed": 0,
},
"info_log_count": len(info_logs),
"info_log_bounded": bool(
len(info_logs) == 2
and info_logs[0].startswith("订阅治理轮次开始: operation=match ")
and info_logs[1].startswith("订阅治理轮次结束: operation=match ")
),
"first_settlement_local_ms": round(settlement_ms[0], 3),
"subscription_settlement_local_p50_ms": _percentile(settlement_ms, 50),
"subscription_settlement_local_p95_ms": _percentile(settlement_ms, 95),
@@ -1163,6 +1193,10 @@ def run_acceptance() -> dict[str, Any]:
case["fresh_fact_loads"] == match_case["media_count"]
for case, match_case in zip(match_execution_cases, match_cases)
),
"match_info_logs_bounded": all(
case["info_log_bounded"]
for case in match_execution_cases
),
"queue_completed": all(
case["queue_state"] == "completed"
and case["queue_finished_count"] == next(
@@ -1210,7 +1244,7 @@ def run_acceptance() -> dict[str, Any]:
),
}
return {
"schema_version": 4,
"schema_version": 5,
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"revision": _git_revision(),
"environment": {

View File

@@ -22,7 +22,7 @@ def test_subscription_governance_controlled_scale_matrix() -> None:
"""两档最终矩阵必须同时满足正确性、压力、恢复和订阅准入门禁。"""
result = run_acceptance()
assert result["schema_version"] == 4
assert result["schema_version"] == 5
assert result["passed"] is True
assert all(result["gates"].values())
assert result["method"]["match_entrypoint"] == "SubscribeChain.match"
@@ -51,6 +51,7 @@ def test_subscription_governance_controlled_scale_matrix() -> None:
assert result["gates"]["match_execution_download_sets_equal"] is True
assert result["gates"]["match_execution_missing_sets_equal"] is True
assert result["gates"]["match_execution_completion_sets_equal"] is True
assert result["gates"]["match_info_logs_bounded"] is True
assert [case["subscription_count"] for case in result["match_cases"]] == [100, 200]
assert [case["site_count"] for case in result["match_cases"]] == [10, 20]
assert min(case["candidate_count"] for case in result["match_cases"]) >= 1000
@@ -69,6 +70,8 @@ def test_subscription_governance_controlled_scale_matrix() -> None:
assert all(
case["downloaded_candidates"]["duplicate_count"] == 0
and case["completed_subscriptions"]["duplicate_count"] == 0
and case["info_log_count"] == 2
and case["info_log_bounded"] is True
for case in result["match_execution_cases"]
)
assert [

View File

@@ -229,3 +229,99 @@ def test_match_stop_does_not_report_unvisited_subscriptions_as_completed(monkeyp
"failed": 0,
},
}
def test_match_logs_one_bounded_start_and_finish_summary(monkeypatch) -> None:
"""Match INFO 只保留轮次摘要,并明确任务完成不等于订阅成功。"""
completed = _subscribe(13)
conflicted = _subscribe(14)
cancelled = _subscribe(15)
chain = _chain([completed, conflicted, cancelled])
search_lease = chain._subscription_execution_admission.try_acquire(
subscription_id=conflicted.id,
operation="search",
ttl_seconds=60,
)
assert search_lease is not None
def process(**kwargs):
"""让一条正常处理完成,另一条在安全边界取消。"""
if kwargs["subscribe"].id == cancelled.id:
raise subscribe_match.SubscriptionSearchCancelled("cancelled")
return "completed"
chain._match_subscription = process
info_logs = []
monkeypatch.setattr(subscribe_match.logger, "info", info_logs.append)
chain.match({"site-a.example": [object()], "site-b.example": [object(), object()]})
assert len(info_logs) == 2
assert info_logs[0].startswith("订阅治理轮次开始: operation=match ")
assert "subscriptions=3" in info_logs[0]
assert "sites=2" in info_logs[0]
assert "candidates=3" in info_logs[0]
assert info_logs[1].startswith("订阅治理轮次结束: operation=match ")
assert "state=skipped" in info_logs[1]
assert "task_completed=1" in info_logs[1]
assert "task_skipped=2" in info_logs[1]
assert "admission_conflicts=1" in info_logs[1]
assert "cancelled=1" in info_logs[1]
assert "ttl_timeouts=0" in info_logs[1]
assert "订阅成功" not in "\n".join(info_logs)
assert chain._subscription_execution_admission.release(search_lease) is True
def test_match_summary_distinguishes_ttl_timeout(monkeypatch) -> None:
"""Match 安全边界因 TTL 停止时必须单独计数。"""
subscribe = _subscribe(16)
chain = _chain([subscribe])
now = [0.0]
chain._subscription_execution_admission = SubscriptionExecutionAdmission(
clock=lambda: now[0]
)
def expire(**_kwargs):
"""让当前 owner 在单订阅执行返回前超过 TTL。"""
now[0] = chain._SUBSCRIPTION_EXECUTION_TTL + 1
return "skipped"
chain._match_subscription = expire
info_logs = []
monkeypatch.setattr(subscribe_match.logger, "info", info_logs.append)
chain.match({"site.example": [object()]})
assert len(info_logs) == 2
assert "task_skipped=1" in info_logs[1]
assert "cancelled=0" in info_logs[1]
assert "ttl_timeouts=1" in info_logs[1]
def test_match_logs_subscription_admission_release_failure(monkeypatch) -> None:
"""订阅 owner token 无法释放时必须同时进入错误日志和轮次摘要。"""
class _ReleaseRejectingAdmission(SubscriptionExecutionAdmission):
"""模拟当前 owner 已被替换的释放冲突。"""
def release(self, lease):
"""拒绝释放以触发可见性合同。"""
del lease
return False
subscribe = _subscribe(17)
chain = _chain([subscribe])
chain._subscription_execution_admission = _ReleaseRejectingAdmission()
chain._match_subscription = lambda **_kwargs: "completed"
info_logs = []
error_logs = []
monkeypatch.setattr(subscribe_match.logger, "info", info_logs.append)
monkeypatch.setattr(
subscribe_match.logger,
"error",
lambda message, **_kwargs: error_logs.append(message),
)
chain.match({"site.example": [object()]})
assert any("订阅准入释放失败: operation=match subscription_id=17" in item for item in error_logs)
assert "release_failures=1" in info_logs[-1]

View File

@@ -14,6 +14,7 @@ from app.application.subscription.contract import SubscriptionSnapshot
from app.application.subscription.execution import SubscriptionExecutionAdmission
from app.application.subscription.sitebudget import SubscriptionSearchCancelled
from app.chain.search.facade import SearchChain
from app.chain.subscribe import search as subscribe_search
from app.chain.subscribe.facade import SubscribeChain
from app.chain.subscribe.search import _search_task_available_at
from app.db.adapters.subscriptionsearch import TransactionalSubscriptionSearchRepository
@@ -128,7 +129,6 @@ def test_inline_fallback_search_preserves_site_pressure_stagger(tmp_path, monkey
"_process_search_subscription",
lambda item, _searchchain, **_kwargs: item,
)
with patch("app.chain.subscribe.search.SearchChain", return_value=Mock()):
chain.search(state="R")
@@ -166,12 +166,19 @@ def test_successful_sites_remain_available_to_next_due_subscription(tmp_path, mo
searchchain.search_site_torrents = search_site_torrents
monkeypatch.setattr(chain, "_process_search_subscription", process)
info_logs = []
monkeypatch.setattr(subscribe_search.logger, "info", info_logs.append)
with patch("app.chain.subscribe.search.SearchChain", return_value=searchchain):
batch_id = chain.search(state="R")
assert calls == [(40, 11), (40, 12), (41, 11), (41, 12)]
assert chain.get_search_batch(batch_id).state == "completed"
assert "sites=2" in info_logs[-1]
assert "site_requests=4" in info_logs[-1]
assert "site_failures=0" in info_logs[-1]
assert "site_cooldown_skips=0" in info_logs[-1]
assert "candidates=4" in info_logs[-1]
def test_fallback_queue_executes_without_match_global_lock(tmp_path, monkeypatch):
@@ -241,6 +248,69 @@ def test_fallback_queue_continues_after_one_subscription_failure(tmp_path, monke
assert chain._subscription_execution_admission.release(lease) is True
def test_search_logs_one_bounded_start_and_finish_summary(tmp_path, monkeypatch):
"""Search INFO 只保留轮次摘要,并携带任务终态与耗时字段。"""
subscribes = [_subscribe(50), _subscribe(51)]
chain = _chain(tmp_path, subscribes)
_make_tasks_ready(monkeypatch)
def process(subscribe, _searchchain, **_kwargs):
"""让一条任务失败并保持下一条正常收口。"""
if subscribe.id == 50:
raise RuntimeError("provider timeout")
return subscribe
monkeypatch.setattr(chain, "_process_search_subscription", process)
info_logs = []
monkeypatch.setattr(subscribe_search.logger, "info", info_logs.append)
with patch("app.chain.subscribe.search.SearchChain", return_value=Mock()):
batch_id = chain.search(state="R")
assert batch_id
assert len(info_logs) == 2
assert info_logs[0].startswith("订阅治理轮次开始: operation=search ")
assert f"batch_id={batch_id}" in info_logs[0]
assert "subscriptions=2" in info_logs[0]
assert info_logs[1].startswith("订阅治理轮次结束: operation=search ")
assert "state=failed" in info_logs[1]
assert "processed=2" in info_logs[1]
assert "task_completed=1" in info_logs[1]
assert "task_failed=1" in info_logs[1]
assert "admission_conflicts=0" in info_logs[1]
assert "ttl_timeouts=0" in info_logs[1]
assert "site_requests=0" in info_logs[1]
assert "duration_ms=" in info_logs[1]
assert "订阅成功" not in "\n".join(info_logs)
def test_search_logs_subscription_admission_release_failure(tmp_path, monkeypatch):
"""Search 无法释放订阅 owner 时必须即时告警并写入轮次摘要。"""
subscribe = _subscribe(52)
chain = _chain(tmp_path, [subscribe])
_make_tasks_ready(monkeypatch)
monkeypatch.setattr(
chain,
"_process_search_subscription",
lambda item, _searchchain, **_kwargs: item,
)
monkeypatch.setattr(chain._subscription_execution_admission, "release", lambda _lease: False)
info_logs = []
error_logs = []
monkeypatch.setattr(subscribe_search.logger, "info", info_logs.append)
monkeypatch.setattr(
subscribe_search.logger,
"error",
lambda message, **_kwargs: error_logs.append(message),
)
with patch("app.chain.subscribe.search.SearchChain", return_value=Mock()):
chain.search(state="R")
assert any("订阅准入释放失败: operation=search subscription_id=52" in item for item in error_logs)
assert "release_failures=1" in info_logs[-1]
def test_swallowed_indexer_failure_marks_task_and_batch_failed_but_continues(tmp_path, monkeypatch):
"""索引器吞错后仍须失败收口当前任务,并继续处理后续订阅。"""
subscribes = [_subscribe(13), _subscribe(14)]
@@ -298,6 +368,8 @@ def test_swallowed_indexer_failure_marks_task_and_batch_failed_but_continues(tmp
return subscribe
monkeypatch.setattr(chain, "_process_search_subscription", process)
info_logs = []
monkeypatch.setattr(subscribe_search.logger, "info", info_logs.append)
with patch("app.chain.subscribe.search.SearchChain", return_value=searchchain):
batch_id = chain.search(state="R")
@@ -309,6 +381,10 @@ def test_swallowed_indexer_failure_marks_task_and_batch_failed_but_continues(tmp
assert batch.failed_count == 1
assert "Flaky" in batch.last_error
assert "HTTP 429" in batch.last_error
assert "sites=2" in info_logs[-1]
assert "site_requests=2" in info_logs[-1]
assert "site_failures=1" in info_logs[-1]
assert "cooldown_seconds=900.0" in info_logs[-1]
def test_same_subscription_conflict_is_skipped_without_waiting(tmp_path, monkeypatch):
@@ -324,6 +400,8 @@ def test_same_subscription_conflict_is_skipped_without_waiting(tmp_path, monkeyp
ttl_seconds=60,
)
assert match_lease is not None
info_logs = []
monkeypatch.setattr(subscribe_search.logger, "info", info_logs.append)
with patch("app.chain.subscribe.search.SearchChain", return_value=Mock()):
batch_id = chain.search(state="R")
@@ -334,6 +412,8 @@ def test_same_subscription_conflict_is_skipped_without_waiting(tmp_path, monkeyp
assert batch.finished_count == 0
assert batch.skipped_count == 1
assert batch.last_error == "同一订阅正在由其他通道处理,本轮搜索已跳过"
assert "task_skipped=1" in info_logs[-1]
assert "admission_conflicts=1" in info_logs[-1]
assert chain._subscription_execution_admission.release(match_lease) is True
@@ -532,6 +612,8 @@ def test_ttl_expiry_after_normal_return_marks_task_and_batch_failed(tmp_path, mo
"_process_search_subscription",
lambda item, _searchchain, **_kwargs: item,
)
info_logs = []
monkeypatch.setattr(subscribe_search.logger, "info", info_logs.append)
with patch("app.chain.subscribe.search.SearchChain", return_value=Mock()):
batch_id = chain.search(state="R")
@@ -542,6 +624,8 @@ def test_ttl_expiry_after_normal_return_marks_task_and_batch_failed(tmp_path, mo
assert batch.failed_count == 1
assert batch.cancelled_count == 0
assert batch.last_error == "订阅执行已超过协作截止时间"
assert "task_failed=1" in info_logs[-1]
assert "ttl_timeouts=1" in info_logs[-1]
def test_ttl_expiry_after_download_started_completes_with_actual_result(tmp_path, monkeypatch):

View File

@@ -14,6 +14,7 @@ from app.application.subscription.sitebudget import (
SiteBudgetClaim,
SubscriptionSearchCancelled,
SubscriptionSiteBudget,
SubscriptionSiteBudgetMetrics,
SubscriptionSiteBudgetUnavailable,
)
from app.chain.search.facade import SearchChain
@@ -244,11 +245,13 @@ def test_skipped_search_releases_budget_without_external_interval():
def test_search_provider_reports_cooled_site_without_blocking_other_results():
"""错误冷却中的站点返回空页并记录聚合失败,而非阻塞 provider。"""
repository = _WaitingRepository()
metrics = SubscriptionSiteBudgetMetrics()
budget = SubscriptionSiteBudget(
repository=repository,
owner="fallback-worker",
cancelled=lambda: False,
stop_state=ProcessStopState(),
metrics=metrics,
)
chain = object.__new__(SearchChain)
chain.configure_subscription_site_budget(budget)
@@ -265,6 +268,9 @@ def test_search_provider_reports_cooled_site_without_blocking_other_results():
failures = chain.consume_subscription_site_budget_failures()
assert len(failures) == 1
assert "站点 11" in failures[0]
snapshot = metrics.snapshot()
assert snapshot.request_count == 0
assert snapshot.cooldown_skip_count == 1
def test_search_provider_releases_successful_site_budget():
@@ -290,11 +296,13 @@ def test_search_provider_releases_successful_site_budget():
captured.update(kwargs)
return True
metrics = SubscriptionSiteBudgetMetrics()
budget = SubscriptionSiteBudget(
repository=_Repository(),
owner="manual-worker",
cancelled=lambda: False,
stop_state=ProcessStopState(),
metrics=metrics,
)
chain = object.__new__(SearchChain)
chain.configure_subscription_site_budget(budget)
@@ -317,6 +325,12 @@ def test_search_provider_releases_successful_site_budget():
assert captured["site_id"] == 12
assert captured["outcome"] == "success"
assert captured["lease_token"] == "lease-token"
snapshot = metrics.snapshot()
assert snapshot.site_count == 1
assert snapshot.request_count == 1
assert snapshot.candidate_count == 1
assert snapshot.failure_count == 0
assert snapshot.release_failure_count == 0
def test_search_provider_aggregates_swallowed_indexer_failure(monkeypatch):
@@ -368,11 +382,13 @@ def test_search_provider_aggregates_swallowed_indexer_failure(monkeypatch):
staticmethod(lambda **_kwargs: []),
)
metrics = SubscriptionSiteBudgetMetrics()
budget = SubscriptionSiteBudget(
repository=_Repository(),
owner="fallback-worker",
cancelled=lambda: False,
stop_state=ProcessStopState(),
metrics=metrics,
)
chain = object.__new__(SearchChain)
chain.configure_subscription_site_budget(budget)
@@ -392,3 +408,53 @@ def test_search_provider_aggregates_swallowed_indexer_failure(monkeypatch):
assert len(failures) == 1
assert "Flaky" in failures[0]
assert "HTTP 429" in failures[0]
snapshot = metrics.snapshot()
assert snapshot.request_count == 1
assert snapshot.failure_count == 1
assert snapshot.cooldown_seconds == 900.0
def test_search_provider_logs_site_budget_release_failure(monkeypatch):
"""站点租约收口返回失败时必须进入摘要并即时记录错误。"""
class _Repository(_WaitingRepository):
"""提供租约但拒绝以当前 token 收口。"""
def claim_site(self, *, site_id: int, owner: str, lease_seconds: int) -> SiteBudgetClaim:
"""返回当前调用独占的站点租约。"""
del owner, lease_seconds
return SiteBudgetClaim(
site_id=site_id,
acquired=True,
retry_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
consecutive_failures=0,
lease_token="stale-token",
)
def finish_site(self, **_kwargs) -> bool:
"""模拟 token 已失效的 CAS 收口。"""
return False
metrics = SubscriptionSiteBudgetMetrics()
budget = SubscriptionSiteBudget(
repository=_Repository(),
owner="fallback-worker",
cancelled=lambda: False,
stop_state=ProcessStopState(),
metrics=metrics,
)
chain = object.__new__(SearchChain)
chain.configure_subscription_site_budget(budget)
chain.search_site_torrents = lambda **_kwargs: ["torrent"]
errors = []
monkeypatch.setattr("app.chain.search.provider.logger.error", lambda message, **_kwargs: errors.append(message))
result = chain._search_site_torrents_with_budget( # pylint: disable=protected-access
site={"id": 14, "name": "Stale"},
keyword="movie",
mtype=None,
page=0,
)
assert result == ["torrent"]
assert metrics.snapshot().release_failure_count == 1
assert errors == ["订阅站点预算释放失败: site_id=14 site=Stale"]