ЕЭСК-лоадер: бинд-параметр в SQL-комментарии ронял все 71 UPDATE с 18.08
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m1s
CI / backend-tests (pull_request) Successful in 17m30s

Комментарий #2464-B, объясняющий, почему из UPDATE убрали :load_pct, сам
содержал «CAST(:load_pct AS text)» — а SQLAlchemy text() парсит бинды и
внутри SQL-комментариев. Параметр стал обязательным, params его не содержит,
КАЖДАЯ строка батча падала на компиляции, per-row SAVEPOINT-except глотал
это как «битую строку», задача оставалась зелёной. Резервы ПС 35-220 не
обновлялись две недели, и никакой сторож этого не видел.

Найдено армейским аудитом 01-02.09 (линза ptica-workers), подтверждено
скептиком воспроизведением на проде.

Правка — переписан комментарий БЕЗ упоминания снятого параметра в живом
синтаксисе бинда, с предупреждением, почему это запрещено.

Сторож на МЕХАНИЗМ: тест собирает text()-стейтмент из исходника и сверяет
его бинд-имена с ключами params. БД не нужна — дефект живёт на компиляции.
Фальсификация: возврат «:load_pct» в комментарий даёт красное по значению
(«стейтмент требует биндов ['load_pct']»).
This commit is contained in:
bot-backend 2026-09-02 11:53:10 +05:00
parent 4fe0538ea0
commit a9de9bedf6
2 changed files with 81 additions and 16 deletions

View file

@ -246,22 +246,19 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
reserve_unit = 'МВт',
installed_capacity_mva = :installed,
district = :district,
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки.
-- load_index категориальная колонка
-- ('open'|'limited'|'closed'|NULL, см.
-- data/sql/180_connection_capacity.sql:35), её
-- заполняет rosseti_wfs_loader._map_load_index.
-- Раньше тут стоял COALESCE(load_index,
-- CAST(:load_pct AS text)) при пустой ячейке
-- в колонку легло бы число строкой ("72.5"),
-- а фронтовый classifyLoadIndex такое значение
-- отбрасывает в null («неизвестно»), и в
-- power_summary.by_load_index появился бы
-- бакет с именем "72.5".
-- Сегодня не стреляло только потому, что у всех
-- 3416 строк load_index уже заполнен
-- (open 2741 / limited 346 / closed 329, NULL 0)
-- и COALESCE не проваливался.
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки:
-- load_index категориальная колонка, её заполняет
-- rosseti_wfs_loader._map_load_index. Историю см. в
-- git log этого файла.
--
-- ВАЖНО: в этом комментарии НЕЛЬЗЯ упоминать
-- бинд-параметры в синтаксисе «двоеточие + имя».
-- SQLAlchemy text() парсит бинды и внутри
-- SQL-комментариев: упоминание снятого параметра
-- «(двоеточие)load_pct» в тексте комментария
-- сделало его ОБЯЗАТЕЛЬНЫМ, все 71 UPDATE падали
-- с 18.08 по 02.09, а per-row except глотал это
-- как «битую строку» задача оставалась зелёной.
capacity_source = 'eesk_35_220',
reserve_asof = :asof
WHERE sc_name_norm = :name_norm

View file

@ -0,0 +1,68 @@
"""Бинды SQL-стейтментов ЕЭСК-лоадера обязаны совпадать с передаваемыми params.
ПОЧЕМУ ЭТОТ ТЕСТ СУЩЕСТВУЕТ. 18.08-02.09.2026 все 71 UPDATE резервов ПС
падали, а задача оставалась зелёной: упоминание СНЯТОГО параметра load_pct
в SQL-КОММЕНТАРИИ (в синтаксисе «двоеточие+имя») SQLAlchemy text() распарсил
как обязательный бинд, params его не содержал, каждая строка падала на
compile, а per-row except глотал это как «битую строку». ЕЭСК-резервы не
обновлялись две недели, freshness этого не видел (задача done).
Тест ловит МЕХАНИЗМ: собирает все text()-стейтменты модуля и сверяет их
бинд-имена с ключами словаря params из того же контекста. Никакой БД не
нужно дефект живёт на этапе компиляции стейтмента.
"""
from __future__ import annotations
import re
from pathlib import Path
from sqlalchemy import text
LOADER = (
Path(__file__).resolve().parents[1]
/ "app"
/ "services"
/ "site_finder"
/ "eesk_reserve_loader.py"
)
def _extract_update_sql() -> str:
"""Достаёт текст UPDATE power_supply_centers из исходника лоадера."""
src = LOADER.read_text(encoding="utf-8")
m = re.search(r'text\("""\s*(UPDATE power_supply_centers.*?)"""\)', src, re.S)
assert m, "UPDATE power_supply_centers не найден в лоадере"
return m.group(1)
def _extract_params_keys() -> set[str]:
"""Ключи словаря params, который передаётся в этот execute."""
src = LOADER.read_text(encoding="utf-8")
m = re.search(r"params = \{(.*?)\}", src, re.S)
assert m, "словарь params не найден"
return set(re.findall(r'"([a-z_]+)":', m.group(1)))
def test_update_binds_are_subset_of_params() -> None:
"""Каждый бинд стейтмента обязан приходить из params — иначе UPDATE падает
на КАЖДОЙ строке, и per-row SAVEPOINT-except прячет это под «битую строку»."""
stmt = text(_extract_update_sql())
binds = {p.key for p in stmt._bindparams.values()}
params = _extract_params_keys()
missing = binds - params
assert not missing, (
f"стейтмент требует биндов {sorted(missing)}, которых нет в params — "
"все строки батча упадут молча. Частая причина: упоминание "
"«двоеточие+имя» в SQL-комментарии (text() парсит бинды и там)."
)
def test_comment_does_not_reintroduce_phantom_bind() -> None:
"""Регресс 18.08: снятый параметр упомянули в комментарии в живом
синтаксисе бинда. Проверяем ЗНАЧЕНИЕМ: компиляция с ровно теми params,
что собирает лоадер, не требует ничего лишнего."""
stmt = text(_extract_update_sql())
have = _extract_params_keys()
for bp in stmt._bindparams.values():
assert bp.key in have, f"фантомный бинд из комментария: {bp.key!r}"