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>
16 KiB
План: исправление оркестратора (двойная выдача валидатору) и очистки очереди
Дата: 2026-10-02 09:02 UTC · Статус: реализовано и проверено (юнит-тесты с
-race, локальный e2e; выкладка и приёмка на стенде — ниже). Решения пользователя: предел очистки 10 минут и 8 параллельных отвязок приняты как базовые Основание: 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тогда и только тогда, когда у строкиXowner_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).
Выкладка
- Правки control-api: сборка
bin/control-api, образciv-capi, перезапуск (БД не затрагивается; миграций нет). Одного этого достаточно, чтобы остановить двойные выдачи и бесконечное «залипание». - Агент: сборка
bin/validator-agent, коммит, раскатка Ansible-сценариемdeploy/ansible(запускает пользователь): heartbeat в отдельном потоке убирает ложныеunreachable. - Перепроверка 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 с) ради темпа.
Вопросы к согласованию
- Предел времени «Очистить всё» (10 минут) и число параллельных отвязок (8) — подходят?
- Выкладывать control-api сразу после тестов (до раскатки агента) — да, как в разделе «Выкладка»?
- 42 адреса
failперепроверять сразу после выкладки или вместе со следующим большим прогоном?