hub v0.101.0 — memoise the artifact dropdown for 60s (R-267, operator ruling)
gates / gates (push) Successful in 28s
gates / gates (push) Successful in 28s
v0.100.x removed the serialisation: 26.2s -> ~9.85s mean. What remained was one Gitea package SEARCH per dropdown at 0.20-3.8s depending on load, which concurrency cannot help. Memoised for 60s IN MEMORY. The TTL was ruled by the operator against the workflow that cares: a bake-and-vouch session publishes an artifact and comes straight here to select it, so a minute is short enough not to be noticed and long enough that every reload in that session is instant. NOT persisted. Gitea IS the store for both the version list and the sha; a copy in hub_settings would be a second source of truth that can drift from the registry it describes, and the operator reads the sha here to confirm what they are about to vouch. An in-memory cache dies with the process and can never be mistaken for a record. A failed resolve is NOT cached — a blip must not pin an empty dropdown for a minute. But an empty list from a package that genuinely has no versions IS cached, because 'we found nothing' and 'we could not look' are different answers (CONTEXT S-39, applied to a list instead of a figure). THE FIRST VERSION OF THIS GOT THAT WRONG: the comment said only successful resolves were cached and the code cached the empty list anyway. TestArtifactChoices_FailureIsNotCached caught it before it shipped — which is the argument for writing the test that asserts the comment, and the same class this session spent the day closing. go build/vet/test green, go test -race clean, run separately from this commit.
This commit is contained in:
@@ -88,6 +88,11 @@ type Server struct {
|
||||
|
||||
sessions map[string]*hubSession
|
||||
sessionsMu sync.RWMutex
|
||||
|
||||
// artifactCache (R-267) memoises the Day-0 dropdown contents for artifactChoicesTTL. In memory
|
||||
// only — see the constant's comment for why this must never become a database row.
|
||||
artifactCache map[string]artifactChoiceCacheEntry
|
||||
artifactCacheMu sync.Mutex
|
||||
}
|
||||
|
||||
// New creates a new web server.
|
||||
@@ -223,10 +228,37 @@ func (s *Server) SetGiteaClient(c *gitea.Client) {
|
||||
// from Gitea, newest first, for the Day-0 artifact dropdown. Returns nil (→ manual text-entry
|
||||
// fallback) when no Gitea client is configured or Gitea is unreachable. A failed sha lookup for a
|
||||
// single version drops just that version, not the whole list.
|
||||
// artifactChoicesTTL bounds how stale the dropdown may be (R-267, operator ruling 2026-08-08).
|
||||
//
|
||||
// 60 s, chosen deliberately against the one workflow that cares: a bake-and-vouch session publishes
|
||||
// an artifact and then goes straight to this page to select it. A minute is short enough that the
|
||||
// operator does not notice waiting for it, and long enough that the page is instant for every reload
|
||||
// during that session. A longer TTL was rejected for exactly that case — it would hide a
|
||||
// just-published golden at the moment someone is looking for it.
|
||||
//
|
||||
// NOT persisted, and that is the point. Gitea IS the store for both the version list and the sha;
|
||||
// a copy in the hub's database would be a second source of truth that can drift from the registry
|
||||
// it describes, and the operator reads the sha here to confirm what they are about to vouch. An
|
||||
// in-memory cache dies with the process and can never be mistaken for a record.
|
||||
const artifactChoicesTTL = 60 * time.Second
|
||||
|
||||
type artifactChoiceCacheEntry struct {
|
||||
choices []artifactChoice
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifactChoice {
|
||||
if s.gitea == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.artifactCacheMu.Lock()
|
||||
if e, ok := s.artifactCache[pkg]; ok && time.Since(e.at) < artifactChoicesTTL {
|
||||
s.artifactCacheMu.Unlock()
|
||||
return e.choices
|
||||
}
|
||||
s.artifactCacheMu.Unlock()
|
||||
|
||||
vers, err := s.gitea.ListVersions(ctx, pkg)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] artifact versions (%s): %v", pkg, err)
|
||||
@@ -277,6 +309,25 @@ func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifa
|
||||
}
|
||||
out = append(out, artifactChoice{Version: v, SHA256: shas[i]})
|
||||
}
|
||||
|
||||
// Only a SUCCESSFUL resolve is cached. A Gitea blip must not pin an empty list in front of the
|
||||
// operator for a minute — the earlier returns leave the cache untouched, and so does this one
|
||||
// when every version's sha lookup failed.
|
||||
//
|
||||
// The `len(vers) > 0` half matters: a package that genuinely has no versions yet resolves to an
|
||||
// empty list legitimately, and caching THAT is correct. Distinguishing the two is the whole
|
||||
// point — "we found nothing" and "we could not look" are not the same answer, which is the rule
|
||||
// CONTEXT S-39 states for figures and applies just as well here.
|
||||
if len(out) == 0 && len(vers) > 0 {
|
||||
s.logger.Printf("[WARN] artifact choices (%s): every version's sha lookup failed — not caching", pkg)
|
||||
return out
|
||||
}
|
||||
s.artifactCacheMu.Lock()
|
||||
if s.artifactCache == nil {
|
||||
s.artifactCache = map[string]artifactChoiceCacheEntry{}
|
||||
}
|
||||
s.artifactCache[pkg] = artifactChoiceCacheEntry{choices: out, at: time.Now()}
|
||||
s.artifactCacheMu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user