Convert a single-flight panic into an error
- flightGroup.Do recovers a panicking fn so the leader and every waiter get a non-nil error instead of a zero-value success served as 200 [] - Note in the README that facts_cache_bytes budgets body bytes only
This commit is contained in:
@@ -117,7 +117,9 @@ backend later without further handler changes.
|
|||||||
- **Bounded** — `facts_cache_bytes` (default 64 MiB) is a byte budget, evicted
|
- **Bounded** — `facts_cache_bytes` (default 64 MiB) is a byte budget, evicted
|
||||||
least-recently-used; reads count as use, so a stale entry that is still being
|
least-recently-used; reads count as use, so a stale entry that is still being
|
||||||
asked for survives. A single response larger than the whole budget is not
|
asked for survives. A single response larger than the whole budget is not
|
||||||
cached at all.
|
cached at all. The budget counts stored response bodies only — cache keys and
|
||||||
|
the list/map bookkeeping are not accounted for, so it is a target for body
|
||||||
|
bytes rather than a hard cap on process memory.
|
||||||
- **Single-flight** — concurrent requests for the same key collapse into one
|
- **Single-flight** — concurrent requests for the same key collapse into one
|
||||||
upstream fan-out; the rest wait for it and share the result.
|
upstream fan-out; the rest wait for it and share the result.
|
||||||
- **Visibility** — `/healthz` carries a `cache` object: `backend`
|
- **Visibility** — `/healthz` carries a `cache` object: `backend`
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"container/list"
|
"container/list"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"runtime/debug"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -191,8 +193,20 @@ type flightCall struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// flightPanic is a panic from a flight's fn, reported to the leader and to every
|
||||||
|
// waiter as an error so callers keep their error handling (stale fallback, 502)
|
||||||
|
// instead of seeing a zero-value success.
|
||||||
|
type flightPanic struct {
|
||||||
|
value any
|
||||||
|
stack []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *flightPanic) Error() string {
|
||||||
|
return fmt.Sprintf("panic building response: %v\n%s", p.value, p.stack)
|
||||||
|
}
|
||||||
|
|
||||||
// Do returns fn's result and whether this caller shared another's in-flight run.
|
// Do returns fn's result and whether this caller shared another's in-flight run.
|
||||||
func (g *flightGroup) Do(key string, fn func() (cachedResponse, error)) (cachedResponse, error, bool) {
|
func (g *flightGroup) Do(key string, fn func() (cachedResponse, error)) (resp cachedResponse, err error, shared bool) {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
if g.calls == nil {
|
if g.calls == nil {
|
||||||
g.calls = make(map[string]*flightCall)
|
g.calls = make(map[string]*flightCall)
|
||||||
@@ -208,6 +222,11 @@ func (g *flightGroup) Do(key string, fn func() (cachedResponse, error)) (cachedR
|
|||||||
g.mu.Unlock()
|
g.mu.Unlock()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
c.resp, c.err = cachedResponse{}, &flightPanic{value: r, stack: debug.Stack()}
|
||||||
|
resp, err = c.resp, c.err
|
||||||
|
}
|
||||||
|
// Done only after the results are stored, so waiters read them.
|
||||||
c.wg.Done()
|
c.wg.Done()
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
delete(g.calls, key)
|
delete(g.calls, key)
|
||||||
|
|||||||
+164
@@ -421,6 +421,170 @@ func TestFlightGroup_LeaderRunsOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFlightGroup_LeaderPanicFailsLeaderAndWaiters(t *testing.T) {
|
||||||
|
var g flightGroup
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
resp cachedResponse
|
||||||
|
err error
|
||||||
|
shared bool
|
||||||
|
}
|
||||||
|
waiters := make([]result, 8)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
var leader result
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
leader.resp, leader.err, leader.shared = g.Do("k", func() (cachedResponse, error) {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
panic("build exploded")
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-entered
|
||||||
|
for i := range waiters {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
waiters[i].resp, waiters[i].err, waiters[i].shared = g.Do("k", func() (cachedResponse, error) {
|
||||||
|
t.Error("a waiter must not run its own fn")
|
||||||
|
return cachedResponse{}, nil
|
||||||
|
})
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
// Park the waiters in Wait() before the leader panics.
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if leader.err == nil {
|
||||||
|
t.Errorf("leader err = nil, want a panic error (resp %+v)", leader.resp)
|
||||||
|
}
|
||||||
|
for i, w := range waiters {
|
||||||
|
if !w.shared {
|
||||||
|
t.Errorf("waiter %d did not share the flight", i)
|
||||||
|
}
|
||||||
|
if w.err == nil {
|
||||||
|
t.Fatalf("waiter %d unblocked with err = nil and resp %+v, want an error", i, w.resp)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.err.Error(), "build exploded") {
|
||||||
|
t.Errorf("waiter %d err = %v, want the panic value", i, w.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The key is released on the panic path, so a later call leads its own flight.
|
||||||
|
resp, err, shared := g.Do("k", func() (cachedResponse, error) {
|
||||||
|
return cachedResponse{Body: json.RawMessage(`[]`), Records: 0}, nil
|
||||||
|
})
|
||||||
|
if shared {
|
||||||
|
t.Error("a call after a panicking flight must start its own flight")
|
||||||
|
}
|
||||||
|
if err != nil || resp.Records != 0 {
|
||||||
|
t.Errorf("later call = (%+v, %v), want a clean success", resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeCached_BuildPanicIs502ThenStale(t *testing.T) {
|
||||||
|
a := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||||
|
b := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||||
|
srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
|
||||||
|
|
||||||
|
panicBuild := func() (cachedResponse, error) { panic("build exploded") }
|
||||||
|
serve := func(build func() (cachedResponse, error)) *httptest.ResponseRecorder {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, factsPath, nil)
|
||||||
|
srv.serveCached(rec, req, factsPath, nil, build)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// No entry to fall back to: a panicking build must not look like a success.
|
||||||
|
rec := serve(panicBuild)
|
||||||
|
if rec.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("status %d (%s), want 502", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
stored := `[` + fact("h1", "role", "web", "") + `]`
|
||||||
|
rec = serve(func() (cachedResponse, error) {
|
||||||
|
return cachedResponse{Body: json.RawMessage(stored), Records: 1}, nil
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("priming status %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
clk.advance(31 * time.Second)
|
||||||
|
rec = serve(panicBuild)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("stale fallback status %d (%s), want 200", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(rec.Body.String()); got != stored {
|
||||||
|
t.Errorf("stale body = %s, want %s", got, stored)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get(recordsHeader); got != "1" {
|
||||||
|
t.Errorf("%s = %q, want 1", recordsHeader, got)
|
||||||
|
}
|
||||||
|
if hr := health(t, srv); !hr.Cache.ServingStale || hr.Cache.StaleServed != 1 {
|
||||||
|
t.Errorf("panic fallback not counted as stale: %+v", hr.Cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeCached_BuildPanicFailsConcurrentWaiters(t *testing.T) {
|
||||||
|
a := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||||
|
b := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||||
|
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
|
||||||
|
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
serve := func(build func() (cachedResponse, error)) *httptest.ResponseRecorder {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, factsPath, nil)
|
||||||
|
srv.serveCached(rec, req, factsPath, nil, build)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
var leaderRec *httptest.ResponseRecorder
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
leaderRec = serve(func() (cachedResponse, error) {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
panic("build exploded")
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-entered
|
||||||
|
const waiters = 8
|
||||||
|
recs := make([]*httptest.ResponseRecorder, waiters)
|
||||||
|
for i := range waiters {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
recs[i] = serve(func() (cachedResponse, error) {
|
||||||
|
t.Error("a waiter must not run its own build")
|
||||||
|
return cachedResponse{}, nil
|
||||||
|
})
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if leaderRec.Code != http.StatusBadGateway {
|
||||||
|
t.Errorf("leader status %d, want 502", leaderRec.Code)
|
||||||
|
}
|
||||||
|
for i, rec := range recs {
|
||||||
|
if rec.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("waiter %d got %d (%s), want 502 rather than an empty success",
|
||||||
|
i, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandler_HealthzReportsCacheState(t *testing.T) {
|
func TestHandler_HealthzReportsCacheState(t *testing.T) {
|
||||||
a := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h1", "role", "web", "") + `]`})
|
a := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h1", "role", "web", "") + `]`})
|
||||||
b := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
b := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||||
|
|||||||
Reference in New Issue
Block a user