Files
cloud-ip-validator/docs/changes/2026-10-02_09-02_orchestrator-validator-state-and-clear-plan.md
ayurishchevandClaude Sonnet 5.5 0532baff09 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>
2026-10-02 14:42:12 +03:00

16 KiB
Raw Permalink Blame History

План: исправление оркестратора (двойная выдача валидатору) и очистки очереди

Дата: 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 тогда и только тогда, когда у строки 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 перепроверять сразу после выкладки или вместе со следующим большим прогоном?