fix(tradein/proxy): успешный fetch затирал exit_ip и latency_ms в NULL (#3283)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m56s

mark_health(ok=True) присваивал exit_ip/latency_ms без условия. Из healthcheck'а
это верно — он передаёт измеренные значения. Но подавляющее большинство вызовов
приходит не оттуда, а из BrowserFetcher._report_fetch_result на КАЖДЫЙ успешный
/fetch и из finally у curl_proxy_url: они зовут mark_health(lease, ok) без
exit_ip/latency_ms, адаптер подставляет None — и None затирал адрес, записанный
proxy_rotation._update_exit_ip минутой раньше.

Прод 31.08: у обоих живых узлов exit_ip IS NULL, при том что в логе ротации
new_ip=31.173.86.74. Аудита «на каком адресе мы были» не существовало.

Правка — COALESCE(:param, <колонка>) на обе строки. Явное значение по-прежнему
пишется; очистить поле через mark_health больше нельзя, для этого есть прямой
UPDATE.

Проверено на живом PostgreSQL: тот же UPDATE с NULL-параметрами в транзакции
сохраняет прежние значения (транзакция откачена, прод не тронут). FakeSession в
тестах интерпретирует SQL по фрагментам, поэтому её ветка mark_health приведена
в соответствие — иначе тест проверял бы фейк, а не поведение.

Тесты: 5254 passed, 37 skipped.
This commit is contained in:
bot-backend 2026-08-31 17:30:33 +03:00
parent 9219ab6507
commit c0c462252f
2 changed files with 47 additions and 4 deletions

View file

@ -573,8 +573,20 @@ def mark_health(
SET consecutive_fails = 0,
last_ok_at = now(),
last_check_at = now(),
exit_ip = CAST(:exit_ip AS text),
latency_ms = CAST(:latency_ms AS integer),
-- COALESCE, а не присваивание (#3283): подавляющее
-- большинство вызовов приходит НЕ из healthcheck'а, а из
-- _report_fetch_result на КАЖДЫЙ успешный /fetch и из
-- finally у curl_proxy_url они зовут mark_health(lease, ok)
-- БЕЗ exit_ip/latency_ms, и адаптер подставляет None. Голое
-- присваивание затирало этим None адрес, записанный
-- proxy_rotation._update_exit_ip минутой раньше, так что
-- exit_ip в базе жил до первого же успешного запроса
-- (прод 31.08: у обоих живых узлов NULL при new_ip=
-- 31.173.86.74 в логе ротации). Явное значение по-прежнему
-- пишется; очистить поле через mark_health больше нельзя
-- для этого есть прямой UPDATE.
exit_ip = COALESCE(CAST(:exit_ip AS text), exit_ip),
latency_ms = COALESCE(CAST(:latency_ms AS integer), latency_ms),
enabled = CASE
WHEN disabled_reason IS NULL THEN true ELSE enabled
END,

View file

@ -266,8 +266,14 @@ class FakeSession:
row = self._by_id(p["id"])
if row is not None:
row["consecutive_fails"] = 0
row["exit_ip"] = p["exit_ip"]
row["latency_ms"] = p["latency_ms"]
# Зеркалит COALESCE(:param, <колонка>) реального SQL (#3283):
# mark_health(lease, ok) без exit_ip/latency_ms приходит из
# _report_fetch_result на каждый успешный /fetch, и None не должен
# затирать адрес, записанный ротацией.
if p["exit_ip"] is not None:
row["exit_ip"] = p["exit_ip"]
if p["latency_ms"] is not None:
row["latency_ms"] = p["latency_ms"]
row["last_ok_at"] = datetime.now(UTC)
row["last_check_at"] = datetime.now(UTC)
# Ручное выключение (#2610): реальный SQL реанимирует (enabled=true)
@ -756,6 +762,31 @@ def test_mark_health_ok_resets_and_records() -> None:
assert row["last_ok_at"] is not None
def test_mark_health_ok_without_exit_ip_keeps_stored_value() -> None:
"""#3283: успешный /fetch зовёт mark_health(lease, ok) БЕЗ exit_ip/latency_ms.
До фикса None затирал адрес, записанный proxy_rotation._update_exit_ip, и
scrape_proxies.exit_ip жил до первого же успешного запроса (прод 31.08: у обоих
живых узлов NULL, при том что в логе ротации new_ip=31.173.86.74).
"""
db = FakeSession([_proxy(1)])
mark_health(db, 1, ok=True, exit_ip="31.173.86.74", latency_ms=120) # type: ignore[arg-type]
mark_health(db, 1, ok=True) # type: ignore[arg-type] # как из _report_fetch_result
row = db._by_id(1)
assert row["exit_ip"] == "31.173.86.74"
assert row["latency_ms"] == 120
def test_mark_health_ok_with_exit_ip_overwrites_stored_value() -> None:
"""Явное значение по-прежнему пишется — COALESCE не превращает поле в append-only."""
db = FakeSession([_proxy(1)])
mark_health(db, 1, ok=True, exit_ip="1.2.3.4", latency_ms=88) # type: ignore[arg-type]
mark_health(db, 1, ok=True, exit_ip="5.6.7.8", latency_ms=99) # type: ignore[arg-type]
row = db._by_id(1)
assert row["exit_ip"] == "5.6.7.8"
assert row["latency_ms"] == 99
def test_mark_health_ok_revives_disabled_proxy() -> None:
"""Успешная проба реанимирует выключенный узел (#2600 п.1) — enabled=true, fails=0."""
db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD)])