mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
fix(#76): preserve pacing and empty-state truth
This commit is contained in:
@@ -48,11 +48,9 @@ func (p *requestPacer) waitContext(ctx context.Context) error {
|
|||||||
select {
|
select {
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// Return the unused reservation so repeated bounded enrichment
|
// Keep the unused reservation in the schedule. Blindly reclaiming an
|
||||||
// cancellations do not leave pacing debt for later real requests.
|
// interval can collide with later callers that already reserved their
|
||||||
p.mu.Lock()
|
// wake times, releasing two requests simultaneously.
|
||||||
p.next = p.next.Add(-p.interval)
|
|
||||||
p.mu.Unlock()
|
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -50,25 +51,67 @@ func TestRequestPacerNilSafe(t *testing.T) {
|
|||||||
p.wait() // must not panic
|
p.wait() // must not panic
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestPacerCancellationReturnsUnusedReservation(t *testing.T) {
|
func TestRequestPacerCancellationDoesNotCollideReservedWaiters(t *testing.T) {
|
||||||
p := &requestPacer{interval: 100 * time.Millisecond}
|
const interval = 80 * time.Millisecond
|
||||||
|
p := &requestPacer{interval: interval}
|
||||||
if err := p.waitContext(context.Background()); err != nil {
|
if err := p.waitContext(context.Background()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
wantNext := p.next
|
initialNext := p.next
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
waitForReservation := func(want time.Time) {
|
||||||
defer cancel()
|
t.Helper()
|
||||||
if err := p.waitContext(ctx); err == nil {
|
deadline := time.Now().Add(250 * time.Millisecond)
|
||||||
t.Fatal("expected paced wait cancellation")
|
for time.Now().Before(deadline) {
|
||||||
|
p.mu.Lock()
|
||||||
|
got := p.next
|
||||||
|
p.mu.Unlock()
|
||||||
|
if got.Equal(want) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("reservation did not reach %v", want)
|
||||||
}
|
}
|
||||||
p.mu.Lock()
|
|
||||||
gotNext := p.next
|
ctxB, cancelB := context.WithCancel(context.Background())
|
||||||
p.mu.Unlock()
|
bDone := make(chan error, 1)
|
||||||
if !gotNext.Equal(wantNext) {
|
go func() { bDone <- p.waitContext(ctxB) }()
|
||||||
t.Fatalf("cancelled reservation left pacing debt: next %v, want %v", gotNext, wantNext)
|
waitForReservation(initialNext.Add(interval))
|
||||||
|
|
||||||
|
cDone := make(chan time.Time, 1)
|
||||||
|
go func() {
|
||||||
|
_ = p.waitContext(context.Background())
|
||||||
|
cDone <- time.Now()
|
||||||
|
}()
|
||||||
|
waitForReservation(initialNext.Add(2 * interval))
|
||||||
|
|
||||||
|
cancelStarted := time.Now()
|
||||||
|
cancelB()
|
||||||
|
select {
|
||||||
|
case err := <-bDone:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("B error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(cancelStarted); elapsed > 30*time.Millisecond {
|
||||||
|
t.Fatalf("B cancellation took %v", elapsed)
|
||||||
|
}
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
t.Fatal("B did not return promptly after cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
|
dDone := make(chan time.Time, 1)
|
||||||
|
go func() {
|
||||||
|
_ = p.waitContext(context.Background())
|
||||||
|
dDone <- time.Now()
|
||||||
|
}()
|
||||||
|
waitForReservation(initialNext.Add(3 * interval))
|
||||||
|
|
||||||
|
cAt, dAt := <-cDone, <-dDone
|
||||||
|
if separation := dAt.Sub(cAt); separation < interval/2 {
|
||||||
|
t.Fatalf("C and D collided: wake separation %v, want at least %v", separation, interval/2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -836,7 +836,7 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mode == sourceLocal {
|
if mode == sourceLocal {
|
||||||
markLocalResponse(w, false)
|
markDataResponse(w, "none", "limited")
|
||||||
writeJSON(w, resp)
|
writeJSON(w, resp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -959,6 +959,9 @@ func fetchSeasonRaces(client *api.OpenF1Client, year int) (races []meetingRace,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if raceKey == 0 {
|
if raceKey == 0 {
|
||||||
|
if isKnownNonChampionshipMeeting(m, sessions) {
|
||||||
|
return meetingRace{}, false
|
||||||
|
}
|
||||||
failed.Store(true)
|
failed.Store(true)
|
||||||
// The meeting list does not identify non-championship events. Skipping
|
// The meeting list does not identify non-championship events. Skipping
|
||||||
// a meeting without a Race may be expected (testing), but the aggregate
|
// a meeting without a Race may be expected (testing), but the aggregate
|
||||||
@@ -975,6 +978,29 @@ func fetchSeasonRaces(client *api.OpenF1Client, year int) (races []meetingRace,
|
|||||||
return races, failed.Load(), nil
|
return races, failed.Load(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isKnownNonChampionshipMeeting(meeting models.Meeting, sessions []models.Session) bool {
|
||||||
|
if hasTestingToken(meeting.MeetingName + " " + meeting.MeetingOfficialName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, session := range sessions {
|
||||||
|
if hasTestingToken(session.SessionName + " " + session.SessionType) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTestingToken(value string) bool {
|
||||||
|
for _, token := range strings.FieldsFunc(strings.ToLower(value), func(r rune) bool {
|
||||||
|
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
|
||||||
|
}) {
|
||||||
|
if token == "test" || token == "tests" || token == "testing" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
|
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
|
||||||
// unit-tested with synthetic data. races must be ordered ascending by date and
|
// unit-tested with synthetic data. races must be ordered ascending by date and
|
||||||
// contain only GP meetings (those with a Race session).
|
// contain only GP meetings (those with a Race session).
|
||||||
@@ -1415,9 +1441,11 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-race sessions have no stints.
|
// Empty strategy data may mean a non-race session or a race still settling.
|
||||||
if len(stints) == 0 {
|
if len(stints) == 0 {
|
||||||
markOpenF1AggregateResponse(w, client, driversErr != nil || rcErr != nil)
|
// Without session-type evidence, an empty primary strategy dataset is
|
||||||
|
// not enough to prove "not applicable" (it may still be settling).
|
||||||
|
markOpenF1Availability(w, client, "limited")
|
||||||
writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}})
|
writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func championshipTestUpstream(t *testing.T, driversOK, meetingHasRace bool) *htt
|
|||||||
}
|
}
|
||||||
_, _ = w.Write([]byte(`[{"driver_number":1,"name_acronym":"VER","full_name":"Max Verstappen","team_name":"Red Bull","team_colour":"3671c6"}]`))
|
_, _ = w.Write([]byte(`[{"driver_number":1,"name_acronym":"VER","full_name":"Max Verstappen","team_name":"Red Bull","team_colour":"3671c6"}]`))
|
||||||
case "/v1/meetings":
|
case "/v1/meetings":
|
||||||
_, _ = w.Write([]byte(`[{"meeting_key":1,"meeting_name":"Test GP"}]`))
|
_, _ = w.Write([]byte(`[{"meeting_key":1,"meeting_name":"Mystery Grand Prix"}]`))
|
||||||
case "/v1/session_result":
|
case "/v1/session_result":
|
||||||
_, _ = w.Write([]byte(`[{"driver_number":1,"position":1,"points":25}]`))
|
_, _ = w.Write([]byte(`[{"driver_number":1,"position":1,"points":25}]`))
|
||||||
case "/v1/starting_grid":
|
case "/v1/starting_grid":
|
||||||
@@ -86,6 +86,51 @@ func TestFetchSeasonRacesMeetingWithoutRaceIsIncomplete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFetchSeasonRacesRecognizedTestingMeetingIsNotIncomplete(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/meetings":
|
||||||
|
_, _ = w.Write([]byte(`[{"meeting_key":1253,"meeting_name":"Pre-Season Testing"}]`))
|
||||||
|
case "/v1/sessions":
|
||||||
|
_, _ = w.Write([]byte(`[{"session_key":1,"meeting_key":1253,"session_name":"Day 1","session_type":"Testing"}]`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
races, incomplete, err := fetchSeasonRaces(client.Scoped(), time.Now().Year())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if incomplete || len(races) != 0 {
|
||||||
|
t.Fatalf("recognized testing meeting = races %d, incomplete %v", len(races), incomplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnownNonChampionshipMeetingRequiresTestingToken(t *testing.T) {
|
||||||
|
if !isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Pre-Season Testing"}, nil) {
|
||||||
|
t.Fatal("pre-season testing was not recognized")
|
||||||
|
}
|
||||||
|
if isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Fastest Grand Prix"}, nil) {
|
||||||
|
t.Fatal("substring inside a normal word was treated as testing")
|
||||||
|
}
|
||||||
|
if !isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Winter Event"}, []models.Session{{SessionType: "Test"}}) {
|
||||||
|
t.Fatal("explicit Test session was not recognized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChampionshipHubSourceLocalWithoutAggregateIsLimited(t *testing.T) {
|
||||||
|
server := NewServer(nil, 0, nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleChampionshipHub(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/championship/hub?year=2026&source=local", nil))
|
||||||
|
if recorder.Code != http.StatusOK || recorder.Header().Get(dataSourceHeader) != "none" || recorder.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Fatalf("empty local championship = %d %q/%q body=%s", recorder.Code, recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader), recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func raceResult(num, pos int, pts float64) models.SessionResult {
|
func raceResult(num, pos int, pts float64) models.SessionResult {
|
||||||
return models.SessionResult{DriverNumber: num, Position: pos, Points: pts}
|
return models.SessionResult{DriverNumber: num, Position: pos, Points: pts}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,19 @@ func TestStrategyOptionalComponentFailureReportsPartial(t *testing.T) {
|
|||||||
assertAvailabilityHeaders(t, recorder, "openf1", "partial")
|
assertAvailabilityHeaders(t, recorder, "openf1", "partial")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStrategyEmptyPrimaryDataReportsLimited(t *testing.T) {
|
||||||
|
server := componentTestServer(t, map[string]string{
|
||||||
|
"/v1/stints": `[]`,
|
||||||
|
"/v1/pit": `[]`,
|
||||||
|
"/v1/session_result": `[{"driver_number":1,"position":1,"number_of_laps":10}]`,
|
||||||
|
"/v1/drivers": `[{"driver_number":1,"full_name":"Max Verstappen","team_name":"Red Bull","team_colour":"3671c6"}]`,
|
||||||
|
"/v1/race_control": `[]`,
|
||||||
|
}, nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleStrategy(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/strategy?session_key=99", nil))
|
||||||
|
assertAvailabilityHeaders(t, recorder, "openf1", "limited")
|
||||||
|
}
|
||||||
|
|
||||||
func TestLapsComparisonDoesNotLabelMissingComponentsFresh(t *testing.T) {
|
func TestLapsComparisonDoesNotLabelMissingComponentsFresh(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/api"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -11,13 +9,17 @@ const (
|
|||||||
dataFreshnessHeader = "X-BoxBox-Data-Freshness"
|
dataFreshnessHeader = "X-BoxBox-Data-Freshness"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type staleResponseReporter interface {
|
||||||
|
LastResponseWasStale() bool
|
||||||
|
}
|
||||||
|
|
||||||
// markOpenF1Response publishes request-scoped success provenance. Callers must
|
// markOpenF1Response publishes request-scoped success provenance. Callers must
|
||||||
// pass the scoped client used for this response, never Server.client.
|
// pass the scoped client used for this response, never Server.client.
|
||||||
func markOpenF1Response(w http.ResponseWriter, client *api.OpenF1Client) {
|
func markOpenF1Response(w http.ResponseWriter, client staleResponseReporter) {
|
||||||
markOpenF1AggregateResponse(w, client, false)
|
markOpenF1AggregateResponse(w, client, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func markOpenF1AggregateResponse(w http.ResponseWriter, client *api.OpenF1Client, partial bool) {
|
func markOpenF1AggregateResponse(w http.ResponseWriter, client staleResponseReporter, partial bool) {
|
||||||
freshness := "fresh"
|
freshness := "fresh"
|
||||||
if partial {
|
if partial {
|
||||||
freshness = "partial"
|
freshness = "partial"
|
||||||
@@ -25,7 +27,7 @@ func markOpenF1AggregateResponse(w http.ResponseWriter, client *api.OpenF1Client
|
|||||||
markOpenF1Availability(w, client, freshness)
|
markOpenF1Availability(w, client, freshness)
|
||||||
}
|
}
|
||||||
|
|
||||||
func markOpenF1Availability(w http.ResponseWriter, client *api.OpenF1Client, freshness string) {
|
func markOpenF1Availability(w http.ResponseWriter, client staleResponseReporter, freshness string) {
|
||||||
w.Header().Set(dataSourceHeader, "openf1")
|
w.Header().Set(dataSourceHeader, "openf1")
|
||||||
if client != nil && client.LastResponseWasStale() {
|
if client != nil && client.LastResponseWasStale() {
|
||||||
w.Header().Set(dataFreshnessHeader, "stale")
|
w.Header().Set(dataFreshnessHeader, "stale")
|
||||||
@@ -51,7 +53,7 @@ func markLocalResponse(w http.ResponseWriter, partial bool) {
|
|||||||
w.Header().Set(dataFreshnessHeader, "local")
|
w.Header().Set(dataFreshnessHeader, "local")
|
||||||
}
|
}
|
||||||
|
|
||||||
func markMixedResponse(w http.ResponseWriter, client *api.OpenF1Client, partial bool) {
|
func markMixedResponse(w http.ResponseWriter, client staleResponseReporter, partial bool) {
|
||||||
w.Header().Set(dataSourceHeader, "mixed")
|
w.Header().Set(dataSourceHeader, "mixed")
|
||||||
if client != nil && client.LastResponseWasStale() {
|
if client != nil && client.LastResponseWasStale() {
|
||||||
w.Header().Set(dataFreshnessHeader, "stale")
|
w.Header().Set(dataFreshnessHeader, "stale")
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type fakeStaleReporter bool
|
||||||
|
|
||||||
|
func (f fakeStaleReporter) LastResponseWasStale() bool { return bool(f) }
|
||||||
|
|
||||||
|
func TestStaleFreshnessTakesPrecedenceOverPartialAndLimited(t *testing.T) {
|
||||||
|
for _, fallback := range []string{"partial", "limited"} {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
markOpenF1Availability(recorder, fakeStaleReporter(true), fallback)
|
||||||
|
if recorder.Header().Get(dataFreshnessHeader) != "stale" {
|
||||||
|
t.Fatalf("fallback %q overrode stale: %q", fallback, recorder.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenF1HandlerReportsFreshThenStaleSuccess(t *testing.T) {
|
func TestOpenF1HandlerReportsFreshThenStaleSuccess(t *testing.T) {
|
||||||
year := time.Now().Year()
|
year := time.Now().Year()
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionK
|
|||||||
}
|
}
|
||||||
resp.StartTime = start.Format(time.RFC3339Nano)
|
resp.StartTime = start.Format(time.RFC3339Nano)
|
||||||
resp.Frames = snapReplayFrames(series, start, intervalMS)
|
resp.Frames = snapReplayFrames(series, start, intervalMS)
|
||||||
return resp, err != nil || len(resp.Frames) == 0, nil
|
return resp, err != nil || len(series) < len(driverNumbers) || len(resp.Frames) == 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func uniqueDriverNumbers(drivers []models.Driver) []int {
|
func uniqueDriverNumbers(drivers []models.Driver) []int {
|
||||||
|
|||||||
@@ -107,8 +107,11 @@ func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||||
}
|
}
|
||||||
if incomplete {
|
if !incomplete {
|
||||||
t.Fatal("complete driver series reported incomplete")
|
t.Fatal("empty entrant location series was labelled complete")
|
||||||
|
}
|
||||||
|
if got := replayResponseFreshness(resp, incomplete); got != "partial" {
|
||||||
|
t.Fatalf("empty entrant freshness = %q", got)
|
||||||
}
|
}
|
||||||
if resp.SessionKey != 99 || resp.Interval != 5000 {
|
if resp.SessionKey != 99 || resp.Interval != 5000 {
|
||||||
t.Fatalf("response metadata = %+v", resp)
|
t.Fatalf("response metadata = %+v", resp)
|
||||||
|
|||||||
Reference in New Issue
Block a user