| new file mode 100644 |
| index 000000000..b71d03a4c |
| --- /dev/null |
| +++ b/internal/edge/gencache_test.go |
| @@ -0,0 +1,187 @@ |
| +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") |
| + } |
| +} |