Files
cloud-ip-validator/internal/openstack/paginate.go
T
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

149 lines
4.0 KiB
Go

package openstack
import (
"context"
"errors"
"io"
"net"
"strings"
"time"
)
// DefaultListPageSize is the page size used when the caller passes a
// non-positive one.
const DefaultListPageSize = 200
// DefaultListPageRetries is how many times a single failed page request is
// retried (so up to DefaultListPageRetries+1 attempts in total).
const DefaultListPageRetries = 5
// pageFetcher fetches one page of floating IPs: at most limit entries that
// follow the entry with ID marker ("" = from the start), in a stable server
// order. A page shorter than limit is the last one.
type pageFetcher func(ctx context.Context, marker string, limit int) ([]FloatingIP, error)
// pageRetry configures per-page retries. The zero value retries nothing.
type pageRetry struct {
// Retries is the number of retries after the first failed attempt.
Retries int
// Sleep waits for d or until ctx is done; nil uses a real timer. Tests
// inject a fake to avoid real backoff delays.
Sleep func(ctx context.Context, d time.Duration) error
}
// retryBackoff is the delay before retry number attempt (1-based):
// 1s, 2s, 4s, 8s, 16s, then capped at 30s.
func retryBackoff(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
d := time.Second << uint(attempt-1)
if d > 30*time.Second || d <= 0 {
d = 30 * time.Second
}
return d
}
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
// statusCoder is implemented by gophercloud.ErrUnexpectedResponseCode (and
// anything wrapping it): it carries the HTTP status of a failed request.
type statusCoder interface{ GetStatusCode() int }
// IsRetryableListError reports whether a failed page request is worth
// repeating: transport-level failures (timeouts, resets, EOF /
// RemoteDisconnected), HTTP 429 and HTTP 5xx. Any other 4xx, context
// cancellation and unknown errors are not retried.
func IsRetryableListError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) {
return false
}
var sc statusCoder
if errors.As(err, &sc) {
code := sc.GetStatusCode()
return code == 429 || code >= 500
}
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
// A deadline is retryable only when it is a per-request network timeout
// (net.Error), which is checked below; the caller's own ctx deadline is
// filtered out by the paginator via ctx.Err().
var ne net.Error
if errors.As(err, &ne) {
return true
}
msg := strings.ToLower(err.Error())
for _, s := range []string{
"remotedisconnected", "remote end closed connection",
"connection reset", "connection refused", "broken pipe",
"unexpected eof", "eof", "timeout", "tls handshake",
} {
if strings.Contains(msg, s) {
return true
}
}
return false
}
// paginate walks all pages with marker-based pagination, retrying each failed
// page per rp, and hands every non-empty page to onPage in server order. It
// returns the number of non-empty pages delivered. An error from onPage, a
// non-retryable error, exhausted retries or ctx cancellation stop the walk.
func paginate(ctx context.Context, pageSize int, rp pageRetry, fetch pageFetcher, onPage func([]FloatingIP) error) (int, error) {
if pageSize <= 0 {
pageSize = DefaultListPageSize
}
sleep := rp.Sleep
if sleep == nil {
sleep = sleepCtx
}
marker := ""
pages := 0
for {
if err := ctx.Err(); err != nil {
return pages, err
}
var page []FloatingIP
var err error
for attempt := 0; ; attempt++ {
page, err = fetch(ctx, marker, pageSize)
if err == nil {
break
}
if ctx.Err() != nil {
return pages, ctx.Err()
}
if attempt >= rp.Retries || !IsRetryableListError(err) {
return pages, err
}
if serr := sleep(ctx, retryBackoff(attempt+1)); serr != nil {
return pages, serr
}
}
if len(page) == 0 {
return pages, nil
}
pages++
if err := onPage(page); err != nil {
return pages, err
}
if len(page) < pageSize {
return pages, nil
}
marker = page[len(page)-1].ID
}
}