package domains import ( "context" "errors" "os" "reflect" "testing" "time" "github.com/carlosframework/platform/internal/release" ) // countingStore counts the GETs the fold spends. That count IS the property // under test — the whole point of the cache is that a claim whose bucket // stamp has not moved costs nothing to fold again. type countingStore struct { release.Store gets int } func (c *countingStore) Get(ctx context.Context, key string) ([]byte, string, error) { c.gets++ return c.Store.Get(ctx, key) } func (c *countingStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) { return c.Store.(release.ModLister).ListMod(ctx, prefix) } func seedClaim(t *testing.T, s release.Store, host, app string) { t.Helper() if _, _, err := AttachClaim(context.Background(), s, Claim{Host: host, Account: "acct", App: app}); err != nil { t.Fatal(err) } } // backdate moves a claim's bucket stamp out of the modSettle window, which // is what a claim written more than a couple of seconds ago looks like. Real // claims change on human timescales; a test cannot afford to sleep for them. func backdate(t *testing.T, dir, host string) { t.Helper() old := time.Now().Add(-time.Hour) if err := os.Chtimes(dir+"/"+ClaimKey(host), old, old); err != nil { t.Fatal(err) } } func TestClaimCacheReReadsNothingWhenNothingChanged(t *testing.T) { dir := t.TempDir() s := &countingStore{Store: release.NewDirStore(dir)} ctx := context.Background() for _, h := range []string{"a.customer.test", "b.customer.test", "c.customer.test"} { seedClaim(t, s, h, "kass") backdate(t, dir, h) } s.gets = 0 var c ClaimCache first, err := c.List(ctx, s) if err != nil { t.Fatal(err) } if len(first) != 3 { t.Fatalf("first fold = %+v, want 3 claims", first) } cold := s.gets if cold != 3 { t.Fatalf("cold fold spent %d gets, want one per claim", cold) } s.gets = 0 second, err := c.List(ctx, s) if err != nil { t.Fatal(err) } if len(second) != 3 { t.Fatalf("warm fold = %+v, want the same 3 claims", second) } if s.gets != 0 { t.Errorf("warm fold spent %d gets, want 0 — an unmoved stamp must cost nothing", s.gets) } } // The cache must be invisible: what it returns has to be what the uncached // fold returns, claim for claim, or a hostname appears on one read path and // vanishes on another. func TestClaimCacheAgreesWithTheUncachedFold(t *testing.T) { dir := t.TempDir() s := release.NewDirStore(dir) ctx := context.Background() for _, h := range []string{"a.customer.test", "b.customer.test"} { seedClaim(t, s, h, "kass") backdate(t, dir, h) } var c ClaimCache if _, err := c.List(ctx, s); err != nil { // prime t.Fatal(err) } cached, err := c.List(ctx, s) if err != nil { t.Fatal(err) } plain, err := ListClaims(ctx, s) if err != nil { t.Fatal(err) } if len(cached) != len(plain) { t.Fatalf("cached fold has %d claims, uncached has %d", len(cached), len(plain)) } byHost := map[string]Claim{} for _, p := range plain { byHost[p.Host] = p } for _, got := range cached { want, ok := byHost[got.Host] if !ok { t.Errorf("cached fold invented %s", got.Host) continue } if !reflect.DeepEqual(got, want) { t.Errorf("%s: cached %+v, uncached %+v", got.Host, got, want) } } } // A detached claim leaves the listing, and leaving the listing IS the // eviction — there is no separate invalidation call to forget to make. func TestClaimCacheDropsADetachedClaim(t *testing.T) { dir := t.TempDir() s := release.NewDirStore(dir) ctx := context.Background() seedClaim(t, s, "a.customer.test", "kass") seedClaim(t, s, "b.customer.test", "kass") backdate(t, dir, "a.customer.test") backdate(t, dir, "b.customer.test") var c ClaimCache if got, err := c.List(ctx, s); err != nil || len(got) != 2 { t.Fatalf("first fold = %+v, %v", got, err) } if _, err := DetachClaim(ctx, s, "b.customer.test", "acct", "kass"); err != nil { t.Fatal(err) } got, err := c.List(ctx, s) if err != nil { t.Fatal(err) } if len(got) != 1 || got[0].Host != "a.customer.test" { t.Fatalf("after detach = %+v, want only a.customer.test", got) } } // A changed claim is re-read, and only it. This is the case a cache gets // wrong: serving a stale body because the listing said the key is still // there. func TestClaimCacheReReadsOnlyWhatChanged(t *testing.T) { dir := t.TempDir() s := &countingStore{Store: release.NewDirStore(dir)} ctx := context.Background() seedClaim(t, s, "a.customer.test", "kass") seedClaim(t, s, "b.customer.test", "kass") backdate(t, dir, "a.customer.test") backdate(t, dir, "b.customer.test") var c ClaimCache if _, err := c.List(ctx, s); err != nil { t.Fatal(err) } if _, err := SetClaimCapabilities(ctx, s, "b.customer.test", "acct", "kass", true, false); err != nil { t.Fatal(err) } backdate(t, dir, "b.customer.test") // settled, but at a DIFFERENT stamp s.gets = 0 got, err := c.List(ctx, s) if err != nil { t.Fatal(err) } if s.gets != 1 { t.Errorf("spent %d gets, want exactly 1 — only the changed claim", s.gets) } for _, cl := range got { if cl.Host == "b.customer.test" && !cl.Wildcard { t.Error("the changed claim came back stale — the cache served a body the bucket no longer holds") } } } // THE lost update the modSettle bound exists for. S3 stamps have one-second // granularity, so a claim read and then rewritten inside the same second // carries an unchanged-looking stamp. Caching that read would serve the old // body forever, and "forever" is not an exaggeration: nothing else would // ever move the stamp. func TestClaimCacheRefusesToTrustAStampItJustSaw(t *testing.T) { dir := t.TempDir() s := &countingStore{Store: release.NewDirStore(dir)} ctx := context.Background() seedClaim(t, s, "a.customer.test", "kass") // written just now: inside modSettle var c ClaimCache if _, err := c.List(ctx, s); err != nil { t.Fatal(err) } s.gets = 0 if _, err := c.List(ctx, s); err != nil { t.Fatal(err) } if s.gets != 1 { t.Errorf("spent %d gets, want 1 — a stamp from the last two seconds must not be trusted as unchanged", s.gets) } } // A store that cannot answer ListMod — the fleet store door's client, every // test fake — degrades to the plain fold. Not to a weaker cache: a customer // fleet box is the hardest place to notice a stale claim set. func TestClaimCacheDegradesOnAStoreWithNoStamps(t *testing.T) { s := stamplessStore{release.NewDirStore(t.TempDir())} ctx := context.Background() seedClaim(t, s, "a.customer.test", "kass") var c ClaimCache got, err := c.List(ctx, s) if err != nil { t.Fatal(err) } if len(got) != 1 { t.Fatalf("fold = %+v, want the claim", got) } // Nothing was cached, so a second fold still sees a change made between // the two — which is the whole point of degrading rather than guessing. seedClaim(t, s, "b.customer.test", "kass") if got, err := c.List(ctx, s); err != nil || len(got) != 2 { t.Fatalf("second fold = %+v, %v; want both claims", got, err) } } // stamplessStore hides DirStore's ListMod behind a plain Store, the way the // fleet door's client and the package's test fakes do. type stamplessStore struct{ inner release.Store } func (s stamplessStore) Get(ctx context.Context, key string) ([]byte, string, error) { return s.inner.Get(ctx, key) } func (s stamplessStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { return s.inner.Put(ctx, key, body, ifMatch) } func (s stamplessStore) List(ctx context.Context, prefix string) ([]string, error) { return s.inner.List(ctx, prefix) } func (s stamplessStore) ListDirs(ctx context.Context, prefix string) ([]string, error) { return s.inner.ListDirs(ctx, prefix) } func (s stamplessStore) Delete(ctx context.Context, key string) error { return s.inner.Delete(ctx, key) } // A nil cache is legal and means "no caching", so a caller with none to // offer passes nil rather than every call site growing a branch. func TestNilClaimCacheFoldsWithoutCaching(t *testing.T) { s := release.NewDirStore(t.TempDir()) ctx := context.Background() seedClaim(t, s, "a.customer.test", "kass") var c *ClaimCache got, err := c.List(ctx, s) if err != nil || len(got) != 1 { t.Fatalf("nil cache fold = %+v, %v", got, err) } c.Reset() // must not panic } // A transient read failure must not be remembered. Remembering it would // hide a perfectly good claim until something moved its bucket stamp — and // nothing edits a settled claim, so for that hostname "until" means never. // A decode refusal IS remembered, because the same bytes refuse the same way // every pass and re-reading them forever buys nothing. func TestClaimCacheNeverRemembersATransientReadFailure(t *testing.T) { dir := t.TempDir() inner := release.NewDirStore(dir) s := &flakyStore{Store: inner} ctx := context.Background() seedClaim(t, inner, "a.customer.test", "kass") backdate(t, dir, "a.customer.test") var c ClaimCache s.failNext = true got, err := c.List(ctx, s) if err != nil { t.Fatal(err) } if len(got) != 0 { t.Fatalf("fold during the blip = %+v, want nothing readable", got) } // The blip is over. The claim must come back on the very next fold, // with no stamp change to prompt it. got, err = c.List(ctx, s) if err != nil { t.Fatal(err) } if len(got) != 1 || got[0].Host != "a.customer.test" { t.Fatalf("after the blip = %+v, want the claim back — a read failure was cached", got) } } type flakyStore struct { release.Store failNext bool } func (f *flakyStore) Get(ctx context.Context, key string) ([]byte, string, error) { if f.failNext { f.failNext = false return nil, "", errFlaky } return f.Store.Get(ctx, key) } func (f *flakyStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) { return f.Store.(release.ModLister).ListMod(ctx, prefix) } var errFlaky = errors.New("bucket blip") // An object that will never decode is remembered, so a deployment carrying // one piece of litter under domains/ does not pay a GET for it on every // fold, on every box, forever. func TestClaimCacheRemembersAnUndecodableObject(t *testing.T) { dir := t.TempDir() s := &countingStore{Store: release.NewDirStore(dir)} ctx := context.Background() if _, err := s.Put(ctx, ClaimKey("junk.customer.test"), []byte("not json"), ""); err != nil { t.Fatal(err) } backdate(t, dir, "junk.customer.test") var c ClaimCache if got, err := c.List(ctx, s); err != nil || len(got) != 0 { t.Fatalf("fold = %+v, %v; want nothing", got, err) } s.gets = 0 if _, err := c.List(ctx, s); err != nil { t.Fatal(err) } if s.gets != 0 { t.Errorf("spent %d gets re-reading an object that will never decode", s.gets) } } // The entry map is replaced whole rather than merged, so a deployment that // churns through hostnames does not accumulate an entry per hostname it has // ever held. Reaching into the field is white-box on purpose: there is no // behaviour to observe here, only unbounded growth, and unbounded growth // with no test is how a leak reaches production. func TestClaimCacheDoesNotGrowWithChurnedHostnames(t *testing.T) { dir := t.TempDir() s := release.NewDirStore(dir) ctx := context.Background() var c ClaimCache for i, host := range []string{"one.customer.test", "two.customer.test", "three.customer.test"} { seedClaim(t, s, host, "kass") backdate(t, dir, host) if _, err := c.List(ctx, s); err != nil { t.Fatal(err) } if _, err := DetachClaim(ctx, s, host, "acct", "kass"); err != nil { t.Fatal(err) } if _, err := c.List(ctx, s); err != nil { t.Fatal(err) } c.mu.Lock() n := len(c.entries) c.mu.Unlock() if n != 0 { t.Fatalf("after churning %d hostname(s) the cache holds %d entries, want 0", i+1, n) } } }