# План: исправление оркестратора (двойная выдача валидатору) и очистки очереди > Дата: 2026-10-02 09:02 UTC · Статус: **реализовано и проверено** (юнит-тесты с `-race`, локальный e2e; выкладка и приёмка на стенде — ниже). Решения пользователя: предел очистки 10 минут и 8 параллельных отвязок приняты как базовые > Основание: [analysis/2026-10-02_08-56_1026-addresses_mass-check-analysis.md](../../analysis/2026-10-02_08-56_1026-addresses_mass-check-analysis.md) ## Context Массовая проверка 2 октября остановилась на 1026 из 6440 адресов. 7 из 20 валидаторов «залипли»: v1, v12, v13 не взяли ни одного задания после 07:17 и 07:33; v7, v16, v17, v3 залипали временно. Итог: 198 сбросов лизинга, 41 ошибка привязки Floating IP (`409 fixed IP already has a floating IP`), **42 адреса в `fail` без единой выполненной проверки**, потеря ~27% пропускной способности. Отдельно «Очистить всё» не уложилась в таймаут клиента (256 с вместо секунд) и сначала оборвалась с 600 ошибками `context canceled`. Цель: валидатор в любой момент держит не больше одного адреса; потеря heartbeat не приводит к двойной выдаче; «Очистить всё» выполняется за секунды и не прерывается разрывом соединения. ## Причины (по коду, подтверждены логами и БД) | № | Причина | Где | |---|---|---| | 1 | Heartbeat возвращает `unreachable` → `idle`, не глядя на `current_ip_id`: занятый валидатор снова считается свободным | `internal/db/queries_validators.go:41` (`Heartbeat`), `:31` (`RegisterValidator`, возврат из `unreachable`) | | 2 | Освобождение валидатора идёт **по имени**, а не по адресу, который он держит: завершение старого адреса освобождает валидатор, уже взявший новый. Так же `RequeueOrFail` (в т. ч. при сбросе лизинга) и `MarkFIPOccupied`, `FreeValidator`. Освобождённый ставится в `idle` даже если он `unreachable` — мёртвый валидатор получает новые адреса каждые 3 минуты | `internal/db/queries_ipqueue.go` (`ReleaseFIP`, `RequeueOrFail`, `MarkFIPOccupied`), `queries_validators.go:143` (`FreeValidator`), `internal/orchestrator/orchestrator.go:469` | | 3 | Моя регрессия: защита от дублей привязки ключуется по валидатору (`assign:`). Вторая выдача того же валидатора не запускает привязку и стоит в `assigning_fip` до конца лизинга | `internal/orchestrator/orchestrator.go:149` | | 4 | Агент шлёт heartbeat только между заданиями. Адрес с 3–4 таймаутами внешних проверок (~40 с) блокирует его дольше порога 30 с | `internal/agentcore/agentcore.go` (`Run`, `pollOnce`) | | 5 | «Очистить всё» отвязывает FIP у **всех** строк с непустым `fip_id`, а он остаётся у `done`/`failed`. Больше 1000 последовательных вызовов OpenStack | `internal/db/queries_ipqueue.go` (`ListFIPRefs`, `ListFIPRefsByAddresses`), `internal/orchestrator/orchestrator.go` (`ClearQueue`, `DeleteIPs`) | | 6 | Очистка работает на контексте HTTP-запроса: разрыв соединения клиентом обрывает её посреди дела (отвязано часть, БД не очищена) | `internal/httpapi/handlers_admin.go:322` | ## Инварианты, которые вводим - **I1.** Валидатор держит не более одного адреса: `validators.current_ip_id = X` тогда и только тогда, когда у строки `X` `owner_validator_id` равен этому валидатору и состояние не терминальное (`done`, `failed`, `occupied`). - **I2.** `idle` означает `current_ip_id IS NULL`. Состояния `unreachable` и `unregistered` не затираются освобождением. - **I3.** Валидатор освобождает только тот адрес, который он сейчас держит. Освобождение и возврат по лизингу чужого или устаревшего адреса состояние валидатора не меняют. ## Изменения ### 1. База данных (без миграций, только запросы) `internal/db/queries_validators.go`, `queries_ipqueue.go`: - **`Heartbeat`:** `unreachable` → `assigned`, если `current_ip_id IS NOT NULL`, иначе `idle`. То же в `RegisterValidator` (возврат из `unreachable`/`unregistered`). - **Общая функция освобождения** `freeValidatorTx(tx, validatorID, ipID)`: `UPDATE validators SET current_ip_id=NULL, state = CASE WHEN state='unreachable' THEN state ELSE 'idle' END WHERE validator_id=? AND current_ip_id=?`. Используют `ReleaseFIP`, `RequeueOrFail`, `MarkFIPOccupied`, `FreeValidator` (получает второй аргумент — id адреса). `deleteIPTx` (уже по `current_ip_id`) и `ClearAllIPs` переводятся на тот же `CASE`, чтобы не затирать `unreachable`. - **`ClaimNextQueued`:** условие обновления валидатора дополняется `AND current_ip_id IS NULL`. - **`ReconcileValidators`** (новая, вызывается из `Orchestrator.Tick`): лечит нарушение инвариантов, если они всё же возникли (падение процесса, старые строки): валидатор с `current_ip_id`, чья строка не существует, терминальна или принадлежит другому валидатору, освобождается; строка в `assigning_fip`/`awaiting_self_check`/`checking`, чей владелец не ссылается на неё, не трогается (её вернёт сброс лизинга). Один короткий запрос на такт. - **`ListFIPRefs` и `ListFIPRefsByAddresses`:** только строки в нетерминальных состояниях (`state NOT IN done, failed, occupied`) с непустым `fip_id`: у терминальных FIP уже отвязан (агрегация, возврат по лизингу, отмена и `occupied` делают это до записи состояния). Значения `fip_id` в строках не меняются (дашборд их показывает). ### 2. Оркестратор (`internal/orchestrator/orchestrator.go`) - Ключ защиты от дублей привязки: `assign:`, а не `assign:` (строка 149). Дублирующий запуск привязки одного и того же адреса по-прежнему исключён. - `ForceCancel`, `sweepExpiredLeases`, `aggregateAndRelease`, `SelfCheckResult`: передают id адреса в освобождение (I3). - **`ClearQueue`/`DeleteIPs`:** отвязка FIP из `ListFIPRefs` (теперь ≤ числа валидаторов) выполняется параллельно, не более 8 одновременных вызовов; затем прямой опрос портов (`releaseValidatorPorts`) как сейчас. - `Tick`: вызывает `ReconcileValidators` первым шагом. ### 3. HTTP (`internal/httpapi/handlers_admin.go`) - «Очистить всё», удаление списка и отмена: контекст отвязан от отмены запроса (`context.WithoutCancel`) с собственным пределом времени (10 минут). Разрыв соединения клиентом (в т. ч. таймаут дашборда) больше не обрывает операцию на середине. ### 4. Агент (`internal/agentcore/agentcore.go`) - Heartbeat уходит из `pollOnce` в **отдельную горутину** со своим тикером (период `poll_interval_seconds`); останавливается по отмене контекста. Долгие внешние проверки больше не блокируют heartbeat. Ошибки heartbeat пишутся в лог (предупреждение). - Протокол и конфигурация агента не меняются (старый агент с новым сервером и наоборот работают). ### 5. Документация `docs/USAGE.md`/`docs/DIAGRAMS.md` (состояния валидатора и правила освобождения), запись в истории изменений `README.md`. ## Тесты Пишу сам (агенты тесты не делают); каждый тест должен падать без исправления. - **db:** - `Heartbeat` из `unreachable` с адресом даёт `assigned`, без адреса `idle`; `RegisterValidator` аналогично; - `ReleaseFIP`/`RequeueOrFail`/`MarkFIPOccupied` старого адреса не освобождают валидатор, который уже держит другой адрес; - освобождение `unreachable`-валидатора оставляет `unreachable`; - `ClaimNextQueued` не выдаёт адрес валидатору с `current_ip_id`; - `ReconcileValidators` лечит битые строки и не трогает корректные; - `ListFIPRefs`/`ListFIPRefsByAddresses` не содержат `done`/`failed`/`occupied`. - **orchestrator:** - сценарий инцидента: валидатор помечен `unreachable`, держа адрес A с идущими проверками → heartbeat → такт оркестратора **не выдаёт** ему B; после завершения A валидатор получает B; - мёртвый валидатор (`unreachable`, лизинг истёк) не получает новых адресов; - `ClearQueue` при 1000 строк `done` с `fip_id` и 5 активных не вызывает `Disassociate` для `done` (счётчик вызовов в обёртке OpenStack); - защита привязки по адресу (два адреса одного валидатора, искусственно, оба привязываются); - **случайный сценарий под нагрузкой** (20 валидаторов, 300 адресов, случайная потеря heartbeat, долгие проверки, часть адресов с отказом привязки): после каждого такта проверяются I1–I3; в конце все адреса завершены, сбросов лизинга нет. - **httpapi:** «Очистить всё» доживает до конца при отмене контекста запроса посередине (БД очищена, FIP отвязаны). - **agentcore:** во время долгой проверки (цель отвечает 3 с) heartbeat уходит по расписанию; остановка по отмене контекста. - **Общий прогон:** `go vet`, `go test -race ./...`, `scripts/run-local-e2e.sh` (с `ip_echo` и `control_api`). ## Выкладка 1. Правки control-api: сборка `bin/control-api`, образ `civ-capi`, перезапуск (БД не затрагивается; миграций нет). Одного этого достаточно, чтобы остановить двойные выдачи и бесконечное «залипание». 2. Агент: сборка `bin/validator-agent`, коммит, раскатка Ansible-сценарием `deploy/ansible` (запускает пользователь): heartbeat в отдельном потоке убирает ложные `unreachable`. 3. Перепроверка 42 адресов: список выгружается из снимка анализа в файл `analysis/2026-10-02_08-56_failed-addresses.txt` (уже выгружен, 42 адреса); ставятся в очередь через `POST /api/v1/admin/ips` (или «Перепроверка» в дашборде) после выкладки. ## Приёмка на стенде Контрольная группа из 20 адресов, затем 400 адресов (при тех же внешних целях с долгими таймаутами) с наблюдением 30 минут: | Показатель | Критерий | |---|---| | `lease_expired` | 0 | | Ошибки привязки `409` | 0 | | Двойные выдачи (два `claimed ip` одному валидатору за <10 с) | 0 | | Завершено каждым валидатором | отклонение от среднего не больше 15% | | `unreachable` при долгих проверках | нет (после раскатки нового агента) | | «Очистить всё» при >1000 строк `done` | ответ не дольше 10 с | | Адреса `fail` | только по существу (не из-за лизинга) | ## Риски и откат - Изменения только в запросах и логике, без миграций; схема БД и протокол агента не меняются. Откат — предыдущий образ `civ-capi` (`docker tag`/предыдущий коммит) и прежний бинарник агента. - Риск: условные `UPDATE` по `current_ip_id` могут оставить валидатор занятым, если строка адреса пропала. Страхует `ReconcileValidators`. - Валидатор `unreachable` теперь не получает адресов до первого heartbeat — это намеренно; если агент жив, но heartbeat по сети не проходит, он простаивает (раньше брал адреса и терял их). ## Не входит в эту правку - Параллельное выполнение внешних проверок в агенте (3–4 таймаута сейчас идут подряд): сократило бы цикл с ~84 с и снизило бы нагрузку на heartbeat; отдельное решение. - Судьба цели `packages.ubuntu.com` (71% `partial`) и причины недоступности проберов — отдельные вопросы из анализа. - Сокращение `fip_settle_seconds` (30 с) ради темпа. ## Вопросы к согласованию 1. Предел времени «Очистить всё» (10 минут) и число параллельных отвязок (8) — подходят? 2. Выкладывать control-api сразу после тестов (до раскатки агента) — да, как в разделе «Выкладка»? 3. 42 адреса `fail` перепроверять сразу после выкладки или вместе со следующим большим прогоном?