package domains import ( "context" "fmt" "strings" "sync" "time" "github.com/carlosframework/platform/internal/release" ) // ClaimCache makes the claim fold cost one bucket call in the steady state // instead of one plus a GET per claim (#173, #165). // // The problem it solves. ListClaims lists domains/ and then reads every // object under it, and three things do that repeatedly: every app Settings // render, every `carlos domains list`, and every box's ReconcileAliases pass // — the last one on a five-minute floor, ahead of the adopt leg inside a // two-second convergence promise. At tens of claims that is invisible. At // the hundreds titogo's custom domains are heading for it is a slow page and // a steady drip of bucket reads on every box in the fleet, forever. // // The fix needs no new object and no new writer, because the listing already // carries the answer: S3 returns LastModified with every ListObjectsV2 entry // and DirStore has the file mtime, both of which release.ModLister already // surfaces and List throws away. A claim whose last-modified time has not // moved has not changed, so its body does not need re-reading. The steady // state is therefore ONE ListMod and zero GETs, and the first pass after a // claim changes reads exactly the claims that changed. // // This is a cache with no TTL and no invalidation call, which is the point: // it is not guessing about staleness, it is being told. Two exact rules do // all the work — an entry is reused only when its stamp is unchanged, and an // entry whose key has left the listing is dropped, which is the detach path. // // A store that cannot answer ListMod (the fleet store door's client, every // test fake) degrades to a plain ListClaims with no caching at all. That is // deliberate: a fallback that cached on time instead would be a different, // weaker contract wearing the same name, and the one place it would be used // is the one place — a customer fleet box — where a stale claim set is // hardest to notice. // // Safe for concurrent use. The zero value is ready, and a nil *ClaimCache // is legal and means "no caching" — so a caller that has no cache to offer // passes nil rather than every call site growing a branch. type ClaimCache struct { mu sync.Mutex entries map[string]cachedClaim } type cachedClaim struct { mod time.Time claim Claim // skipped records that this key was listed but produced no claim for a // DETERMINISTIC reason — unparseable, or a schema newer than this // binary. Cached like a hit so a permanently undecodable object does not // earn a GET on every pass forever. A key whose READ failed is never // cached at all: see readClaim's firm return. skipped bool } // modSettle is how recently an object may have been written before its // stamp stops being trustworthy as an unchanged-marker. // // S3 last-modified has one-second granularity. Without this bound there is a // real lost update: read a claim at T and cache stamp T, have it rewritten // later in the same second T, and every later listing shows stamp T — which // this cache would read as "unchanged" forever. Refusing to cache anything // stamped within the last two seconds closes it, at the cost of re-reading a // just-changed claim once or twice more. Claims change on human timescales, // so that cost is approximately never paid. const modSettle = 2 * time.Second // List folds the deployment's claims, reading only the ones that changed. // // The returned slice is fresh on every call and safe for the caller to keep; // Claim is a value type with no reference fields, so cached entries cannot // be mutated through it. func (c *ClaimCache) List(ctx context.Context, s release.Store) ([]Claim, error) { claims, _, err := c.listWithHosts(ctx, s) return claims, err } // claimHostFromKey is the hostname a claim key names (ClaimKey's inverse). func claimHostFromKey(key string) string { return strings.TrimSuffix(strings.TrimPrefix(key, ClaimsPrefix), ".json") } // listWithHosts is List plus the hostname of every key the LISTING showed, // whether or not its body could be read this pass. A rule of the form "a // claim on this name, whoever holds it, wins" must be decided from the // listing: a body whose GET failed (a transient store error) is missing from // the claims, and deciding from those would let a default take the name for // that pass — failing open on exactly the blip it should ride out. func (c *ClaimCache) listWithHosts(ctx context.Context, s release.Store) ([]Claim, map[string]bool, error) { lister, ok := s.(release.ModLister) if c == nil || !ok { keys, err := s.List(ctx, ClaimsPrefix) if err != nil { return nil, nil, fmt.Errorf("domains: list claims: %w", err) } hosts := make(map[string]bool, len(keys)) var claims []Claim for _, key := range keys { hosts[claimHostFromKey(key)] = true if cl, ok, _ := readClaim(ctx, s, key); ok { claims = append(claims, cl) } } return claims, hosts, nil } listed, err := lister.ListMod(ctx, ClaimsPrefix) if err != nil { return nil, nil, err } hosts := make(map[string]bool, len(listed)) for _, km := range listed { hosts[claimHostFromKey(km.Key)] = true } now := time.Now() c.mu.Lock() defer c.mu.Unlock() next := make(map[string]cachedClaim, len(listed)) claims := make([]Claim, 0, len(listed)) for _, km := range listed { if hit, ok := c.entries[km.Key]; ok && hit.mod.Equal(km.Mod) { // Carried forward WITHOUT re-checking modSettle: the bound // applies when an entry is admitted, so anything already in the // map was settled when it went in. next[km.Key] = hit if !hit.skipped { claims = append(claims, hit.claim) } continue } claim, ok, firm := readClaim(ctx, s, km.Key) if ok { claims = append(claims, claim) } if !firm { // The read itself failed. Remembering that would hide a good // claim until something moved its stamp — and nothing edits a // settled claim, so that is indistinguishable from forever. continue } if now.Sub(km.Mod) < modSettle { // Too fresh to trust as an unchanged-marker. Used, not kept. continue } next[km.Key] = cachedClaim{mod: km.Mod, claim: claim, skipped: !ok} } // Whatever is not in next is not in the bucket: replacing the map whole // IS the eviction, so a detached claim leaves the cache on the same pass // it leaves the listing, with no separate invalidation to forget. c.entries = next return claims, hosts, nil } // Reset drops everything. Nothing in this package needs it; it exists so a // caller whose store changed underneath it (a test, a reconfiguration) can // say so explicitly rather than construct a new cache and lose the field. func (c *ClaimCache) Reset() { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() c.entries = nil }