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