hub v0.100.0 — the Configuration page took 26 seconds, and it was never hashing anything
gates / gates (push) Successful in 21s
gates / gates (push) Successful in 21s
MEASURED, NOT GUESSED: GET /configuration -> HTTP 200 in 26.2s. The reasonable guess was that it hashes the artifacts on page load. It does not, and the code already said so: Gitea stores each package file's sha256 and gitea.FileSHA256 reads it as metadata — "a cheap metadata call, the artifact bytes are never downloaded". The cost was never CPU. IT WAS LATENCY x COUNT. artifactChoices made ONE SERIAL round-trip per version, for two packages, capped at 20 each: 2 x (1 version list + 20 sha lookups) = 42 sequential requests at ~0.6s each out through the public ingress. 42 x 0.6 = 26s, which is what the clock said. 1. The sha lookups now run CONCURRENTLY, bounded at 8 in flight. Order preserved by writing into a slot rather than appending — the dropdown is newest-first, and a scrambled sha would show the operator a hash belonging to a DIFFERENT artifact. A failed lookup still drops that version only. 2. The client talks to Gitea IN-CLUSTER (http://gitea.gitea-system.svc.cluster.local:3000, overridable via GITEA_API_URL). Measured from the hub pod: 0.11s against 0.26-1.16s, because the public path adds DNS, the ingress hop and a TLS handshake to each of the 42. Plain HTTP is safe ONLY because it never leaves the cluster network — the registry token rides the Authorization header, so this must not point at a public host without TLS. Unreachable -> the existing graceful degradation to manual text entry, unchanged. DELIBERATELY NOT DONE: caching the sha in the hub's own database. That was the other half of the proposal and it is the wrong shape. Gitea already IS the store; a copy in hub_settings would be a second source of truth that can drift from the registry it describes — and the operator reads exactly this value to confirm what they are about to vouch, so a stale one would be a confident wrong answer. The same reasoning golden_currency_gate.py already records for the vouched version. With the fan-out, a cold load needs no cache to be fast. The cap stays at 20 and now bounds the FAN-OUT too, not just the rendered list. Tests pin order (and that each sha belongs to its own version), per-version failure isolation, and THE CONCURRENCY ITSELF — a wall-clock assertion plus an in-flight counter, so a fast run cannot be luck, and an upper bound so a large package list cannot stampede Gitea. Red-proof: reverting to the serial loop takes 861ms where the concurrent one takes 150ms, and the test fails naming the 26-second page. go build / go vet / go test ./... green (18 packages), run separately from this commit.
This commit is contained in:
+66
-34
@@ -20,8 +20,8 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/poke"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/poke"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -51,28 +51,28 @@ type hubSession struct {
|
||||
|
||||
// Server handles the dashboard web UI.
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
store *store.Store
|
||||
// configPasswordHash is the operator login password bcrypt hash SEEDED from hub.yaml
|
||||
// (auth.password_hash) at startup. It is the fallback only — a hub_settings DB override set via
|
||||
// the Configuration UI wins. Never read this field directly for an auth decision; call
|
||||
// effectivePasswordHash().
|
||||
configPasswordHash string
|
||||
apiKey string // report API key — used for controller callbacks
|
||||
version string
|
||||
logger *log.Logger
|
||||
templates *template.Template
|
||||
staleThreshold time.Duration
|
||||
versionChecker *VersionChecker
|
||||
templateFetcher *TemplateFetcher
|
||||
assetsMgr *assets.Manager
|
||||
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
|
||||
offsite *offsite.Provisioner // optional; enables Hetzner offsite provisioning (SLICE 1)
|
||||
offsiteBox func() (monitor.BoxSnapshot, bool) // optional (v0.64.0, R-5); the restic pool-box aggregate snapshot accessor
|
||||
pbsdrBox func() (monitor.PBSBoxSnapshot, bool) // optional (v0.65.0, R-5); the PBS-DR datastore fill snapshot accessor
|
||||
tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go)
|
||||
claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0)
|
||||
selfBindMailer SelfBindMailer // optional; enables the customer self-bind link button (v0.66.0, R-27)
|
||||
bindLimiter *bindRateLimiter // per-IP throttle for the PUBLIC /bind/ surface (v0.66.0, R-27)
|
||||
version string
|
||||
logger *log.Logger
|
||||
templates *template.Template
|
||||
staleThreshold time.Duration
|
||||
versionChecker *VersionChecker
|
||||
templateFetcher *TemplateFetcher
|
||||
assetsMgr *assets.Manager
|
||||
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
|
||||
offsite *offsite.Provisioner // optional; enables Hetzner offsite provisioning (SLICE 1)
|
||||
offsiteBox func() (monitor.BoxSnapshot, bool) // optional (v0.64.0, R-5); the restic pool-box aggregate snapshot accessor
|
||||
pbsdrBox func() (monitor.PBSBoxSnapshot, bool) // optional (v0.65.0, R-5); the PBS-DR datastore fill snapshot accessor
|
||||
tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go)
|
||||
claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0)
|
||||
selfBindMailer SelfBindMailer // optional; enables the customer self-bind link button (v0.66.0, R-27)
|
||||
bindLimiter *bindRateLimiter // per-IP throttle for the PUBLIC /bind/ surface (v0.66.0, R-27)
|
||||
// intentHub (v0.58.0, Direction-2 immediate-sync) is Bumped by every operator-intent handler
|
||||
// (config save/delete, claim resend, offsite re-issue/freeze, floor, block/unblock, log pull)
|
||||
// so a box long-polling GET /api/v1/wait wakes in seconds. Shared with the API handler. nil =
|
||||
@@ -126,12 +126,12 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
|
||||
store: store,
|
||||
configPasswordHash: passwordHash,
|
||||
apiKey: apiKey,
|
||||
version: version,
|
||||
logger: logger,
|
||||
templates: tmpl,
|
||||
staleThreshold: staleThreshold,
|
||||
sessions: make(map[string]*hubSession),
|
||||
bindLimiter: newBindRateLimiter(30), // public /bind/ surface: 30 req/min/IP burst (R-27)
|
||||
version: version,
|
||||
logger: logger,
|
||||
templates: tmpl,
|
||||
staleThreshold: staleThreshold,
|
||||
sessions: make(map[string]*hubSession),
|
||||
bindLimiter: newBindRateLimiter(30), // public /bind/ surface: 30 req/min/IP burst (R-27)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,14 +237,45 @@ func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifa
|
||||
s.logger.Printf("[INFO] artifact versions (%s): showing newest %d of %d", pkg, maxChoices, len(vers))
|
||||
vers = vers[:maxChoices]
|
||||
}
|
||||
// CONCURRENTLY (v0.100.0). Each sha is one independent metadata round-trip, and doing them in
|
||||
// series made /configuration take 26 SECONDS on a page with two dropdowns: 2 packages x (1
|
||||
// version list + 20 sha lookups) = 42 sequential requests at ~0.6 s each.
|
||||
//
|
||||
// ⚠ NOTHING IS BEING HASHED HERE, and the operator's reasonable guess that it was is worth
|
||||
// recording so nobody re-derives it: Gitea stores the sha256 with the package file and
|
||||
// FileSHA256 reads it as metadata — the artifact bytes are never downloaded (see its comment).
|
||||
// The cost was never CPU; it was latency x count, and the fix is therefore concurrency, not a
|
||||
// cached or precomputed hash. A hash copied into the hub's own DB would be a SECOND SOURCE OF
|
||||
// TRUTH that can drift from the registry it describes, and the operator reads this value to
|
||||
// confirm what they are about to vouch — a stale one would be a confident wrong answer.
|
||||
//
|
||||
// Bounded at 8 in flight: enough to collapse the wall-clock, few enough not to stampede Gitea
|
||||
// on a page an operator may reload. Order is preserved by writing into a slot, not appending —
|
||||
// the dropdown is newest-first and must stay that way.
|
||||
const parallel = 8
|
||||
shas := make([]string, len(vers))
|
||||
errs := make([]error, len(vers))
|
||||
sem := make(chan struct{}, parallel)
|
||||
var wg sync.WaitGroup
|
||||
for i, v := range vers {
|
||||
wg.Add(1)
|
||||
go func(i int, v string) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
shas[i], errs[i] = s.gitea.FileSHA256(ctx, pkg, v, file)
|
||||
}(i, v)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
out := make([]artifactChoice, 0, len(vers))
|
||||
for _, v := range vers {
|
||||
sha, err := s.gitea.FileSHA256(ctx, pkg, v, file)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] artifact sha (%s/%s): %v", pkg, v, err)
|
||||
for i, v := range vers {
|
||||
if errs[i] != nil {
|
||||
// unchanged behaviour: a failed lookup drops THAT version, never the whole list
|
||||
s.logger.Printf("[WARN] artifact sha (%s/%s): %v", pkg, v, errs[i])
|
||||
continue
|
||||
}
|
||||
out = append(out, artifactChoice{Version: v, SHA256: sha})
|
||||
out = append(out, artifactChoice{Version: v, SHA256: shas[i]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -837,12 +868,13 @@ func timeAgo(t time.Time) string {
|
||||
// statusColor maps a status value to a design-system-v2 semantic token, consumed as a
|
||||
// class suffix (status-dot-nominal, tag-nominal, …) — never as an inline color (D4).
|
||||
// Exception-color principle: a healthy fleet is blue/neutral; amber/red only on deviation.
|
||||
// ok -> nominal (blue — operating normally)
|
||||
// warn, stale -> warn (amber — degraded / stale report)
|
||||
// down, fail -> crit (red — outage)
|
||||
// pending -> neutral (a not-yet-provisioned customer is a normal fleet state)
|
||||
// disabled -> neutral (deliberately paused — not a deviation)
|
||||
// blocked -> warn (an operator cut a customer off: intentional but attention-worthy)
|
||||
//
|
||||
// ok -> nominal (blue — operating normally)
|
||||
// warn, stale -> warn (amber — degraded / stale report)
|
||||
// down, fail -> crit (red — outage)
|
||||
// pending -> neutral (a not-yet-provisioned customer is a normal fleet state)
|
||||
// disabled -> neutral (deliberately paused — not a deviation)
|
||||
// blocked -> warn (an operator cut a customer off: intentional but attention-worthy)
|
||||
func statusColor(status string) string {
|
||||
switch status {
|
||||
case "ok":
|
||||
|
||||
Reference in New Issue
Block a user