mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
fix(#76): report request-scoped data freshness
This commit is contained in:
@@ -164,8 +164,8 @@ func (c *Cache) Get(key string) ([]byte, bool) {
|
||||
if ttl > 0 {
|
||||
age := time.Since(time.Unix(createdAt, 0))
|
||||
if age > ttl {
|
||||
// Expired — delete and return miss.
|
||||
_, _ = c.db.Exec(`DELETE FROM cache WHERE key = ?`, key)
|
||||
// Expired entries remain stored so get() can use them as a stale
|
||||
// fallback if the live request fails. Prune() owns physical cleanup.
|
||||
atomic.AddInt64(&c.stats.Misses, 1)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -56,6 +56,26 @@ type OpenF1Client struct {
|
||||
staleFlag int32
|
||||
}
|
||||
|
||||
// Scoped returns a lightweight request-scoped view of the client. Network,
|
||||
// pacing and cache resources are shared, while the stale fallback indicator is
|
||||
// deliberately not shared. Web handlers use this view so a stale fallback in
|
||||
// one concurrent HTTP request can never mark an unrelated response as stale.
|
||||
//
|
||||
// The legacy client-wide stale flag remains available for the TUI, whose loads
|
||||
// are intentionally aggregated into one navigation-level notice.
|
||||
func (c *OpenF1Client) Scoped() *OpenF1Client {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return &OpenF1Client{
|
||||
url: c.url,
|
||||
apiKey: c.apiKey,
|
||||
httpClient: c.httpClient,
|
||||
cache: c.cache,
|
||||
pacer: c.pacer,
|
||||
}
|
||||
}
|
||||
|
||||
func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
|
||||
return &OpenF1Client{
|
||||
url: url,
|
||||
|
||||
123
internal/api/freshness_test.go
Normal file
123
internal/api/freshness_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func expireCacheEntry(t *testing.T, c *OpenF1Client, key string) {
|
||||
t.Helper()
|
||||
if _, err := c.cache.db.Exec(`UPDATE cache SET created_at = ? WHERE key = ?`, time.Now().Add(-48*time.Hour).Unix(), key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedClientReportsStaleFallbackWithoutMutatingParent(t *testing.T) {
|
||||
year := time.Now().Year()
|
||||
var fail atomic.Bool
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if fail.Load() {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `[{
|
||||
"meeting_key": 1,
|
||||
"meeting_name": "British Grand Prix",
|
||||
"year": %d
|
||||
}]`, year)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
client := NewOpenF1Client(upstream.URL, time.Second)
|
||||
defer client.Close()
|
||||
client.pacer.interval = 0
|
||||
key := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
|
||||
_, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key = ?`, key)
|
||||
defer func() { _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key = ?`, key) }()
|
||||
|
||||
if _, err := client.GetMeetingsForYear(year); err != nil {
|
||||
t.Fatalf("prime cache: %v", err)
|
||||
}
|
||||
expireCacheEntry(t, client, key)
|
||||
fail.Store(true)
|
||||
|
||||
scoped := client.Scoped()
|
||||
meetings, err := scoped.GetMeetingsForYear(year)
|
||||
if err != nil || len(meetings) != 1 {
|
||||
t.Fatalf("stale fallback = (%+v, %v)", meetings, err)
|
||||
}
|
||||
if !scoped.LastResponseWasStale() {
|
||||
t.Fatal("scoped request did not report its stale fallback")
|
||||
}
|
||||
if client.LastResponseWasStale() {
|
||||
t.Fatal("request-scoped fallback leaked into the parent client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedClientsDoNotLeakFreshnessAcrossConcurrentRequests(t *testing.T) {
|
||||
year := time.Now().Year()
|
||||
staleStarted := make(chan struct{})
|
||||
releaseStale := make(chan struct{})
|
||||
var failMeetings atomic.Bool
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/meetings":
|
||||
if failMeetings.Load() {
|
||||
close(staleStarted)
|
||||
<-releaseStale
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `[{"meeting_key":1,"meeting_name":"British Grand Prix","year":%d}]`, year)
|
||||
case "/v1/sessions":
|
||||
_, _ = w.Write([]byte(`[{"session_key":11,"meeting_key":2,"session_name":"Race"}]`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
client := NewOpenF1Client(upstream.URL, 2*time.Second)
|
||||
defer client.Close()
|
||||
client.pacer.interval = 0
|
||||
meetingKey := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
|
||||
sessionKey := upstream.URL + "/v1/sessions?meeting_key=2"
|
||||
_, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key IN (?, ?)`, meetingKey, sessionKey)
|
||||
defer func() { _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key IN (?, ?)`, meetingKey, sessionKey) }()
|
||||
if _, err := client.GetMeetingsForYear(year); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expireCacheEntry(t, client, meetingKey)
|
||||
failMeetings.Store(true)
|
||||
|
||||
staleClient := client.Scoped()
|
||||
staleDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := staleClient.GetMeetingsForYear(year)
|
||||
staleDone <- err
|
||||
}()
|
||||
<-staleStarted
|
||||
|
||||
freshClient := client.Scoped()
|
||||
if _, err := freshClient.GetSessionsForMeeting(2); err != nil {
|
||||
t.Fatalf("fresh concurrent request: %v", err)
|
||||
}
|
||||
if freshClient.LastResponseWasStale() {
|
||||
t.Fatal("fresh request inherited concurrent request's stale state")
|
||||
}
|
||||
close(releaseStale)
|
||||
if err := <-staleDone; err != nil {
|
||||
t.Fatalf("stale request: %v", err)
|
||||
}
|
||||
if !staleClient.LastResponseWasStale() {
|
||||
t.Fatal("stale request lost its own freshness state")
|
||||
}
|
||||
if freshClient.LastResponseWasStale() || client.LastResponseWasStale() {
|
||||
t.Fatal("stale state leaked after concurrent requests completed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user