diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index a57519d8..66c3153d 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -996,6 +996,10 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = WHERE sp.id <> CAST(:proxy_id AS bigint) AND sp.enabled AND sp.consecutive_fails < CAST(:max_fails AS integer) + -- Как в acquire(): узел с истёкшей арендой порта остаётся enabled, + -- но не выдаётся. Без этой строки он «спасал» источник, и бан уходил + -- последнему живому узлу (ревью #3565 к #3299). + AND (sp.expires_at IS NULL OR sp.expires_at > now()) AND NOT EXISTS ( SELECT 1 FROM scrape_proxy_source_bans b diff --git a/tradein-mvp/backend/tests/test_3299_fallback_counts_any_nodes.py b/tradein-mvp/backend/tests/test_3299_fallback_counts_any_nodes.py index 002ce1a5..70b378ec 100644 --- a/tradein-mvp/backend/tests/test_3299_fallback_counts_any_nodes.py +++ b/tradein-mvp/backend/tests/test_3299_fallback_counts_any_nodes.py @@ -63,16 +63,17 @@ def db() -> Iterator[Session]: conn.close() -def _node(db: Session, affinity: str, *, fails: int = 0) -> int: +def _node(db: Session, affinity: str, *, fails: int = 0, expired: bool = False) -> int: proxy_id = db.execute( text( """ - INSERT INTO scrape_proxies (url, provider_affinity, consecutive_fails) - VALUES ('http://t3299-' || gen_random_uuid(), :aff, :fails) + INSERT INTO scrape_proxies (url, provider_affinity, consecutive_fails, expires_at) + VALUES ('http://t3299-' || gen_random_uuid(), :aff, :fails, + CASE WHEN :expired THEN now() - interval '1 minute' END) RETURNING id """ ), - {"aff": affinity, "fails": fails}, + {"aff": affinity, "fails": fails, "expired": expired}, ).scalar_one() db.commit() return int(proxy_id) @@ -148,6 +149,19 @@ def test_unhealthy_any_node_is_not_a_backup(db: Session) -> None: assert _leased_by(db, dedicated) is None +def test_expired_any_node_is_not_a_backup(db: Session) -> None: + """Узел с истёкшей арендой порта acquire не выдаёт — резервом он тоже не считается. + + Отдельно от карантина: у предиката резерва два независимых условия пригодности, и + каждое проверяется своим узлом (ревью PR #3565: без этого теста снятие проверки + `expires_at` из подзапроса оставляло сьют зелёным).""" + dedicated = _node(db, "avito") + _node(db, "any", expired=True) + + assert acquire(db, "domclick", run_id=None) is None + assert _leased_by(db, dedicated) is None + + # ── mark_banned: защита считает так же, как acquire ────────────────────────── @@ -179,3 +193,39 @@ def test_mark_banned_still_protects_when_dedicated_node_has_no_usable_backup( assert mark_banned(db, banned, source="cian") == "protected" lease = acquire(db, "cian", run_id=None) assert lease is not None and lease.id == banned + + +def test_mark_banned_unhealthy_backup_does_not_count(db: Session) -> None: + """Резерв выделенного узла в карантине по consecutive_fails — для cian узла нет, + защита держит. Сам резерв кандидатом cian не считается по той же причине (карантин + проверяется и во внешнем отборе), так что ответ решает именно предикат резерва.""" + banned = _node(db, "any") + _node(db, "avito") + _ban(db, banned, "avito") + _node(db, "any", fails=MAX_CONSECUTIVE_FAILS) + + assert mark_banned(db, banned, source="cian") == "protected" + + +def test_mark_banned_expired_backup_does_not_count(db: Session) -> None: + """Резерв выделенного узла с истёкшей арендой — для cian узла нет, защита держит.""" + banned = _node(db, "any") + _node(db, "avito") + _ban(db, banned, "avito") + _node(db, "any", expired=True) + + assert mark_banned(db, banned, source="cian") == "protected" + + +def test_mark_banned_expired_node_does_not_save_the_source(db: Session) -> None: + """Внешний отбор: у cian остался только 'any'-узел с истёкшей арендой. acquire его не + выдаёт, значит банимый узел последний — бан не пишется, и cian получает этот узел. + + До правки защита засчитывала просроченный узел (пока healthcheck не наберёт ему + отказов), бан уходил, а cian оставался ни с чем.""" + banned = _node(db, "any") + _node(db, "any", expired=True) + + assert mark_banned(db, banned, source="cian") == "protected" + lease = acquire(db, "cian", run_id=None) + assert lease is not None and lease.id == banned