# План: вердикт без опоздавших результатов Статус: план, код не менялся. Основа: код `internal/orchestrator`, `internal/probercore`, `internal/httpapi` и БД стенда (запуск 2026-10-02, 6440 адресов). ## 1. Требование Вердикт адреса и его проверки должны совпадать. После вердикта результат не может появиться или измениться. Вердикт можно пересчитать из сохранённых проверок и получить то же значение. ## 2. Причины (проверены по коду и данным) 1. **Prober проверяет один адрес много раз.** `GET /probers/{site}/assignments` отдаёт все адреса в состоянии `checking`. Prober (`probercore.pollOnce`) обходит их при каждом опросе (каждые 5 с) и ничего не помнит о том, что адрес уже проверен. Пока адрес ждёт остальные площадки и egress, он проверяется снова и снова. 2. **Новый результат затирает старый.** Результаты пишутся через `UpsertCheck` с `ON CONFLICT DO UPDATE`. Строка живёт с первой записи, а значения берутся из последнего круга. В БД 61 406 из 76 644 ingress-строк (80%) перезаписаны позже первой записи более чем на 3 с. У egress перезаписей нет (агент проверяет один раз). 3. **Записи принимаются после вердикта.** `Orchestrator.RecordCheck` вызывает `UpsertCheck` без проверки состояния адреса. Итог на запуске 02.10: - ingress: 1430 строк записаны после вердикта (844 проваленных и 586 успешных); из 844 проваленных 833 записаны уже после отвязки Floating IP, то есть проверяли адрес, который к валидатору уже не привязан; - egress: 351 строка после вердикта (32 проваленных). 4. **Окно считается не с начала проверки.** `isReadyToAggregate` сравнивает `assigned_at` с порогом `checking_window_seconds` (120 с). `assigned_at` ставится при выдаче адреса валидатору, то есть окно включает привязку Floating IP, паузу `fip_settle_seconds` (30 с) и self-check (до 60 с). На сами проверки остаётся около 30 с. По окну агрегировано 509 адресов (8%), у них часть результатов не успела прийти. 5. **Время сравнивается чужими часами.** `checks.checked_at` ставит prober или агент на своей машине, а `aggregated_at` — control-api. При расхождении часов «после вердикта» определяется неверно. Следствие: часть проваленных ingress-проверок в БД — не провалы адреса, а результат проверки уже отвязанного Floating IP. Отчёты по провалам ingress (в том числе таймаутам) за запуск 02.10 нужно читать с этой поправкой. ## 3. Решение ### A. Одна проверка адреса на площадку и попытку `handleProberAssignments` не отдаёт адрес, для которого эта площадка уже отметила `complete` в `ip_site_checks` в текущей попытке. Новая попытка (повтор после сбоя) отдаёт адрес снова. Prober менять не нужно, пересобирается только `control-api`. Побочный эффект: нагрузка на prober и на сеть падает в разы. ### B. После вердикта запись закрыта `UpsertCheck` пишет строку только если адрес в состоянии `checking` и номер попытки совпадает. Проверка выполняется в одном SQL-запросе (условие в `INSERT … SELECT … WHERE EXISTS`), без гонок. - Отброшенный результат не пишется и не меняет существующую строку. - Ответ агенту и prober остаётся `200 {"ok": true, "ignored": true}`, чтобы они не повторяли запрос. - На каждую отброшенную пачку пишется одно событие `result_dropped` (источник, число проверок). Счётчик виден в аналитике; в норме он равен нулю или близок к нему. ### C. Окно проверки считается с начала проверки Новая колонка `ip_queue.checking_started_at`, ставится в `SetChecking`. `isReadyToAggregate` сравнивает порог с ней, а не с `assigned_at`. Значение `checking_window_seconds` не меняется (120 с), но теперь это 120 с именно на проверки. Для строк без значения (старые данные) используется `assigned_at`. ### D. Время записи ставит сервер Новая колонка `checks.recorded_at` — время последней записи по часам control-api; при перезаписи обновляется. Все сравнения «до/после вердикта» идут по ней, а не по `checked_at`. Для старых строк значение равно `created_at`. ### E. Вердикт воспроизводим При агрегации в событие `aggregated` дописываются `egress_total`, `ingress_total`. Тест-инвариант: вердикт, пересчитанный по сохранённым проверкам адреса с учётом недостающих, равен `overall_result`. ### F. Недостающие результаты Правило не меняется: недостающая проверка считается провалом, вердикт не выше `partial`. Благодаря C число таких адресов должно упасть. Остаток показывается отдельно («нет результата»), а не смешивается с провалами. ### G. Старые данные В миграции `0010` колонка `checks.after_verdict` (0/1) для существующих строк: `1`, если `checked_at` позже `aggregated_at` строки очереди. Пересчитать затёртые результаты нельзя, поэтому в аналитике они помечаются как недостоверные и показываются отдельно (на запуске 02.10: 1430 ingress и 351 egress). После внедрения для новых данных значение всегда `0`. Отвергнутый вариант: пересчитывать вердикт при опоздавших результатах. К этому моменту Floating IP уже отвязан, и такие результаты ничего не доказывают. ## 4. Файлы - `internal/db/migrations/0010_verdict_integrity.sql` — колонки `ip_queue.checking_started_at`, `checks.recorded_at`, `checks.after_verdict`. - `internal/db/queries_checks.go` — условная запись, признак «отброшено». - `internal/db/queries_ipqueue.go` — `SetChecking`, выборка для prober без адресов с готовой площадкой. - `internal/orchestrator/orchestrator.go` — `RecordCheck` возвращает признак, `isReadyToAggregate` по `checking_started_at`, поля в событии `aggregated`. - `internal/httpapi/handlers_agent.go`, `handlers_prober.go` — ответ `ignored`, событие `result_dropped`. - Документы: `docs/API.md` (ответы на запись результатов), `docs/USAGE.md`, `README.md`. - Нумерация миграций: этот план идёт раньше плана аналитики (`…_analytics-section-plan.md`); аналитика получает `0011`. ## 5. Тесты - Запись после вердикта игнорируется: строка не создана и не изменена; запись в состоянии `checking` проходит; запись старой попытки игнорируется. - Повторная запись той же проверки внутри окна идемпотентна. - `assignments`: после `complete` площадки адрес не возвращается ей, но возвращается остальным; после повтора попытки возвращается снова. - Окно считается с `checking_started_at`: адрес, назначенный давно, но начавший проверку только что, не агрегируется по окну. - Инвариант вердикта: для набора сценариев (все успешны, часть провалена, часть недостающих, ничего) вердикт равен пересчёту. - Миграция на копии боевой БД: `after_verdict` = 1 для 1430 ingress и 351 egress строк запуска 02.10, остальное без изменений; повторный запуск безопасен. ## 6. Проверка на стенде 1. Копия `rxprod-compose/capi-db/control-api.db`, пересборка и перезапуск `control-api` (по процедуре из `docs/SETUP.md`). Prober и агенты не пересобираются. 2. Контрольный прогон на небольшой партии (например, 300 адресов). 3. Ожидаемый результат: ни одной строки `checks` с `recorded_at` позже `aggregated_at` (`after_verdict = 0`); нет перезаписанных ingress-строк (`recorded_at` равен первой записи); событий `result_dropped` нет или единицы; адресов с агрегацией по окну заметно меньше 8%. 4. Сравнение с запуском 02.10: доля проваленных ingress-проверок (таймауты ssh, tcp-22, icmp) должна уменьшиться, если часть прежних провалов была отвязанным Floating IP. Если не уменьшится, версия про отвязку неверна, и нужно искать другую причину. ## 7. Риски - Отброшенные результаты теряются. Это сознательно: после вердикта Floating IP отвязан. Счётчик `result_dropped` показывает, насколько часто это случается. - Уменьшение числа проверок prober меняет нагрузку на сеть площадок. Это ожидаемый эффект, а не риск, но стоит предупредить владельцев площадок. - Новое окно (с начала проверки) может чуть увеличить длительность прогона на адресах, у которых действительно не пришли результаты. Значение окна настраивается. ## 8. Порядок по правилам проекта План (этот файл) → реализация → `…-summary.md` → обновление `README.md` и `docs/` → пересборка стенда → контрольный прогон → обновление графа. Раздел «Аналитика» (план `2026-10-03_16-39`) строится после этого изменения: «Поздние результаты» там становятся контрольным показателем, который должен быть равен нулю.