146 lines
16 KiB
Markdown
146 lines
16 KiB
Markdown
# План: исправление оркестратора (двойная выдача валидатору) и очистки очереди
|
||||
|
|
|
|||
|
|
> Дата: 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:<validator>`). Вторая выдача того же валидатора не запускает привязку и стоит в `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:<id адреса>`, а не `assign:<validator>` (строка 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` перепроверять сразу после выкладки или вместе со следующим большим прогоном?
|