| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "io" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "path/filepath" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/registry" |
| 12 | ) |
| 13 | |
| 14 | // The guard at the cache's own level: a tagged entry is live only while its |
| 15 | // generation is its route's newest, and a stale one is refused at Put. |
| 16 | func TestCacheGenerationGuard(t *testing.T) { |
| 17 | c := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes) |
| 18 | cur := uint64(1) |
| 19 | c.SetGenerations(func(route string) (uint64, bool) { return cur, route == "inst.example.com" }) |
| 20 | key := CacheKey{Host: "www.customer.example", Method: "GET", Path: "/"} |
| 21 | tagged := func(n uint64) *CachedResponse { |
| 22 | return &CachedResponse{StatusCode: 200, Body: []byte("x"), StoredAt: time.Now(), TTL: time.Minute, Route: "inst.example.com", Gen: n} |
| 23 | } |
| 24 | c.Put(key, "", tagged(1)) |
| 25 | if _, ok := c.get(key); !ok { |
| 26 | t.Fatal("an entry tagged with the current generation missed") |
| 27 | } |
| 28 | cur = 2 |
| 29 | if _, ok := c.get(key); ok { |
| 30 | t.Fatal("an entry from generation 1 hit after generation 2 became current") |
| 31 | } |
| 32 | other := CacheKey{Host: "inst.example.com", Method: "GET", Path: "/late"} |
| 33 | c.Put(other, "", tagged(1)) |
| 34 | if len(c.byURL[other.urlKey()]) != 0 { |
| 35 | t.Fatal("Put stored a response from a generation that is no longer current") |
| 36 | } |
| 37 | c.Put(other, "", &CachedResponse{StatusCode: 200, Body: []byte("y"), StoredAt: time.Now(), TTL: time.Minute}) |
| 38 | if _, ok := c.get(other); !ok { |
| 39 | t.Fatal("an untagged entry was refused; the guard must leave unmanaged routes alone") |
| 40 | } |
| 41 | unarmed := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes) |
| 42 | unarmed.Put(key, "", tagged(2)) |
| 43 | if _, ok := unarmed.get(key); ok { |
| 44 | t.Fatal("a tagged entry was served by a cache with no generation guard") |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | // genCacheFixture is genFixture's managed instance reached under four names |
| 49 | // (its own host, a custom-domain alias, a wildcard alias, and a path-routed |
| 50 | // front door), with the cache on and a second generation's app ready. |
| 51 | func genCacheFixture(t *testing.T) (e *Edge, act *genActivator, a, b *genApp) { |
| 52 | t.Helper() |
| 53 | dir := genSockDir(t) |
| 54 | a = startGenApp(t, filepath.Join(dir, "a.sock"), body("A", "max-age=60")) |
| 55 | b = startGenApp(t, filepath.Join(dir, "b.sock"), body("B", "max-age=60")) |
| 56 | front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { io.WriteString(w, "front") })) |
| 57 | t.Cleanup(front.Close) |
| 58 | reg := openReg(t) |
| 59 | for _, rt := range []registry.Route{ |
| 60 | {Host: "inst.example.com", Kind: "instance", Hibernate: true, Socket: filepath.Join(dir, "canon.sock"), Status: "ready", Version: "v0"}, |
| 61 | {Host: "front.example", Kind: "service", Addr: front.Listener.Addr().String(), Status: "ready"}, |
| 62 | } { |
| 63 | if err := reg.Upsert(rt); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | } |
| 67 | upsertAlias(t, reg, "www.customer.example", "inst.example.com") |
| 68 | upsertAlias(t, reg, "*.wild.example", "inst.example.com") |
| 69 | upsertPath(t, reg, "front.example", "acct", "inst.example.com") |
| 70 | act = &genActivator{fakeGens: newFakeGens(true)} |
| 71 | act.switchTo("inst.example.com", GenInfo{Socket: a.sock, N: 1, Build: "v1"}) |
| 72 | var err error |
| 73 | e, err = New(reg, act) |
| 74 | if err != nil { |
| 75 | t.Fatalf("New: %v", err) |
| 76 | } |
| 77 | e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| 78 | return e, act, a, b |
| 79 | } |
| 80 | |
| 81 | // Spec §12 "Cache aliases": a custom domain, a wildcard and a path-routed |
| 82 | // request each miss after the switch, without anything listing the aliases. |
| 83 | func TestCachedAliasesMissAfterTheSwitch(t *testing.T) { |
| 84 | e, act, _, b := genCacheFixture(t) |
| 85 | urls := []string{ |
| 86 | "http://inst.example.com/p", |
| 87 | "http://www.customer.example/p", |
| 88 | "http://x.wild.example/p", |
| 89 | "http://front.example/acct/p", |
| 90 | } |
| 91 | for _, u := range urls { |
| 92 | if got := serve(t, e, u).Body.String(); got != "A" { |
| 93 | t.Fatalf("GET %s = %q, want A from generation 1", u, got) |
| 94 | } |
| 95 | } |
| 96 | before := act.served.Load() |
| 97 | for _, u := range urls { |
| 98 | if got := serve(t, e, u).Body.String(); got != "A" { |
| 99 | t.Fatalf("repeat GET %s = %q, want the cached A", u, got) |
| 100 | } |
| 101 | } |
| 102 | if act.served.Load() != before { |
| 103 | t.Fatal("repeat requests reached the activator; they should have been cache hits") |
| 104 | } |
| 105 | act.switchTo("inst.example.com", GenInfo{Socket: b.sock, N: 2, Build: "v2"}) |
| 106 | for _, u := range urls { |
| 107 | rec := serve(t, e, u) |
| 108 | if rec.Body.String() != "B" || rec.Header().Get("X-Carlos-Version") != "v2" { |
| 109 | t.Fatalf("GET %s after the switch = %q (version %q), want B from v2", u, rec.Body.String(), rec.Header().Get("X-Carlos-Version")) |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | // A response that finishes on the old generation after the switch is |
| 115 | // returned to its client and never stored (spec §7.2 insertion rule). |
| 116 | func TestLateResponseFromARetiredGenerationIsNotCached(t *testing.T) { |
| 117 | dir := genSockDir(t) |
| 118 | entered, gate := make(chan struct{}), make(chan struct{}) |
| 119 | a := startGenApp(t, filepath.Join(dir, "a.sock"), http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 120 | close(entered) |
| 121 | <-gate |
| 122 | w.Header().Set("Cache-Control", "max-age=60") |
| 123 | io.WriteString(w, "A") |
| 124 | })) |
| 125 | b := startGenApp(t, filepath.Join(dir, "b.sock"), body("B", "max-age=60")) |
| 126 | reg := openReg(t) |
| 127 | if err := reg.Upsert(registry.Route{Host: "inst.example.com", Kind: "instance", Hibernate: true, |
| 128 | Socket: filepath.Join(dir, "canon.sock"), Status: "ready"}); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | act := &genActivator{fakeGens: newFakeGens(true)} |
| 132 | act.switchTo("inst.example.com", GenInfo{Socket: a.sock, N: 1, Build: "v1"}) |
| 133 | e, err := New(reg, act) |
| 134 | if err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| 138 | |
| 139 | done := make(chan string) |
| 140 | go func() { done <- serve(t, e, "http://inst.example.com/slow").Body.String() }() |
| 141 | <-entered |
| 142 | act.switchTo("inst.example.com", GenInfo{Socket: b.sock, N: 2, Build: "v2"}) |
| 143 | close(gate) |
| 144 | if got := <-done; got != "A" { |
| 145 | t.Fatalf("in-flight request got %q, want its own A", got) |
| 146 | } |
| 147 | if n := len(e.cache.byURL[cacheURLKey{Host: "inst.example.com", Method: "GET", Path: "/slow"}]); n != 0 { |
| 148 | t.Fatalf("%d entries stored for a response that finished after the switch, want 0", n) |
| 149 | } |
| 150 | if got := serve(t, e, "http://inst.example.com/slow").Body.String(); got != "B" { |
| 151 | t.Fatalf("next request = %q, want B", got) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // Cache-before-wake survives generations: a sleeping instance's cached page |
| 156 | // still answers without a wake, because the entry's generation is still the |
| 157 | // route's newest. |
| 158 | func TestSleepingInstanceStillAnswersFromTheCache(t *testing.T) { |
| 159 | e, act, a, _ := genCacheFixture(t) |
| 160 | if got := serve(t, e, "http://inst.example.com/p").Body.String(); got != "A" { |
| 161 | t.Fatalf("first GET = %q, want A", got) |
| 162 | } |
| 163 | act.sleep("inst.example.com") |
| 164 | a.srv.Close() |
| 165 | before := act.served.Load() |
| 166 | if got := serve(t, e, "http://inst.example.com/p").Body.String(); got != "A" { |
| 167 | t.Fatalf("GET while asleep = %q, want the cached A", got) |
| 168 | } |
| 169 | if act.served.Load() != before { |
| 170 | t.Fatal("a cached page woke the instance") |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | // Flag off: entries are untagged and nothing about generations touches them. |
| 175 | func TestGenerationsOffCachesUntagged(t *testing.T) { |
| 176 | e, act, _ := genFixture(t, false, true) |
| 177 | e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| 178 | e.cache.Put(CacheKey{Host: "inst.example.com", Method: "GET", Path: "/p"}, "", &CachedResponse{ |
| 179 | StatusCode: 200, Body: []byte("cached"), StoredAt: time.Now(), TTL: time.Minute}) |
| 180 | act.switchTo("inst.example.com", GenInfo{Socket: "/nonexistent.sock", N: 9, Build: "v9"}) |
| 181 | if got := serve(t, e, "http://inst.example.com/p").Body.String(); got != "cached" { |
| 182 | t.Fatalf("GET = %q, want the untagged cached body", got) |
| 183 | } |
| 184 | if e.cache.genOf != nil { |
| 185 | t.Fatal("the generation guard was armed with the switch off") |
| 186 | } |
| 187 | } |
| 188 | |