fix(#76): preserve pacing and empty-state truth

This commit is contained in:
2026-07-12 21:01:42 -04:00
parent c0d1cf2892
commit a0ff8fa5a7
9 changed files with 176 additions and 30 deletions

View File

@@ -48,11 +48,9 @@ func (p *requestPacer) waitContext(ctx context.Context) error {
select {
case <-timer.C:
case <-ctx.Done():
// Return the unused reservation so repeated bounded enrichment
// cancellations do not leave pacing debt for later real requests.
p.mu.Lock()
p.next = p.next.Add(-p.interval)
p.mu.Unlock()
// Keep the unused reservation in the schedule. Blindly reclaiming an
// interval can collide with later callers that already reserved their
// wake times, releasing two requests simultaneously.
return ctx.Err()
}
}

View File

@@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -50,25 +51,67 @@ func TestRequestPacerNilSafe(t *testing.T) {
p.wait() // must not panic
}
func TestRequestPacerCancellationReturnsUnusedReservation(t *testing.T) {
p := &requestPacer{interval: 100 * time.Millisecond}
func TestRequestPacerCancellationDoesNotCollideReservedWaiters(t *testing.T) {
const interval = 80 * time.Millisecond
p := &requestPacer{interval: interval}
if err := p.waitContext(context.Background()); err != nil {
t.Fatal(err)
}
p.mu.Lock()
wantNext := p.next
initialNext := p.next
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
defer cancel()
if err := p.waitContext(ctx); err == nil {
t.Fatal("expected paced wait cancellation")
waitForReservation := func(want time.Time) {
t.Helper()
deadline := time.Now().Add(250 * time.Millisecond)
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
p.mu.Unlock()
if !gotNext.Equal(wantNext) {
t.Fatalf("cancelled reservation left pacing debt: next %v, want %v", gotNext, wantNext)
ctxB, cancelB := context.WithCancel(context.Background())
bDone := make(chan error, 1)
go func() { bDone <- p.waitContext(ctxB) }()
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)
}
}