package edge import ( "io" "net/http" "net/http/httptest" "path/filepath" "testing" "time" "github.com/carlosframework/platform/internal/registry" ) // The guard at the cache's own level: a tagged entry is live only while its // generation is its route's newest, and a stale one is refused at Put. func TestCacheGenerationGuard(t *testing.T) { c := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes) cur := uint64(1) c.SetGenerations(func(route string) (uint64, bool) { return cur, route == "inst.example.com" }) key := CacheKey{Host: "www.customer.example", Method: "GET", Path: "/"} tagged := func(n uint64) *CachedResponse { return &CachedResponse{StatusCode: 200, Body: []byte("x"), StoredAt: time.Now(), TTL: time.Minute, Route: "inst.example.com", Gen: n} } c.Put(key, "", tagged(1)) if _, ok := c.get(key); !ok { t.Fatal("an entry tagged with the current generation missed") } cur = 2 if _, ok := c.get(key); ok { t.Fatal("an entry from generation 1 hit after generation 2 became current") } other := CacheKey{Host: "inst.example.com", Method: "GET", Path: "/late"} c.Put(other, "", tagged(1)) if len(c.byURL[other.urlKey()]) != 0 { t.Fatal("Put stored a response from a generation that is no longer current") } c.Put(other, "", &CachedResponse{StatusCode: 200, Body: []byte("y"), StoredAt: time.Now(), TTL: time.Minute}) if _, ok := c.get(other); !ok { t.Fatal("an untagged entry was refused; the guard must leave unmanaged routes alone") } unarmed := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes) unarmed.Put(key, "", tagged(2)) if _, ok := unarmed.get(key); ok { t.Fatal("a tagged entry was served by a cache with no generation guard") } } // genCacheFixture is genFixture's managed instance reached under four names // (its own host, a custom-domain alias, a wildcard alias, and a path-routed // front door), with the cache on and a second generation's app ready. func genCacheFixture(t *testing.T) (e *Edge, act *genActivator, a, b *genApp) { t.Helper() dir := genSockDir(t) a = startGenApp(t, filepath.Join(dir, "a.sock"), body("A", "max-age=60")) b = startGenApp(t, filepath.Join(dir, "b.sock"), body("B", "max-age=60")) front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { io.WriteString(w, "front") })) t.Cleanup(front.Close) reg := openReg(t) for _, rt := range []registry.Route{ {Host: "inst.example.com", Kind: "instance", Hibernate: true, Socket: filepath.Join(dir, "canon.sock"), Status: "ready", Version: "v0"}, {Host: "front.example", Kind: "service", Addr: front.Listener.Addr().String(), Status: "ready"}, } { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } upsertAlias(t, reg, "www.customer.example", "inst.example.com") upsertAlias(t, reg, "*.wild.example", "inst.example.com") upsertPath(t, reg, "front.example", "acct", "inst.example.com") act = &genActivator{fakeGens: newFakeGens(true)} act.switchTo("inst.example.com", GenInfo{Socket: a.sock, N: 1, Build: "v1"}) var err error e, err = New(reg, act) if err != nil { t.Fatalf("New: %v", err) } e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) return e, act, a, b } // Spec §12 "Cache aliases": a custom domain, a wildcard and a path-routed // request each miss after the switch, without anything listing the aliases. func TestCachedAliasesMissAfterTheSwitch(t *testing.T) { e, act, _, b := genCacheFixture(t) urls := []string{ "http://inst.example.com/p", "http://www.customer.example/p", "http://x.wild.example/p", "http://front.example/acct/p", } for _, u := range urls { if got := serve(t, e, u).Body.String(); got != "A" { t.Fatalf("GET %s = %q, want A from generation 1", u, got) } } before := act.served.Load() for _, u := range urls { if got := serve(t, e, u).Body.String(); got != "A" { t.Fatalf("repeat GET %s = %q, want the cached A", u, got) } } if act.served.Load() != before { t.Fatal("repeat requests reached the activator; they should have been cache hits") } act.switchTo("inst.example.com", GenInfo{Socket: b.sock, N: 2, Build: "v2"}) for _, u := range urls { rec := serve(t, e, u) if rec.Body.String() != "B" || rec.Header().Get("X-Carlos-Version") != "v2" { t.Fatalf("GET %s after the switch = %q (version %q), want B from v2", u, rec.Body.String(), rec.Header().Get("X-Carlos-Version")) } } } // A response that finishes on the old generation after the switch is // returned to its client and never stored (spec §7.2 insertion rule). func TestLateResponseFromARetiredGenerationIsNotCached(t *testing.T) { dir := genSockDir(t) entered, gate := make(chan struct{}), make(chan struct{}) a := startGenApp(t, filepath.Join(dir, "a.sock"), http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { close(entered) <-gate w.Header().Set("Cache-Control", "max-age=60") io.WriteString(w, "A") })) b := startGenApp(t, filepath.Join(dir, "b.sock"), body("B", "max-age=60")) reg := openReg(t) if err := reg.Upsert(registry.Route{Host: "inst.example.com", Kind: "instance", Hibernate: true, Socket: filepath.Join(dir, "canon.sock"), Status: "ready"}); err != nil { t.Fatal(err) } act := &genActivator{fakeGens: newFakeGens(true)} act.switchTo("inst.example.com", GenInfo{Socket: a.sock, N: 1, Build: "v1"}) e, err := New(reg, act) if err != nil { t.Fatal(err) } e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) done := make(chan string) go func() { done <- serve(t, e, "http://inst.example.com/slow").Body.String() }() <-entered act.switchTo("inst.example.com", GenInfo{Socket: b.sock, N: 2, Build: "v2"}) close(gate) if got := <-done; got != "A" { t.Fatalf("in-flight request got %q, want its own A", got) } if n := len(e.cache.byURL[cacheURLKey{Host: "inst.example.com", Method: "GET", Path: "/slow"}]); n != 0 { t.Fatalf("%d entries stored for a response that finished after the switch, want 0", n) } if got := serve(t, e, "http://inst.example.com/slow").Body.String(); got != "B" { t.Fatalf("next request = %q, want B", got) } } // Cache-before-wake survives generations: a sleeping instance's cached page // still answers without a wake, because the entry's generation is still the // route's newest. func TestSleepingInstanceStillAnswersFromTheCache(t *testing.T) { e, act, a, _ := genCacheFixture(t) if got := serve(t, e, "http://inst.example.com/p").Body.String(); got != "A" { t.Fatalf("first GET = %q, want A", got) } act.sleep("inst.example.com") a.srv.Close() before := act.served.Load() if got := serve(t, e, "http://inst.example.com/p").Body.String(); got != "A" { t.Fatalf("GET while asleep = %q, want the cached A", got) } if act.served.Load() != before { t.Fatal("a cached page woke the instance") } } // Flag off: entries are untagged and nothing about generations touches them. func TestGenerationsOffCachesUntagged(t *testing.T) { e, act, _ := genFixture(t, false, true) e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) e.cache.Put(CacheKey{Host: "inst.example.com", Method: "GET", Path: "/p"}, "", &CachedResponse{ StatusCode: 200, Body: []byte("cached"), StoredAt: time.Now(), TTL: time.Minute}) act.switchTo("inst.example.com", GenInfo{Socket: "/nonexistent.sock", N: 9, Build: "v9"}) if got := serve(t, e, "http://inst.example.com/p").Body.String(); got != "cached" { t.Fatalf("GET = %q, want the untagged cached body", got) } if e.cache.genOf != nil { t.Fatal("the generation guard was armed with the switch off") } }