Keep one address per validator; fix heartbeat handling and queue clear
A mass check on 2026-10-02 stalled 7 of 20 validators and sent 42 addresses to fail without a single check. A validator busy with slow checks went silent, was marked unreachable, and its next heartbeat put it back to idle while it still held the address; it was handed a second one, whose association never ran (the in-flight guard was keyed by validator), and both waited for their leases to expire. - Heartbeat/re-register return an unreachable validator to assigned when it still holds an address, else idle. - A validator is released only from the address it currently holds (ReleaseFIP, RequeueOrFail, MarkFIPOccupied, FreeValidator); an unreachable validator stays unreachable until its next heartbeat, so a dead validator is no longer handed a new address every lease period. - ClaimNextQueued refuses a validator that still has an address; a ReconcileValidators pass on every tick repairs rows that disagree with the queue. - Association guard is keyed by address, not validator. - The agent sends heartbeats from their own goroutine. - Clear queue / delete: detach only floating IPs of unfinished rows (done, failed and occupied rows kept their fip_id and made a clear issue >1000 sequential cloud calls: 256 s), at most 8 in parallel; the operation no longer dies with the client connection (10 minute limit). Includes the incident analysis and the plan under analysis/ and docs/changes/, and rebuilt bin/control-api and bin/validator-agent. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf4a883363
commit
0532baff09
17 files changed
+1293
-62
No files matched your search
@@ -408,6 +408,17 @@ curl -s http://<control-api>:8080/api/v1/admin/validators | python3 -m json.tool
|
||||
(занят), `unreachable` (пропустил heartbeat дольше
|
||||
`orchestrator.heartbeat_timeout_seconds`).
|
||||
|
||||
Правила, которые держат состояние валидатора согласованным:
|
||||
- валидатор держит **не более одного адреса**; адрес освобождает валидатор
|
||||
только пока он остаётся его текущим (запоздалое завершение старого адреса
|
||||
чужого валидатора не освобождает);
|
||||
- после пропущенного heartbeat валидатор, у которого есть адрес, возвращается
|
||||
в `assigned`, а не в `idle`, и не получает второй адрес; без адреса — в `idle`;
|
||||
- `unreachable`-валидатор не получает адресов, пока не пришлёт heartbeat
|
||||
(даже если лизинг его адреса истёк и адрес вернулся в очередь);
|
||||
- на каждом такте оркестратор сверяет валидаторы с очередью и исправляет
|
||||
расхождения (в логе `repaired validators that disagreed with the queue`).
|
||||
|
||||
**Добавление нового валидатора (без перезапуска control-api):**
|
||||
1. Поднимите новую ВМ в сервисном проекте облака, узнайте её Neutron
|
||||
`port_id`.
|
||||
@@ -690,6 +701,12 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/delete \
|
||||
curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/clear
|
||||
```
|
||||
|
||||
Очистка отвязывает Floating IP только у адресов, которые ещё в работе
|
||||
(завершённые уже свободны), затем сама опрашивает порты валидаторов в облаке и
|
||||
снимает оставшиеся привязки адресов из реестра. Занимает секунды. Она не
|
||||
прерывается разрывом соединения (таймаутом клиента или дашборда): операция
|
||||
доводится до конца на стороне control-api, предел — 10 минут.
|
||||
|
||||
В `admin-dashboard` то же самое доступно на странице `/ips`: чекбоксы у
|
||||
каждой строки + кнопка «Удалить выбранные» для точечного/массового
|
||||
удаления, кнопка «Удалить» в каждой строке, и отдельная кнопка «Очистить
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# План: исправление оркестратора (двойная выдача валидатору) и очистки очереди
|
||||
|
||||
> Дата: 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` перепроверять сразу после выкладки или вместе со следующим большим прогоном?
|
||||
Reference in new issue
Block a user