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>
149 lines
4.0 KiB
Go
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
|
|
}
|
|
}
|