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 "errors"
6 "os"
7 "reflect"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/platform/internal/release"
12)
13
14// countingStore counts the GETs the fold spends. That count IS the property
15// under test — the whole point of the cache is that a claim whose bucket
16// stamp has not moved costs nothing to fold again.
17type countingStore struct {
18 release.Store
19 gets int
20}
21
22func (c *countingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
23 c.gets++
24 return c.Store.Get(ctx, key)
25}
26
27func (c *countingStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) {
28 return c.Store.(release.ModLister).ListMod(ctx, prefix)
29}
30
31func seedClaim(t *testing.T, s release.Store, host, app string) {
32 t.Helper()
33 if _, _, err := AttachClaim(context.Background(), s, Claim{Host: host, Account: "acct", App: app}); err != nil {
34 t.Fatal(err)
35 }
36}
37
38// backdate moves a claim's bucket stamp out of the modSettle window, which
39// is what a claim written more than a couple of seconds ago looks like. Real
40// claims change on human timescales; a test cannot afford to sleep for them.
41func backdate(t *testing.T, dir, host string) {
42 t.Helper()
43 old := time.Now().Add(-time.Hour)
44 if err := os.Chtimes(dir+"/"+ClaimKey(host), old, old); err != nil {
45 t.Fatal(err)
46 }
47}
48
49func TestClaimCacheReReadsNothingWhenNothingChanged(t *testing.T) {
50 dir := t.TempDir()
51 s := &countingStore{Store: release.NewDirStore(dir)}
52 ctx := context.Background()
53 for _, h := range []string{"a.customer.test", "b.customer.test", "c.customer.test"} {
54 seedClaim(t, s, h, "kass")
55 backdate(t, dir, h)
56 }
57 s.gets = 0
58
59 var c ClaimCache
60 first, err := c.List(ctx, s)
61 if err != nil {
62 t.Fatal(err)
63 }
64 if len(first) != 3 {
65 t.Fatalf("first fold = %+v, want 3 claims", first)
66 }
67 cold := s.gets
68 if cold != 3 {
69 t.Fatalf("cold fold spent %d gets, want one per claim", cold)
70 }
71
72 s.gets = 0
73 second, err := c.List(ctx, s)
74 if err != nil {
75 t.Fatal(err)
76 }
77 if len(second) != 3 {
78 t.Fatalf("warm fold = %+v, want the same 3 claims", second)
79 }
80 if s.gets != 0 {
81 t.Errorf("warm fold spent %d gets, want 0 — an unmoved stamp must cost nothing", s.gets)
82 }
83}
84
85// The cache must be invisible: what it returns has to be what the uncached
86// fold returns, claim for claim, or a hostname appears on one read path and
87// vanishes on another.
88func TestClaimCacheAgreesWithTheUncachedFold(t *testing.T) {
89 dir := t.TempDir()
90 s := release.NewDirStore(dir)
91 ctx := context.Background()
92 for _, h := range []string{"a.customer.test", "b.customer.test"} {
93 seedClaim(t, s, h, "kass")
94 backdate(t, dir, h)
95 }
96 var c ClaimCache
97 if _, err := c.List(ctx, s); err != nil { // prime
98 t.Fatal(err)
99 }
100 cached, err := c.List(ctx, s)
101 if err != nil {
102 t.Fatal(err)
103 }
104 plain, err := ListClaims(ctx, s)
105 if err != nil {
106 t.Fatal(err)
107 }
108 if len(cached) != len(plain) {
109 t.Fatalf("cached fold has %d claims, uncached has %d", len(cached), len(plain))
110 }
111 byHost := map[string]Claim{}
112 for _, p := range plain {
113 byHost[p.Host] = p
114 }
115 for _, got := range cached {
116 want, ok := byHost[got.Host]
117 if !ok {
118 t.Errorf("cached fold invented %s", got.Host)
119 continue
120 }
121 if !reflect.DeepEqual(got, want) {
122 t.Errorf("%s: cached %+v, uncached %+v", got.Host, got, want)
123 }
124 }
125}
126
127// A detached claim leaves the listing, and leaving the listing IS the
128// eviction — there is no separate invalidation call to forget to make.
129func TestClaimCacheDropsADetachedClaim(t *testing.T) {
130 dir := t.TempDir()
131 s := release.NewDirStore(dir)
132 ctx := context.Background()
133 seedClaim(t, s, "a.customer.test", "kass")
134 seedClaim(t, s, "b.customer.test", "kass")
135 backdate(t, dir, "a.customer.test")
136 backdate(t, dir, "b.customer.test")
137
138 var c ClaimCache
139 if got, err := c.List(ctx, s); err != nil || len(got) != 2 {
140 t.Fatalf("first fold = %+v, %v", got, err)
141 }
142 if _, err := DetachClaim(ctx, s, "b.customer.test", "acct", "kass"); err != nil {
143 t.Fatal(err)
144 }
145 got, err := c.List(ctx, s)
146 if err != nil {
147 t.Fatal(err)
148 }
149 if len(got) != 1 || got[0].Host != "a.customer.test" {
150 t.Fatalf("after detach = %+v, want only a.customer.test", got)
151 }
152}
153
154// A changed claim is re-read, and only it. This is the case a cache gets
155// wrong: serving a stale body because the listing said the key is still
156// there.
157func TestClaimCacheReReadsOnlyWhatChanged(t *testing.T) {
158 dir := t.TempDir()
159 s := &countingStore{Store: release.NewDirStore(dir)}
160 ctx := context.Background()
161 seedClaim(t, s, "a.customer.test", "kass")
162 seedClaim(t, s, "b.customer.test", "kass")
163 backdate(t, dir, "a.customer.test")
164 backdate(t, dir, "b.customer.test")
165
166 var c ClaimCache
167 if _, err := c.List(ctx, s); err != nil {
168 t.Fatal(err)
169 }
170 if _, err := SetClaimCapabilities(ctx, s, "b.customer.test", "acct", "kass", true, false); err != nil {
171 t.Fatal(err)
172 }
173 backdate(t, dir, "b.customer.test") // settled, but at a DIFFERENT stamp
174 s.gets = 0
175 got, err := c.List(ctx, s)
176 if err != nil {
177 t.Fatal(err)
178 }
179 if s.gets != 1 {
180 t.Errorf("spent %d gets, want exactly 1 — only the changed claim", s.gets)
181 }
182 for _, cl := range got {
183 if cl.Host == "b.customer.test" && !cl.Wildcard {
184 t.Error("the changed claim came back stale — the cache served a body the bucket no longer holds")
185 }
186 }
187}
188
189// THE lost update the modSettle bound exists for. S3 stamps have one-second
190// granularity, so a claim read and then rewritten inside the same second
191// carries an unchanged-looking stamp. Caching that read would serve the old
192// body forever, and "forever" is not an exaggeration: nothing else would
193// ever move the stamp.
194func TestClaimCacheRefusesToTrustAStampItJustSaw(t *testing.T) {
195 dir := t.TempDir()
196 s := &countingStore{Store: release.NewDirStore(dir)}
197 ctx := context.Background()
198 seedClaim(t, s, "a.customer.test", "kass") // written just now: inside modSettle
199
200 var c ClaimCache
201 if _, err := c.List(ctx, s); err != nil {
202 t.Fatal(err)
203 }
204 s.gets = 0
205 if _, err := c.List(ctx, s); err != nil {
206 t.Fatal(err)
207 }
208 if s.gets != 1 {
209 t.Errorf("spent %d gets, want 1 — a stamp from the last two seconds must not be trusted as unchanged", s.gets)
210 }
211}
212
213// A store that cannot answer ListMod — the fleet store door's client, every
214// test fake — degrades to the plain fold. Not to a weaker cache: a customer
215// fleet box is the hardest place to notice a stale claim set.
216func TestClaimCacheDegradesOnAStoreWithNoStamps(t *testing.T) {
217 s := stamplessStore{release.NewDirStore(t.TempDir())}
218 ctx := context.Background()
219 seedClaim(t, s, "a.customer.test", "kass")
220 var c ClaimCache
221 got, err := c.List(ctx, s)
222 if err != nil {
223 t.Fatal(err)
224 }
225 if len(got) != 1 {
226 t.Fatalf("fold = %+v, want the claim", got)
227 }
228 // Nothing was cached, so a second fold still sees a change made between
229 // the two — which is the whole point of degrading rather than guessing.
230 seedClaim(t, s, "b.customer.test", "kass")
231 if got, err := c.List(ctx, s); err != nil || len(got) != 2 {
232 t.Fatalf("second fold = %+v, %v; want both claims", got, err)
233 }
234}
235
236// stamplessStore hides DirStore's ListMod behind a plain Store, the way the
237// fleet door's client and the package's test fakes do.
238type stamplessStore struct{ inner release.Store }
239
240func (s stamplessStore) Get(ctx context.Context, key string) ([]byte, string, error) {
241 return s.inner.Get(ctx, key)
242}
243func (s stamplessStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
244 return s.inner.Put(ctx, key, body, ifMatch)
245}
246func (s stamplessStore) List(ctx context.Context, prefix string) ([]string, error) {
247 return s.inner.List(ctx, prefix)
248}
249func (s stamplessStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
250 return s.inner.ListDirs(ctx, prefix)
251}
252func (s stamplessStore) Delete(ctx context.Context, key string) error {
253 return s.inner.Delete(ctx, key)
254}
255
256// A nil cache is legal and means "no caching", so a caller with none to
257// offer passes nil rather than every call site growing a branch.
258func TestNilClaimCacheFoldsWithoutCaching(t *testing.T) {
259 s := release.NewDirStore(t.TempDir())
260 ctx := context.Background()
261 seedClaim(t, s, "a.customer.test", "kass")
262 var c *ClaimCache
263 got, err := c.List(ctx, s)
264 if err != nil || len(got) != 1 {
265 t.Fatalf("nil cache fold = %+v, %v", got, err)
266 }
267 c.Reset() // must not panic
268}
269
270// A transient read failure must not be remembered. Remembering it would
271// hide a perfectly good claim until something moved its bucket stamp — and
272// nothing edits a settled claim, so for that hostname "until" means never.
273// A decode refusal IS remembered, because the same bytes refuse the same way
274// every pass and re-reading them forever buys nothing.
275func TestClaimCacheNeverRemembersATransientReadFailure(t *testing.T) {
276 dir := t.TempDir()
277 inner := release.NewDirStore(dir)
278 s := &flakyStore{Store: inner}
279 ctx := context.Background()
280 seedClaim(t, inner, "a.customer.test", "kass")
281 backdate(t, dir, "a.customer.test")
282
283 var c ClaimCache
284 s.failNext = true
285 got, err := c.List(ctx, s)
286 if err != nil {
287 t.Fatal(err)
288 }
289 if len(got) != 0 {
290 t.Fatalf("fold during the blip = %+v, want nothing readable", got)
291 }
292 // The blip is over. The claim must come back on the very next fold,
293 // with no stamp change to prompt it.
294 got, err = c.List(ctx, s)
295 if err != nil {
296 t.Fatal(err)
297 }
298 if len(got) != 1 || got[0].Host != "a.customer.test" {
299 t.Fatalf("after the blip = %+v, want the claim back — a read failure was cached", got)
300 }
301}
302
303type flakyStore struct {
304 release.Store
305 failNext bool
306}
307
308func (f *flakyStore) Get(ctx context.Context, key string) ([]byte, string, error) {
309 if f.failNext {
310 f.failNext = false
311 return nil, "", errFlaky
312 }
313 return f.Store.Get(ctx, key)
314}
315
316func (f *flakyStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) {
317 return f.Store.(release.ModLister).ListMod(ctx, prefix)
318}
319
320var errFlaky = errors.New("bucket blip")
321
322// An object that will never decode is remembered, so a deployment carrying
323// one piece of litter under domains/ does not pay a GET for it on every
324// fold, on every box, forever.
325func TestClaimCacheRemembersAnUndecodableObject(t *testing.T) {
326 dir := t.TempDir()
327 s := &countingStore{Store: release.NewDirStore(dir)}
328 ctx := context.Background()
329 if _, err := s.Put(ctx, ClaimKey("junk.customer.test"), []byte("not json"), ""); err != nil {
330 t.Fatal(err)
331 }
332 backdate(t, dir, "junk.customer.test")
333 var c ClaimCache
334 if got, err := c.List(ctx, s); err != nil || len(got) != 0 {
335 t.Fatalf("fold = %+v, %v; want nothing", got, err)
336 }
337 s.gets = 0
338 if _, err := c.List(ctx, s); err != nil {
339 t.Fatal(err)
340 }
341 if s.gets != 0 {
342 t.Errorf("spent %d gets re-reading an object that will never decode", s.gets)
343 }
344}
345
346// The entry map is replaced whole rather than merged, so a deployment that
347// churns through hostnames does not accumulate an entry per hostname it has
348// ever held. Reaching into the field is white-box on purpose: there is no
349// behaviour to observe here, only unbounded growth, and unbounded growth
350// with no test is how a leak reaches production.
351func TestClaimCacheDoesNotGrowWithChurnedHostnames(t *testing.T) {
352 dir := t.TempDir()
353 s := release.NewDirStore(dir)
354 ctx := context.Background()
355 var c ClaimCache
356 for i, host := range []string{"one.customer.test", "two.customer.test", "three.customer.test"} {
357 seedClaim(t, s, host, "kass")
358 backdate(t, dir, host)
359 if _, err := c.List(ctx, s); err != nil {
360 t.Fatal(err)
361 }
362 if _, err := DetachClaim(ctx, s, host, "acct", "kass"); err != nil {
363 t.Fatal(err)
364 }
365 if _, err := c.List(ctx, s); err != nil {
366 t.Fatal(err)
367 }
368 c.mu.Lock()
369 n := len(c.entries)
370 c.mu.Unlock()
371 if n != 0 {
372 t.Fatalf("after churning %d hostname(s) the cache holds %d entries, want 0", i+1, n)
373 }
374 }
375}
376