carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package domains
2
3import (
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.
47type ClaimCache struct {
48 mu sync.Mutex
49 entries map[string]cachedClaim
50}
51
52type 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.
73const 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.
80func (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).
86func 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.
96func (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.
163func (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