From c0c462252f3fb5504db60b9d73ecebd6f1aa7c3b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 31 Aug 2026 17:30:33 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/proxy):=20=D1=83=D1=81=D0=BF=D0=B5?= =?UTF-8?q?=D1=88=D0=BD=D1=8B=D0=B9=20fetch=20=D0=B7=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=B0=D0=BB=20exit=5Fip=20=D0=B8=20latency=5Fms=20=D0=B2?= =?UTF-8?q?=20NULL=20(#3283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../backend/app/services/proxy_pool.py | 16 +++++++-- .../backend/tests/services/test_proxy_pool.py | 35 +++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index 0e7dce9c..9f52e565 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -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, diff --git a/tradein-mvp/backend/tests/services/test_proxy_pool.py b/tradein-mvp/backend/tests/services/test_proxy_pool.py index 25c5455c..9a1b9ea0 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_pool.py +++ b/tradein-mvp/backend/tests/services/test_proxy_pool.py @@ -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)]) -- 2.45.3