Пул прокси МЕРА: оплаченный выделенный узел снова выдаётся, бан не выбивает последний узел, прогон знает свой прокси #3565

Merged
bot-backend merged 11 commits from fix/proxy-pool-guards into main 2026-09-17 10:49:04 +00:00
2 changed files with 58 additions and 4 deletions
Showing only changes of commit 29ac3043b4 - Show all commits

View file

@ -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

View file

@ -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