| 1 | package domains |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | "sync" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/release" |
| 11 | ) |
| 12 | |
| 13 | // ClaimCache makes the claim fold cost one bucket call in the steady state |
| 14 | // instead of one plus a GET per claim (#173, #165). |
| 15 | // |
| 16 | // The problem it solves. ListClaims lists domains/ and then reads every |
| 17 | // object under it, and three things do that repeatedly: every app Settings |
| 18 | // render, every `carlos domains list`, and every box's ReconcileAliases pass |
| 19 | // — the last one on a five-minute floor, ahead of the adopt leg inside a |
| 20 | // two-second convergence promise. At tens of claims that is invisible. At |
| 21 | // the hundreds titogo's custom domains are heading for it is a slow page and |
| 22 | // a steady drip of bucket reads on every box in the fleet, forever. |
| 23 | // |
| 24 | // The fix needs no new object and no new writer, because the listing already |
| 25 | // carries the answer: S3 returns LastModified with every ListObjectsV2 entry |
| 26 | // and DirStore has the file mtime, both of which release.ModLister already |
| 27 | // surfaces and List throws away. A claim whose last-modified time has not |
| 28 | // moved has not changed, so its body does not need re-reading. The steady |
| 29 | // state is therefore ONE ListMod and zero GETs, and the first pass after a |
| 30 | // claim changes reads exactly the claims that changed. |
| 31 | // |
| 32 | // This is a cache with no TTL and no invalidation call, which is the point: |
| 33 | // it is not guessing about staleness, it is being told. Two exact rules do |
| 34 | // all the work — an entry is reused only when its stamp is unchanged, and an |
| 35 | // entry whose key has left the listing is dropped, which is the detach path. |
| 36 | // |
| 37 | // A store that cannot answer ListMod (the fleet store door's client, every |
| 38 | // test fake) degrades to a plain ListClaims with no caching at all. That is |
| 39 | // deliberate: a fallback that cached on time instead would be a different, |
| 40 | // weaker contract wearing the same name, and the one place it would be used |
| 41 | // is the one place — a customer fleet box — where a stale claim set is |
| 42 | // hardest to notice. |
| 43 | // |
| 44 | // Safe for concurrent use. The zero value is ready, and a nil *ClaimCache |
| 45 | // is legal and means "no caching" — so a caller that has no cache to offer |
| 46 | // passes nil rather than every call site growing a branch. |
| 47 | type ClaimCache struct { |
| 48 | mu sync.Mutex |
| 49 | entries map[string]cachedClaim |
| 50 | } |
| 51 | |
| 52 | type cachedClaim struct { |
| 53 | mod time.Time |
| 54 | claim Claim |
| 55 | // skipped records that this key was listed but produced no claim for a |
| 56 | // DETERMINISTIC reason — unparseable, or a schema newer than this |
| 57 | // binary. Cached like a hit so a permanently undecodable object does not |
| 58 | // earn a GET on every pass forever. A key whose READ failed is never |
| 59 | // cached at all: see readClaim's firm return. |
| 60 | skipped bool |
| 61 | } |
| 62 | |
| 63 | // modSettle is how recently an object may have been written before its |
| 64 | // stamp stops being trustworthy as an unchanged-marker. |
| 65 | // |
| 66 | // S3 last-modified has one-second granularity. Without this bound there is a |
| 67 | // real lost update: read a claim at T and cache stamp T, have it rewritten |
| 68 | // later in the same second T, and every later listing shows stamp T — which |
| 69 | // this cache would read as "unchanged" forever. Refusing to cache anything |
| 70 | // stamped within the last two seconds closes it, at the cost of re-reading a |
| 71 | // just-changed claim once or twice more. Claims change on human timescales, |
| 72 | // so that cost is approximately never paid. |
| 73 | const modSettle = 2 * time.Second |
| 74 | |
| 75 | // List folds the deployment's claims, reading only the ones that changed. |
| 76 | // |
| 77 | // The returned slice is fresh on every call and safe for the caller to keep; |
| 78 | // Claim is a value type with no reference fields, so cached entries cannot |
| 79 | // be mutated through it. |
| 80 | func (c *ClaimCache) List(ctx context.Context, s release.Store) ([]Claim, error) { |
| 81 | claims, _, err := c.listWithHosts(ctx, s) |
| 82 | return claims, err |
| 83 | } |
| 84 | |
| 85 | // claimHostFromKey is the hostname a claim key names (ClaimKey's inverse). |
| 86 | func claimHostFromKey(key string) string { |
| 87 | return strings.TrimSuffix(strings.TrimPrefix(key, ClaimsPrefix), ".json") |
| 88 | } |
| 89 | |
| 90 | // listWithHosts is List plus the hostname of every key the LISTING showed, |
| 91 | // whether or not its body could be read this pass. A rule of the form "a |
| 92 | // claim on this name, whoever holds it, wins" must be decided from the |
| 93 | // listing: a body whose GET failed (a transient store error) is missing from |
| 94 | // the claims, and deciding from those would let a default take the name for |
| 95 | // that pass — failing open on exactly the blip it should ride out. |
| 96 | func (c *ClaimCache) listWithHosts(ctx context.Context, s release.Store) ([]Claim, map[string]bool, error) { |
| 97 | lister, ok := s.(release.ModLister) |
| 98 | if c == nil || !ok { |
| 99 | keys, err := s.List(ctx, ClaimsPrefix) |
| 100 | if err != nil { |
| 101 | return nil, nil, fmt.Errorf("domains: list claims: %w", err) |
| 102 | } |
| 103 | hosts := make(map[string]bool, len(keys)) |
| 104 | var claims []Claim |
| 105 | for _, key := range keys { |
| 106 | hosts[claimHostFromKey(key)] = true |
| 107 | if cl, ok, _ := readClaim(ctx, s, key); ok { |
| 108 | claims = append(claims, cl) |
| 109 | } |
| 110 | } |
| 111 | return claims, hosts, nil |
| 112 | } |
| 113 | listed, err := lister.ListMod(ctx, ClaimsPrefix) |
| 114 | if err != nil { |
| 115 | return nil, nil, err |
| 116 | } |
| 117 | hosts := make(map[string]bool, len(listed)) |
| 118 | for _, km := range listed { |
| 119 | hosts[claimHostFromKey(km.Key)] = true |
| 120 | } |
| 121 | now := time.Now() |
| 122 | c.mu.Lock() |
| 123 | defer c.mu.Unlock() |
| 124 | next := make(map[string]cachedClaim, len(listed)) |
| 125 | claims := make([]Claim, 0, len(listed)) |
| 126 | for _, km := range listed { |
| 127 | if hit, ok := c.entries[km.Key]; ok && hit.mod.Equal(km.Mod) { |
| 128 | // Carried forward WITHOUT re-checking modSettle: the bound |
| 129 | // applies when an entry is admitted, so anything already in the |
| 130 | // map was settled when it went in. |
| 131 | next[km.Key] = hit |
| 132 | if !hit.skipped { |
| 133 | claims = append(claims, hit.claim) |
| 134 | } |
| 135 | continue |
| 136 | } |
| 137 | claim, ok, firm := readClaim(ctx, s, km.Key) |
| 138 | if ok { |
| 139 | claims = append(claims, claim) |
| 140 | } |
| 141 | if !firm { |
| 142 | // The read itself failed. Remembering that would hide a good |
| 143 | // claim until something moved its stamp — and nothing edits a |
| 144 | // settled claim, so that is indistinguishable from forever. |
| 145 | continue |
| 146 | } |
| 147 | if now.Sub(km.Mod) < modSettle { |
| 148 | // Too fresh to trust as an unchanged-marker. Used, not kept. |
| 149 | continue |
| 150 | } |
| 151 | next[km.Key] = cachedClaim{mod: km.Mod, claim: claim, skipped: !ok} |
| 152 | } |
| 153 | // Whatever is not in next is not in the bucket: replacing the map whole |
| 154 | // IS the eviction, so a detached claim leaves the cache on the same pass |
| 155 | // it leaves the listing, with no separate invalidation to forget. |
| 156 | c.entries = next |
| 157 | return claims, hosts, nil |
| 158 | } |
| 159 | |
| 160 | // Reset drops everything. Nothing in this package needs it; it exists so a |
| 161 | // caller whose store changed underneath it (a test, a reconfiguration) can |
| 162 | // say so explicitly rather than construct a new cache and lose the field. |
| 163 | func (c *ClaimCache) Reset() { |
| 164 | if c == nil { |
| 165 | return |
| 166 | } |
| 167 | c.mu.Lock() |
| 168 | defer c.mu.Unlock() |
| 169 | c.entries = nil |
| 170 | } |
| 171 | |