Files
cloud-ip-validator/docs/changes/2026-10-01_18-19_fip-scan-at-scale-plan.md
ayurishchevandClaude Sonnet 5.5 aff8fe38b5 Scan floating IPs in the background, page by page, so thousands of addresses work
The "Scan Floating IP" button failed with a client timeout: the project now
holds ~6.4k floating IPs and the scan listed them all in one unpaginated,
timeout-less Neutron request on the HTTP request context.

openstack: ListFreeFloatingIPs reads marker-based pages (fields= keeps them
small) with per-page retry/backoff on transport errors, 5xx and 429, and every
request now has a timeout (also ends hangs inside the orchestrator tick).

orchestrator: the scan is a single-flight background job on the process
context with progress (clearing/listing/enqueuing/done/error), dry_run, full
discovery before anything is enqueued, then SubmitIPs in chunks of 500 in
ascending IP order; a failed read leaves the queue untouched. The auto-cycle
gets a "scanning" phase that polls the job, so the control loop and
autoCycleMu are never held across OpenStack/DB work; it recovers after a
restart and waits for (instead of adopting) a scan started by someone else.

db: migration 0009 (indexes), paged ListIPsPage/ListRegistryPage, GROUP BY
counters, EXISTS completion check, set-based ClearAllIPs.

API: POST /admin/ips/scan -> 202 (dry_run, wait), GET /admin/ips/scan, paging
and filters on /admin/ips and /admin/registry (bare arrays without limit),
results_by_overall in /admin/status.

dashboard: scan progress panel and dry-run button, paginated /ips and
/registry with server-side filters, Overview on counters and capped lists
with progress/ETA, "select all N by filter", hx-params fix for per-row
buttons, real counts in confirmations.

Also: docs (API, USAGE, DASHBOARD, README), plan and review under
docs/changes/, bin/ rebuilt with new SHA256SUMS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 19:31:11 +03:00

23 KiB
Raw Permalink Blame History

План: сканирование Floating IP и автоцикл при тысячах адресов

Дата: 2026-10-01 18:19 MSK · Статус: реализовано — результаты ревью и тестов: 2026-10-01_18-59_fip-scan-at-scale-review.md

Context

Нажатие «Сканировать Floating IP» на живом стенде падает: control-api недоступен: … context deadline exceeded. Расследование (2026-10-01) показало: в проекте OpenStack 6441 Floating IP, 6440 свободны (раньше было 5). ListFloatingIPs запрашивает весь список одним запросом без limit и без таймаута, Neutron отвечает >60 с (постранично: 200 адресов ≈ 2,4 с, весь список ≈ 70–80 с), а дашборд ждёт 10 с. Запрос к тому же привязан к r.Context(): при обрыве соединения скан отменяется и не может завершиться.

Цель пользователя прежняя: одной кнопкой подключить к проверке все доступные в проекте адреса, даже если их тысячи, после чего проверка запускается автоматически; автоматический цикл (очистка → скан → проверка → пауза) должен работать в этих условиях. Решение пользователя по объёму: полная адаптация — фоновый постраничный скан + автоцикл + постраничные страницы дашборда.

Ожидание по времени (оценка по реальным данным стенда: слот на адрес ≈ 50 с, из них 30 с — fip_settle_seconds): 6440 адресов ≈ 18 ч на 5 валидаторах, ≈ 9 ч на 10, ≈ 4,5 ч на 20. Это ограничение пропускной способности, а не кода; рычаги — число валидаторов и fip_settle_seconds. Для автоцикла это значит: max_run_seconds должен быть 0 (без лимита) или > 20 ч.

Карта кода (по графу и разведке)

  • openstack.FloatingIPClient (internal/openstack/interface.go): GetFloatingIPByAddress, ListFloatingIPs, Associate…, Disassociate…; реализации — реальный Client (client.go, нет таймаутов и ретраев) и MockClient (mock.go, есть ListFailure, нет пагинации).
  • Orchestrator.ScanFloatingIPs (internal/orchestrator/orchestrator.go:412) — синхронный: OS.ListFloatingIPs → фильтр PortID=="" → один DB.SubmitIPs. Вызывается из handleAdminScanFloatingIPs (internal/httpapi/handlers_admin.go:107, на r.Context()), из autoCycleStartRun (autocycle.go, в горутине цикла оркестратора под autoCycleMu — минутный скан остановит Tick и sweeps) и из периодического scanTickerC (cmd/control-api/main.go).
  • БД: SQLite, SetMaxOpenConns(1); SubmitIPs — одна транзакция, ~6 запросов на новый адрес (6440 ≈ 38 тыс. запросов); DeleteIPs/ClearQueue — одна транзакция, ~6 запросов на адрес; ListRegistry — N+1 и O(n²): нет индекса ip_queue(registry_id).
  • Full-table загрузчики: GET /admin/ips, GET /admin/status (грузит все строки ради счёта), GET /admin/registry, autoCycleCheckRun (ListIPs каждый тик), дашборд /overview (опрос каждые 5 с: ~3 МБ JSON и таблица «текущая проверка» из ~6000 queued-строк), /ips (~8 МБ HTML), /registry. Пагинации и фильтров на сервере нет.
  • Дашборд: client.do с таймаутом 10 с; фрагменты и опрос htmx (overview.html, overview_fragment.html), GET-фильтр registry.html (hx-select + hx-replace-url) — идиома для переиспользования. Per-row кнопки hx-delete при «выбрать все» кладут все отмеченные адреса в URL (уже сейчас дефект, при тысячах — фатальный).
  • Тик оркестратора не читает всю очередь (ClaimNextQueued, ListChecking — по индексу), пропускная способность не зависит от размера очереди.

Дизайн

1. OpenStack: постраничное чтение, таймауты, ретраи (internal/openstack, internal/config)

  • Новый метод интерфейса ListFreeFloatingIPs(ctx, pageSize int, onPage func(page []FloatingIP) error) (pages int, err error): цикл «страница → onPage»; для каждой страницы один запрос floatingips.List(ListOpts{Limit, Marker=lastID}) с EachPage (возврат false после первой страницы) — собственная пагинация по marker, а не next-ссылка (за прокси она может указывать на внутренний хост). Свой ListOptsBuilder, добавляющий fields=id&fields=floating_ip_address&fields=port_id&fields=project_id (в gophercloud ListOpts.Fields нет; на стенде проверено: fields + marker работают). Фильтр свободных — на клиенте (PortID==""); серверный status=DOWN не используем (надмножество, возможны гонки статуса).
  • Ретраи страницы с backoff (по умолчанию 5 попыток, 1→2→4→8→16 с) на сетевые ошибки, EOF/RemoteDisconnected, 5xx и 429 (на стенде уже наблюдался RemoteDisconnected на второй странице); 4xx (кроме 429) — без ретрая. Контекст отменяет ретраи.
  • Таймаут на запрос: provider.HTTPClient.Timeout (openstack.request_timeout_seconds, 60) — закрывает и вечные зависания в Tick (GetFloatingIPByAddress/Associate/Disassociate), ключевой побочный эффект.
  • Конфиг: openstack.list_page_size (200), openstack.request_timeout_seconds (60), orchestrator.fip_scan_timeout_seconds (1800), openstack.list_page_retries (5); дефолты в LoadControlAPI, примеры в configs/*.example.yaml и rxprod-compose/sources/.
  • ListFloatingIPs (полный список) остаётся для совместимости и тестов (реализован поверх нового метода).
  • MockClient: пагинация (PageSize), счётчик вызовов/страниц, очередь ошибок ListFailures []error (по одной на запрос), опциональная задержка страницы, SeedMany(n) для тестов на тысячи.

2. Фоновое задание скана (internal/orchestrator/scanjob.go)

  • ScanJob в Orchestrator, нулевое значение пригодно (тесты строят &Orchestrator{…} литералом): sync.Mutex, текущий прогресс, cancel. Метод StartScan(opts) (ScanStatus, started bool) — single-flight: если скан уже идёт, возвращает его статус (started=false). Горутина работает на контексте жизни процесса (хранится в Orchestrator, задаётся из main, по умолчанию context.Background()), не на r.Context(); общий дедлайн fip_scan_timeout_seconds.
  • Опции: ClearFirst bool (для автоцикла), DryRun bool (только обнаружить и посчитать, очередь не трогать — безопасная проверка на живом стенде и полезная функция для оператора).
  • Фазы и прогресс: idle → clearing → listing → enqueuing → done|error|cancelled; поля pages, discovered, free, added, requeued, reordered, skipped_in_progress, started_at, finished_at, error.
  • Алгоритм: (1) ClearFirst → ClearQueue; (2) чтение всех страниц в память (6440 строк — килобайты), free = PortID==""; (3) сортировка по IPv4 по возрастанию (детерминированный порядок очереди); (4) только после полного обнаружения — SubmitIPs кусками по 500 в этом порядке (base=MAX+1 пересчитывается на вызов ⇒ порядок сохраняется; транзакции короткие, единственное соединение освобождается между кусками); проверки стартуют, как только появляются первые queued; (5) одно событие fip_scan с итоговыми счётчиками. Ошибка чтения после ретраев ⇒ ничего не ставится в очередь (для ручного скана очередь не меняется), статус error с причиной; повтор — кнопкой или следующим циклом. Ошибка БД посередине ⇒ уже поставленные куски остаются (повтор идемпотентен: SubmitIPs переупорядочивает/пропускает).
  • ScanFloatingIPs(ctx) остаётся тонкой синхронной обёрткой («запустить и дождаться») для существующих тестов/скриптов.
  • Периодический scanTickerC вызывает неблокирующий StartScan.

3. Масштабирование БД и запросов (internal/db, миграция 0009)

  • Миграция 0009_scale_indexes.sql: idx_ip_queue_registry ON ip_queue(registry_id) (убирает O(n²) в реестре), idx_ip_queue_state_aggregated ON ip_queue(state, aggregated_at) (список «последние завершённые»).
  • Новые запросы: CountIPsByState, CountIPsByResult (GROUP BY — вместо загрузки всех строк в /admin/status), AnyNonTerminalIP (SELECT EXISTS … state NOT IN (done,failed,occupied)), ListIPsPage(filter{states[], q, result, order}, limit, offset) → (items, total), ListRegistryPage(filter{q, lastResult}, limit, offset) → (items, total) — LIMIT/OFFSET до fillRegistrySummary, поэтому 3–4 запроса на строку платят только строки страницы. Фильтр lastResult реализуется одним SQL: ip_registry r LEFT JOIN ip_queue q ON q.registry_id=r.id, условие (q.id IS NOT NULL AND q.overall_result=?) OR (q.id IS NULL AND <подзапрос по checks последнего цикла: pass/fail/partial>=?) — та же семантика, что fillRegistrySummary/lastCycleResultFromChecks, без денормализации и миграции данных.
  • ClearQueue: set-based очистка без цикла по адресам — UPDATE validators SET current_ip_id=NULL…, UPDATE checks SET ip_id=NULL, UPDATE events SET ip_id=NULL, DELETE ip_site_checks, DELETE ip_queue (5 запросов, O(n)); disassociate FIP только для строк с FIPID; событие queue_cleared — счётчик и усечённый список (не 6440 адресов). Orchestrator.DeleteIPs — выбор строк без N GetIPByAddress.

4. HTTP API (internal/httpapi) — обратная совместимость сохраняется

Метод Путь Изменение
POST /admin/ips/scan 202 {state, started_at, …} (запуск или уже идущий скан — 202 с текущим статусом); ?dry_run=true; ?wait=true — старая синхронная семантика (200 + счётчики) для curl/скриптов
GET /admin/ips/scan новый: статус и прогресс скана (admin-токен)
GET /admin/ips без параметров — как раньше (массив); с limit — конверт {items,total,limit,offset}; фильтры state (csv), q, result, order
GET /admin/registry то же: limit/offset/q/last_result → конверт с total
GET /admin/status + results_by_overall, счёт через GROUP BY
GET /admin/overview опционально одним запросом: счётчики, активные (≤100), последние завершённые (N), ближайшие в очереди (≤10), статус скана и автоцикла

Новые admin-маршруты попадают в таблицу routes.go с accessAdmin; TestRouteTableClassification (auth_test.go) обновить (+1–2 admin).

5. Автоцикл (internal/orchestrator/autocycle.go, queries_autocycle.go, dashboard/dto.go)

  • Новая фаза scanning (миграция не нужна — валидатор фаз в UpdateAutoCycleState расширить; подписи PhaseLabel — «сканирование Floating IP»).
  • autoCycleStartRun перестаёт блокировать цикл: запускает StartScan{ClearFirst:true} (очистка + скан целиком в фоне, литерал сценария пользователя сохранён: очистка → скан) и сразу переводит фазу в scanning; autoCycleMu держится только на время чтения/записи состояния, а не на всё время скана ⇒ Tick и Start/Stop не блокируются.
  • Шаг scanning: опрос статуса задания. running → выход; error → существующий путь fail() (исход error, повтор через interval_seconds); done и free==0 → no_free_ips; done → фаза running, last_scanned_free, run_started_at = конец скана (лимит max_run_seconds считается от конца скана). Таймаут самого скана — fip_scan_timeout_seconds.
  • Восстановление после рестарта: фаза scanning без живого задания ⇒ заново StartScan{ClearFirst:true} (идемпотентно).
  • Stop отменяет задание скана (исход stopped, если шёл скан или проверка).
  • autoCycleCheckRun: проверка завершения — AnyNonTerminalIP вместо ListIPs каждый тик; COUNT только при завершении.
  • Документировать: при тысячах адресов max_run_seconds=0; цикл длится часы; интервал отсчитывается от завершения.

6. Дашборд (internal/dashboard, шаблоны, CSS)

  • Скан-кнопка и прогресс: hx-post="/ips/scan" возвращает панель scan_progress (вне #ips-form): стадия, <progress>, прочитано/свободных/добавлено, время, ошибка; пока running панель сама опрашивает GET /ips/scan/status (hx-trigger="every 2s"), по завершении — без триггера и с HX-Trigger: scan-finished, по которому таблица перезагружается (hx-get + hx-select); кнопка блокируется на время скана; 409/ошибки — штатным баннером (bannerFor). Скан-старт возвращается мгновенно, таймаут 10 с больше не проблема.
  • Пагинация /ips и /registry: page, per_page (50 по умолчанию; 25/50/100/200), partial pager («Показано a–b из N», ‹ ›, url.Values для экранирования); фильтры на сервере: /registry — q, status; /ips — новая форма q + состояние (все / в очереди / в работе / done / failed / occupied / результат), идиома GET + hx-select + hx-replace-url. Скрытые page/q/state внутри #ips-form, чтобы мутации возвращали ту же страницу.
  • Массовые операции: чекбоксы — только строки страницы + счётчик «Выбрано на странице k из 50»; при отмеченном заголовке и total > per_page — ссылка «Выбрать все N по фильтру» (scope=all): адреса разрешаются на сервере постранично и уходят в DeleteIPs/SubmitIPs кусками по ~500. Per-row и scan/clear-кнопки получают hx-params="page,q,state" (чинит URL из тысяч адресов). hx-confirm с реальным числом (Удалить ВСЕ {{.Total}} адресов…).
  • «Обзор»: без ListIPs; Status (+results_by_overall) и ограниченные списки: «В работе» (активные состояния), «В очереди: Q» (счётчик + ссылка на /ips?state=queued, ≤10 ближайших), «Последние N завершённых»; occupied — терминальное состояние. Индикатор в блоке статистики (OOB-обновление, как сейчас): «Готово D из T (P%) · в работе A · в очереди Q» + <progress>, оценка времени по скорости последних завершённых; статус скана («Сканирование: прочитано X») и автоцикла.
  • client.go: ListIPsPage, ListRegistryPage, ScanStatus; длинный таймаут/отдельный клиент для clear и массовых операций.

7. Прочее

  • routes, dto_admin.go, docs/API.md (202/статус/пагинация/dry_run/wait), docs/USAGE.md (скан, автоцикл: фаза scanning, ожидание ≈ N×50 с/валидаторов, рычаги), docs/DASHBOARD.md, docs/LOCAL_E2E.md, README.md, configs/*.example.yaml (+ копии в rxprod-compose/sources/ и docker-примеры).
  • rxprod-compose/control-api.yaml (живой конфиг) — при необходимости задать max_run_seconds автоцикла = 0 (уже 0) и openstack.list_page_size; по умолчанию достаточно дефолтов.
  • Опционально (не входит): переупорядочить Tick (сначала sweeps, потом assignIdleValidators) — экономит до 5 с на адрес (~10 %).

Тесты (минимальные, в стиле существующих)

  • internal/openstack: пагинация по marker на моке (N=2500, PageSize=200), ретрай страницы при ListFailures, отмена контекста; классификация ретраемых ошибок.
  • internal/orchestrator: задание скана — single-flight, прогресс, DryRun, ошибка чтения ⇒ очередь не изменена, куски по 500, порядок по возрастанию IP, 6440 адресов за разумное время; автоцикл: фаза scanning, шаги с явным now, ошибка скана, no_free_ips, рестарт в фазе scanning, Stop отменяет скан, Tick не блокируется во время долгого скана (мок с задержкой).
  • internal/db: ListIPsPage/ListRegistryPage (фильтры, total, LIMIT до summary), CountIPsBy*, AnyNonTerminalIP, set-based ClearQueue, тест на 6440 строк (время и отсутствие N+1).
  • internal/httpapi: 202/409-семантика скана, ?wait=true, ?dry_run=true, GET /ips/scan, конверт пагинации, обратная совместимость без limit; обновить TestScanFloatingIPsEndpoint и счётчики в auth_test.go.
  • internal/dashboard: панель прогресса и остановка опроса, пагинация/pager, фильтры /ips, scope=all, Overview на ограниченных списках при тысячах queued, hx-params; обновить fakeControlAPI и затронутые тесты (TestIPsScan и др.).
  • scripts/run-local-e2e.sh: автоцикл проходит через фазу scanning (мок-пагинация), полный сценарий остаётся зелёным за 90 с.

Верификация

  1. go build ./... && go vet ./... && go test ./... и go test -race для orchestrator, openstack, httpapi, dashboard, db.
  2. scripts/run-local-e2e.sh (с токенами) — зелёный, автоцикл проходит scanning → running → waiting.
  3. Живой стенд, безопасно (чтение): POST /admin/ips/scan?dry_run=true — скан проходит все страницы реального Neutron, прогресс идёт, итог ≈ 6440 свободных, очередь не меняется, дашборд не получает таймаута. Замер времени скана и нагрузки.
  4. Живой стенд, по согласованию: кнопка «Сканировать Floating IP» — адреса поставлены в очередь, проверки идут, /ips, /registry, «Обзор» остаются быстрыми (проверка размера ответов и времени), «Очистить всё» отрабатывает за секунды. Решение о реальной постановке 6440 адресов (≈18 ч проверок на 5 валидаторах) принимает пользователь; перед этим — бэкап БД.
  5. Автоцикл на живом стенде — только после п. 4 и с согласия пользователя; наблюдать фазу scanning, отсутствие блокировки Tick.
  6. Реальный браузер (Playwright из venv): прогресс скана, пагинация, фильтры, выбор «все N по фильтру», отсутствие JS-ошибок.