Files
MoviePilot/tests/test_subscription_site_budget.py
2026-11-03 02:59:35 +08:00

1007 lines
36 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 datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from app.application.site.observation import (
SiteSearchObservation,
report_site_search_outcome,
)
from app.application.subscription.sitebudget import (
SiteBudgetClaim,
SubscriptionSearchCancelled,
SubscriptionSiteBudget,
SubscriptionSiteBudgetMetrics,
SubscriptionSiteBudgetUnavailable,
)
from app.chain.search.facade import SearchChain
from app.db.adapters.subscriptionsearch import TransactionalSubscriptionSearchRepository
from app.db.base import Base
from app.db.models.subscriptionsearch import SubscriptionSiteBudget as SiteBudgetRecord
from app.modules.indexer import IndexerModule, _classify_search_failure
from app.runtime.stop import ProcessStopState
def _repository(tmp_path):
"""
构造使用独立 SQLite 文件的站点预算仓储。
"""
engine = create_engine(f"sqlite:///{tmp_path / 'site-budget.db'}")
Base.metadata.create_all(engine)
return TransactionalSubscriptionSearchRepository(sessionmaker(bind=engine)), engine
def test_site_budget_allows_one_inflight_per_site_and_independent_sites(tmp_path):
"""
同站点第二个调用必须等待,不同站点可立即并行。
"""
repository, _engine = _repository(tmp_path)
before_retry = datetime.now(timezone.utc)
first = repository.claim_site(site_id=1, owner="worker-a", lease_seconds=900)
same_site = repository.claim_site(site_id=1, owner="worker-b", lease_seconds=900)
other_site = repository.claim_site(site_id=2, owner="worker-b", lease_seconds=900)
assert first.acquired is True
assert same_site.acquired is False
assert same_site.lease_token is None
assert same_site.wait_reason == "busy"
assert datetime.fromisoformat(same_site.retry_at) <= before_retry + timedelta(seconds=11)
assert other_site.acquired is True
def test_site_budget_recovers_expired_inflight_lease(tmp_path):
"""
进程遗留的过期站点租约可被新 worker 以新 token 恢复。
"""
repository, engine = _repository(tmp_path)
first = repository.claim_site(site_id=20, owner="worker-a", lease_seconds=900)
expired_at = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat(timespec="seconds")
with Session(engine) as session:
session.execute(
SiteBudgetRecord.__table__.update()
.where(SiteBudgetRecord.site_id == 20)
.values(lease_expires_at=expired_at)
)
session.commit()
recovered = repository.claim_site(site_id=20, owner="worker-b", lease_seconds=900)
assert recovered.acquired is True
assert recovered.lease_token != first.lease_token
def test_busy_site_does_not_hide_other_inflight_search(tmp_path):
"""
一个站点繁忙时,任务仍应展示其它已取得租约的站点正在搜索。
"""
repository, engine = _repository(tmp_path)
busy_claim = repository.claim_site(site_id=1, owner="other-search", lease_seconds=900)
phases = []
budget = SubscriptionSiteBudget(
repository=repository,
owner="current-search",
cancelled=lambda: False,
stop_state=ProcessStopState(),
phase_changed=lambda phase, site_id: phases.append((phase, site_id)),
)
try:
searching_claim = budget.acquire(2)
with pytest.raises(SubscriptionSiteBudgetUnavailable) as deferred:
budget.acquire(1)
assert deferred.value.site_id == 1
assert phases[-1] == ("searching", 2)
assert budget.finish(searching_claim, SiteSearchObservation())
assert repository.finish_site(
site_id=1,
lease_token=busy_claim.lease_token,
outcome="success",
next_allowed_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
)
finally:
engine.dispose()
def test_site_budget_applies_error_cooldown_and_gradual_success_recovery(tmp_path):
"""
失败增加冷却计数,后续成功每次只恢复一级而非直接清零。
"""
repository, engine = _repository(tmp_path)
claim = repository.claim_site(site_id=3, owner="worker-a", lease_seconds=900)
now = datetime.now(timezone.utc)
assert repository.finish_site(
site_id=3,
lease_token=claim.lease_token,
outcome="timeout",
next_allowed_at=(now + timedelta(minutes=5)).isoformat(timespec="seconds"),
error="request timeout",
) is True
cooled = repository.claim_site(site_id=3, owner="worker-b", lease_seconds=900)
assert cooled.acquired is False
with Session(engine) as session:
failed = session.execute(
select(SiteBudgetRecord).where(SiteBudgetRecord.site_id == 3)
).scalar_one()
assert failed.consecutive_failures == 1
assert failed.success_streak == 0
failed.next_allowed_at = (now - timedelta(seconds=1)).isoformat(timespec="seconds")
session.commit()
recovered = repository.claim_site(site_id=3, owner="worker-b", lease_seconds=900)
assert recovered.acquired is True
assert recovered.consecutive_failures == 1
assert repository.finish_site(
site_id=3,
lease_token=recovered.lease_token,
outcome="success",
next_allowed_at=(now + timedelta(minutes=1)).isoformat(timespec="seconds"),
) is True
with Session(engine) as session:
healthy = session.execute(
select(SiteBudgetRecord).where(SiteBudgetRecord.site_id == 3)
).scalar_one()
assert healthy.consecutive_failures == 0
assert healthy.success_streak == 1
assert healthy.last_outcome == "success"
def test_site_budget_ignores_legacy_success_interval(tmp_path):
"""
升级前留下的正常成功间隔不能阻止新语义下的到期订阅。
"""
repository, engine = _repository(tmp_path)
first = repository.claim_site(site_id=4, owner="worker-a", lease_seconds=900)
future = (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat(timespec="seconds")
assert repository.finish_site(
site_id=4,
lease_token=first.lease_token,
outcome="success",
next_allowed_at=future,
) is True
recovered = repository.claim_site(site_id=4, owner="worker-b", lease_seconds=900)
assert recovered.acquired is True
assert recovered.lease_token is not None
with Session(engine) as session:
record = session.execute(
select(SiteBudgetRecord).where(SiteBudgetRecord.site_id == 4)
).scalar_one()
assert record.next_allowed_at < future
def test_automatic_pagination_shares_success_interval_across_subscriptions(tmp_path):
"""
不同订阅的自动分页共用站点成功请求间隔。
"""
repository, engine = _repository(tmp_path)
before = datetime.now(timezone.utc)
first = repository.claim_site(site_id=9, owner="subscription-a", lease_seconds=900, minimum_interval=10)
assert first.acquired
assert datetime.fromisoformat(first.retry_at) >= before + timedelta(seconds=10)
assert repository.finish_site(site_id=9, lease_token=first.lease_token, outcome="success", next_allowed_at=first.retry_at)
waiting = repository.claim_site(site_id=9, owner="subscription-b", lease_seconds=900, minimum_interval=10)
assert not waiting.acquired
assert waiting.wait_reason == "cooldown"
assert waiting.retry_at == first.retry_at
other = repository.claim_site(site_id=10, owner="subscription-b", lease_seconds=900, minimum_interval=20)
assert other.acquired
assert datetime.fromisoformat(other.retry_at) >= before + timedelta(seconds=20)
engine.dispose()
@pytest.mark.parametrize(
("error", "outcome"),
[
(RuntimeError("HTTP 429"), "rate_limited"),
(RuntimeError("403 forbidden"), "forbidden"),
(RuntimeError("Cookie 登录失效"), "login_invalid"),
(TimeoutError("request timed out"), "timeout"),
],
)
def test_indexer_failures_map_to_site_cooldown_categories(error, outcome):
"""
外站典型失败必须进入对应的站点级冷却类别。
"""
assert _classify_search_failure(error) == outcome
class _WaitingRepository:
"""
始终返回未来重试时间的可取消预算仓储。
"""
def claim_site(self, *, site_id: int, owner: str, lease_seconds: int) -> SiteBudgetClaim:
"""
返回未认领的未来预算。
"""
del owner, lease_seconds
return SiteBudgetClaim(
site_id=site_id,
acquired=False,
retry_at=(datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat(timespec="seconds"),
consecutive_failures=0,
)
def finish_site(self, **_kwargs) -> bool:
"""
等待测试不会取得租约,因此不应调用收口。
"""
raise AssertionError("未取得站点预算时不应收口")
def test_unavailable_site_budget_does_not_sleep_in_worker():
"""
错误冷却中的站点应立即留待下轮,不占用同步 worker 等待。
"""
budget = SubscriptionSiteBudget(
repository=_WaitingRepository(),
owner="worker-a",
cancelled=lambda: False,
stop_state=ProcessStopState(),
)
with pytest.raises(SubscriptionSiteBudgetUnavailable):
budget.acquire(9)
def test_site_budget_checks_cancellation_before_repository_claim():
"""
取消或停机必须在创建任何站点租约前终止当前任务。
"""
class _ForbiddenRepository(_WaitingRepository):
"""
取消路径不允许触发持久认领。
"""
def claim_site(self, **_kwargs) -> SiteBudgetClaim:
"""
若取消检查失效则立即暴露。
"""
raise AssertionError("取消任务不应认领站点预算")
budget = SubscriptionSiteBudget(
repository=_ForbiddenRepository(),
owner="worker-a",
cancelled=lambda: True,
stop_state=ProcessStopState(),
)
with pytest.raises(SubscriptionSearchCancelled):
budget.acquire(9)
def test_site_budget_only_delays_external_failures():
"""
正常完成立即恢复,外站错误仍按类别进入冷却。
"""
budget = SubscriptionSiteBudget(
repository=_WaitingRepository(),
owner="manual-worker",
cancelled=lambda: False,
stop_state=ProcessStopState(),
)
# 直接验证退避时长算法,不等待真实调度时间。
assert budget._next_delay("success", consecutive_failures=0) == 0.0 # pylint: disable=protected-access
# 直接验证退避时长算法,不等待真实调度时间。
assert budget._next_delay("success", consecutive_failures=2) == 0.0 # pylint: disable=protected-access
# 直接验证退避时长算法,不等待真实调度时间。
assert budget._next_delay("rate_limited", consecutive_failures=0) == 900.0 # pylint: disable=protected-access
def test_skipped_search_releases_budget_without_external_interval():
"""
本地限流等未发请求结果不应伪造成功或追加外站间隔。
"""
captured = {}
class _Repository(_WaitingRepository):
"""
记录预算收口参数。
"""
def finish_site(self, **kwargs) -> bool:
"""
保存收口事实供断言。
"""
captured.update(kwargs)
return True
now = datetime(2026, 9, 1, tzinfo=timezone.utc)
budget = SubscriptionSiteBudget(
repository=_Repository(),
owner="worker-a",
cancelled=lambda: False,
stop_state=ProcessStopState(),
clock=lambda: now,
)
claim = SiteBudgetClaim(
site_id=10,
acquired=True,
retry_at=now.isoformat(timespec="seconds"),
consecutive_failures=0,
lease_token="lease-token",
)
assert budget.finish(claim, SiteSearchObservation()) is True
assert captured["outcome"] == "skipped"
assert captured["next_allowed_at"] == now.isoformat(timespec="seconds")
@pytest.mark.parametrize("outcome, attempted, delay", [
("deferred", False, 6),
("deferred", False, 3601),
("skipped", False, 0),
("success", True, 0),
])
def test_request_reservation_preserves_only_actual_request_interval(tmp_path, outcome, attempted, delay):
"""
未发请求时按实际剩余时间释放预约,真实成功请求仍受完整访问间隔约束。
"""
repository, engine = _repository(tmp_path)
now = datetime.now(timezone.utc)
budget = SubscriptionSiteBudget(
repository=repository, owner="scan", cancelled=lambda: False,
stop_state=ProcessStopState(), clock=lambda: now,
)
try:
claim = budget.acquire(15, minimum_interval=20)
assert claim.acquired
assert budget.finish(
claim, SiteSearchObservation(attempted=attempted, outcome=outcome),
deferral_seconds=delay,
)
with Session(engine) as session:
record = session.execute(select(SiteBudgetRecord).where(SiteBudgetRecord.site_id == 15)).scalar_one()
expected = claim.retry_at if attempted else (now + timedelta(seconds=delay)).isoformat(timespec="seconds")
assert record.next_allowed_at == expected
assert record.last_outcome == outcome
assert record.consecutive_failures == 0
assert record.lease_token is None
finally:
engine.dispose()
def test_local_rate_limit_defers_site_budget_for_full_window():
"""
本地流控拒绝必须按站点窗口写入冷却,而不是立即恢复后再次触发限流。
"""
captured = {}
class _Repository(_WaitingRepository):
"""
记录预算收口参数。
"""
def finish_site(self, **kwargs) -> bool:
"""
保存收口事实供断言。
"""
captured.update(kwargs)
return True
now = datetime(2026, 9, 1, tzinfo=timezone.utc)
budget = SubscriptionSiteBudget(
repository=_Repository(),
owner="worker-a",
cancelled=lambda: False,
stop_state=ProcessStopState(),
clock=lambda: now,
)
claim = SiteBudgetClaim(
site_id=10,
acquired=True,
retry_at=now.isoformat(timespec="seconds"),
consecutive_failures=2,
lease_token="lease-token",
)
assert budget.finish(
claim,
SiteSearchObservation(attempted=False, outcome="deferred", error="触发流控规则"),
deferral_seconds=3601,
) is True
assert captured["outcome"] == "deferred"
assert captured["error"] == "触发流控规则"
assert captured["next_allowed_at"] == (now + timedelta(seconds=3601)).isoformat(timespec="seconds")
def test_deferred_site_cooldown_blocks_other_subscriptions_without_failure_growth(tmp_path):
"""
窗口冷却中的站点对其它订阅同样不可认领,且本地限流不计入失败次数。
"""
repository, engine = _repository(tmp_path)
claim = repository.claim_site(site_id=30, owner="worker-a", lease_seconds=900)
future = (datetime.now(timezone.utc) + timedelta(seconds=3601)).isoformat(timespec="seconds")
assert repository.finish_site(
site_id=30,
lease_token=claim.lease_token,
outcome="deferred",
next_allowed_at=future,
error="触发流控规则,3600 秒内访问次数不得超过 25 次",
) is True
waiting = repository.claim_site(site_id=30, owner="worker-b", lease_seconds=900)
assert waiting.acquired is False
assert waiting.wait_reason == "rate_limit"
assert waiting.retry_at == future
with Session(engine) as session:
record = session.execute(
select(SiteBudgetRecord).where(SiteBudgetRecord.site_id == 30)
).scalar_one()
assert record.consecutive_failures == 0
assert record.success_streak == 0
assert record.last_outcome == "deferred"
record.next_allowed_at = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat(timespec="seconds")
session.commit()
recovered = repository.claim_site(site_id=30, owner="worker-b", lease_seconds=900)
assert recovered.acquired is True
assert recovered.consecutive_failures == 0
engine.dispose()
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)
chain.search_site_torrents = lambda **_kwargs: ["unexpected"]
# 直接覆盖带预算的站点搜索入口,隔离上层订阅编排。
result = chain._search_site_torrents_with_budget( # pylint: disable=protected-access
site={"id": 11, "name": "Cooling"},
keyword="movie",
mtype=None,
page=0,
)
assert result == []
deferrals = chain.consume_subscription_site_budget_deferrals()
assert len(deferrals) == 1
assert deferrals[0].site_id == 11
assert deferrals[0].retry_at
failures = chain.consume_subscription_site_budget_failures()
assert failures == ()
snapshot = metrics.snapshot()
assert snapshot.request_count == 0
assert snapshot.cooldown_skip_count == 1
def test_search_provider_releases_successful_site_budget():
"""
真实站点页正常完成后必须释放租约并立即允许下一任务。
"""
captured = {}
class _Repository(_WaitingRepository):
"""
提供立即可用租约并记录收口参数。
"""
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="lease-token",
)
def finish_site(self, **kwargs) -> bool:
"""
记录成功收口事实。
"""
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)
def search_site_torrents(**_kwargs):
"""
模拟索引器成功并发布观察结果。
"""
report_site_search_outcome(attempted=True, outcome="success")
return ["torrent"]
chain.search_site_torrents = search_site_torrents
# 直接覆盖带预算的站点搜索入口,隔离上层订阅编排。
result = chain._search_site_torrents_with_budget( # pylint: disable=protected-access
site={"id": 12, "name": "Healthy"},
keyword="movie",
mtype=None,
page=0,
)
assert result == ["torrent"]
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
chain.record_subscription_site_budget_failure("站点 Flaky 搜索失败:HTTP 429")
assert not chain.consume_subscription_site_budget_failures()
@pytest.mark.parametrize("reason, seconds", [
("访问间隔不得小于 20 秒", 21),
("3600 秒内访问次数不得超过 25 次", 3601),
("未知限制", 3601),
])
def test_search_provider_defers_budget_for_local_rate_limit_window(monkeypatch, reason, seconds):
"""
同步 provider 按实际限流原因持久化冷却,短间隔不扩大为小时窗口,且不算站点失败。
"""
from app.chain.search import provider
monkeypatch.setattr(provider, "_wait_for_site_request", lambda _site: None)
captured = {}
class _Repository(_WaitingRepository):
"""
提供立即可用租约并记录收口参数。
"""
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="lease-token",
)
def finish_site(self, **kwargs) -> bool:
"""
记录本地流控收口事实。
"""
captured.update(kwargs)
return True
budget = SubscriptionSiteBudget(
repository=_Repository(),
owner="manual-worker",
cancelled=lambda: False,
stop_state=ProcessStopState(),
)
chain = object.__new__(SearchChain)
chain.configure_subscription_site_budget(budget)
def search_site_torrents(**_kwargs):
"""
模拟索引器被本地流控拒绝,未发出任何请求。
"""
report_site_search_outcome(attempted=False, outcome="deferred", error=f"触发流控规则,{reason}")
return []
chain.search_site_torrents = search_site_torrents
before = datetime.now(timezone.utc)
# 直接覆盖带预算的站点搜索入口,隔离上层订阅编排。
result = chain._search_site_torrents_with_budget( # pylint: disable=protected-access
site={"id": 16, "name": "Limited", "limit_interval": 3600, "limit_count": 25, "limit_seconds": 20},
keyword="movie",
mtype=None,
page=0,
)
assert result == []
assert captured["outcome"] == "deferred"
assert captured["error"] == f"触发流控规则,{reason}"
delay = (datetime.fromisoformat(captured["next_allowed_at"]) - before).total_seconds()
assert seconds - 1 < delay <= seconds
assert chain.consume_subscription_site_budget_failures() == ()
def test_other_search_results_keep_site_failures_non_terminal():
"""
插件等其他搜索源已有候选时,站点失败不得覆盖可用结果。
"""
chain = object.__new__(SearchChain)
chain.configure_subscription_site_budget(None)
chain.record_subscription_site_budget_failure("站点 Flaky 搜索失败:HTTP 429")
assert not chain.consume_subscription_site_budget_failures(has_results=True)
def test_search_provider_aggregates_swallowed_indexer_failure(monkeypatch):
"""
索引器吞掉外站异常返回空页时,provider 仍须暴露站点失败。
"""
captured = {}
class _Repository(_WaitingRepository):
"""
提供立即可用租约并记录失败收口。
"""
def claim_site(self, *, site_id: int, owner: str, lease_seconds: int) -> SiteBudgetClaim:
"""
返回当前调用独占的站点租约。
"""
del owner, lease_seconds
now = datetime.now(timezone.utc)
return SiteBudgetClaim(
site_id=site_id,
acquired=True,
retry_at=now.isoformat(timespec="seconds"),
consecutive_failures=0,
lease_token="lease-token",
)
def finish_site(self, **kwargs) -> bool:
"""
记录预算收口,确认错误冷却仍由 budget.finish 负责。
"""
captured.update(kwargs)
return True
def execute_search(_site, _request):
"""
模拟 IndexerModule 捕获的 HTTP 429。
"""
raise RuntimeError("HTTP 429")
monkeypatch.setattr(
IndexerModule,
"_IndexerModule__search_check",
staticmethod(lambda _site, _keyword=None: True),
)
monkeypatch.setattr(
IndexerModule,
"_IndexerModule__execute_search",
staticmethod(execute_search),
)
monkeypatch.setattr(
IndexerModule,
"_IndexerModule__indexer_statistic",
staticmethod(lambda **_kwargs: None),
)
monkeypatch.setattr(
IndexerModule,
"_IndexerModule__parse_result",
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)
chain.search_site_torrents = object.__new__(IndexerModule).search_torrents
# 直接覆盖带预算的站点搜索入口,隔离上层订阅编排。
result = chain._search_site_torrents_with_budget( # pylint: disable=protected-access
site={"id": 13, "name": "Flaky"},
keyword="movie",
mtype=None,
page=0,
)
assert result == []
assert captured["outcome"] == "rate_limited"
assert captured["error"] == "HTTP 429"
failures = chain.consume_subscription_site_budget_failures()
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
@pytest.mark.parametrize(
("failure_detail", "expected_error"),
[
(None, "站点请求或页面解析失败"),
(
"HTTP 403,页面标题:Just a moment...",
"站点请求或页面解析失败(HTTP 403,页面标题:Just a moment...)",
),
],
)
def test_search_provider_explains_error_flag_without_exception(
monkeypatch,
failure_detail,
expected_error,
):
"""
索引器仅返回错误标志时也必须持久化可读原因,爬虫给出的失败原因附在兜底文案后且不改变冷却分类。
"""
captured = {}
class _Repository(_WaitingRepository):
"""
提供立即可用租约并记录通用失败收口。
"""
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="lease-token",
)
def finish_site(self, **kwargs) -> bool:
"""
记录预算收口参数供断言。
"""
captured.update(kwargs)
return True
monkeypatch.setattr(
IndexerModule,
"_IndexerModule__search_check",
staticmethod(lambda _site, _keyword=None: True),
)
monkeypatch.setattr(
IndexerModule,
"_IndexerModule__execute_search",
staticmethod(lambda _site, _request: (True, [], failure_detail)),
)
monkeypatch.setattr(
IndexerModule,
"_IndexerModule__indexer_statistic",
staticmethod(lambda **_kwargs: None),
)
monkeypatch.setattr(
IndexerModule,
"_IndexerModule__parse_result",
staticmethod(lambda **_kwargs: []),
)
budget = SubscriptionSiteBudget(
repository=_Repository(),
owner="fallback-worker",
cancelled=lambda: False,
stop_state=ProcessStopState(),
)
chain = object.__new__(SearchChain)
chain.configure_subscription_site_budget(budget)
chain.search_site_torrents = object.__new__(IndexerModule).search_torrents
warnings = []
monkeypatch.setattr(
"app.chain.search.provider.logger.warning",
warnings.append,
)
# 直接覆盖带预算的站点搜索入口,隔离上层订阅编排。
result = chain._search_site_torrents_with_budget( # pylint: disable=protected-access
site={"id": 15, "name": "Generic"},
keyword="movie",
mtype=None,
page=0,
)
assert result == []
assert captured["outcome"] == "error"
assert captured["error"] == expected_error
assert chain.consume_subscription_site_budget_failures() == (
f"站点 Generic 搜索失败:{expected_error}",
)
assert warnings == [f"站点 Generic 搜索失败:{expected_error}"]
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"]
def test_retry_provider_only_searches_pending_enabled_sites(monkeypatch):
"""
恢复时不重搜成功站点和插件源,也不重新启用已经移除的站点。
"""
from unittest.mock import Mock
from app.chain.search.provider import SearchProviderOwner
chain = object.__new__(SearchChain)
chain.configure_subscription_site_budget(SubscriptionSiteBudget(
repository=_WaitingRepository(), owner="retry", cancelled=lambda: False,
stop_state=ProcessStopState(), pending_site_ids=(2, 3),
))
chain._sync_indexers = lambda _sites: [{"id": 1}, {"id": 2}]
chain._torrent_type = lambda *_args: None
chain._torrent_keyword = lambda *_args: "movie"
chain._build_search_pages = lambda _page: [0]
chain.search_plugin_torrents = Mock(return_value=[])
captured = []
def collect(**kwargs):
"""
只观察 provider 选择,禁止真实站点和插件调用。
"""
captured.extend(site["id"] for site in kwargs["indexer_sites"])
return {}
monkeypatch.setattr(SearchProviderOwner, "_collect_sync_site_results", lambda _self, **kwargs: collect(**kwargs))
monkeypatch.setattr("app.chain.search.provider.ProgressHelper", Mock())
SearchProviderOwner._search_all_sites(chain, keyword="movie", sites=[1, 2])
assert captured == [2]
chain.search_plugin_torrents.assert_not_called()
def test_deferral_reports_cooldown_separately_from_busy():
"""
请求间隔按正常搜索展示,不伪装成站点占用,并保留所有未完成站点。
"""
from app.application.subscription.execution import raise_subscription_site_budget_deferral
from app.application.subscription.sitebudget import SubscriptionSearchDeferred, SubscriptionSiteBudgetDeferral
deferred_sites = (
SubscriptionSiteBudgetDeferral(site_id=1, retry_at="2026-09-08T10:00:00+00:00", wait_reason="cooldown"),
SubscriptionSiteBudgetDeferral(site_id=2, retry_at="2026-09-08T11:00:00+00:00", wait_reason="cooldown"),
)
with pytest.raises(SubscriptionSearchDeferred, match="正在搜索") as caught:
raise_subscription_site_budget_deferral(deferred_sites, None)
assert caught.value.site_ids == (1, 2)
assert caught.value.retry_at == deferred_sites[0].retry_at
def test_claim_tells_failing_site_from_normal_request_interval(tmp_path):
"""
出错后的暂停报告为 failing 并带上失败原因,正常请求间隔仍报告为 cooldown。
"""
repository, _engine = _repository(tmp_path)
future = (datetime.now(timezone.utc) + timedelta(minutes=30)).isoformat(timespec="seconds")
claim = repository.claim_site(site_id=40, owner="worker-a", lease_seconds=900)
assert repository.finish_site(site_id=40, lease_token=claim.lease_token, outcome="error",
next_allowed_at=future, error="返回登录或权限提示页") is True
failing = repository.claim_site(site_id=40, owner="worker-b", lease_seconds=900)
assert failing.acquired is False
assert failing.wait_reason == "failing"
assert failing.last_error == "返回登录或权限提示页"
assert failing.consecutive_failures == 1
claim = repository.claim_site(site_id=41, owner="worker-a", lease_seconds=900)
assert repository.finish_site(site_id=41, lease_token=claim.lease_token, outcome="success",
next_allowed_at=future) is True
paced = repository.claim_site(site_id=41, owner="worker-b", lease_seconds=900, minimum_interval=10)
assert paced.acquired is False
assert paced.wait_reason == "cooldown"
class _FailingSiteRepository(_WaitingRepository):
"""
模拟登录失效、连续出错后暂停访问的站点。
"""
def claim_site(self, *, site_id: int, owner: str, lease_seconds: int) -> SiteBudgetClaim:
"""
返回出错暂停中的预算。
"""
del owner, lease_seconds
return SiteBudgetClaim(
site_id=site_id, acquired=False,
retry_at=(datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(timespec="seconds"),
consecutive_failures=30, wait_reason="failing", last_error="返回登录或权限提示页",
)
def test_failing_site_is_skipped_without_deferring_subscription(monkeypatch):
"""
出错暂停中的站点这一轮直接跳过,不让整个订阅重新排队等它;连续失败多时每天只警告一次。
"""
from app.chain.search import provider as provider_module
warnings = []
monkeypatch.setattr(provider_module.logger, "warning", lambda message, *_args, **_kwargs: warnings.append(message))
monkeypatch.setattr(provider_module, "_site_failing_noticed_at", {})
budget = SubscriptionSiteBudget(repository=_FailingSiteRepository(), owner="worker", cancelled=lambda: False,
stop_state=ProcessStopState())
chain = object.__new__(SearchChain)
chain.configure_subscription_site_budget(budget)
chain.search_site_torrents = lambda **_kwargs: ["unexpected"]
for _ in range(2):
# 直接覆盖带预算的站点搜索入口,隔离上层订阅编排。
result = chain._search_site_torrents_with_budget( # pylint: disable=protected-access
site={"id": 16, "name": "我堡"}, keyword="show", mtype=None, page=0,
)
assert result == []
assert chain.consume_subscription_site_budget_deferrals() == ()
assert len(warnings) == 1
assert "我堡" in warnings[0] and "连续 30 次" in warnings[0] and "返回登录或权限提示页" in warnings[0]