108 lines
3.6 KiB
Go
108 lines
3.6 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
)
|
|
|
|
// countingFeedbackClient counts top-level GetPRFeedback calls so the cache /
|
|
// singleflight behavior of Service.GetPRFeedback can be asserted without
|
|
// reaching real GitHub.
|
|
type countingFeedbackClient struct {
|
|
*MockClient
|
|
calls atomic.Int32
|
|
}
|
|
|
|
func (c *countingFeedbackClient) GetPRFeedback(_ context.Context, owner, repo string, number int) (*PRFeedback, error) {
|
|
c.calls.Add(1)
|
|
return &PRFeedback{PR: &PR{RepoOwner: owner, RepoName: repo, Number: number}}, nil
|
|
}
|
|
|
|
// Repeated feedback fetches for the same PR within the TTL window must collapse
|
|
// to a single upstream call. Before the cache was added, every call hit GitHub
|
|
// (4 sequential REST calls each), so a render/mount burst on the task page
|
|
// fanned out into dozens of slow, rate-limited duplicate requests.
|
|
func TestService_GetPRFeedback_UsesCache(t *testing.T) {
|
|
client := &countingFeedbackClient{MockClient: NewMockClient()}
|
|
svc := newTestService(client)
|
|
ctx := context.Background()
|
|
|
|
for i := 0; i < 3; i++ {
|
|
if _, err := svc.GetPRFeedback(ctx, "o", "r", 1); err != nil {
|
|
t.Fatalf("call %d: %v", i, err)
|
|
}
|
|
}
|
|
if got := client.calls.Load(); got != 1 {
|
|
t.Fatalf("expected 1 upstream call, got %d", got)
|
|
}
|
|
|
|
if _, err := svc.GetPRFeedback(ctx, "o", "r", 2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := client.calls.Load(); got != 2 {
|
|
t.Fatalf("expected new fetch for different number, got %d", got)
|
|
}
|
|
}
|
|
|
|
// blockingFeedbackClient blocks inside GetPRFeedback until released, so the
|
|
// test can force many concurrent in-flight fetches for the same PR and assert
|
|
// they coalesce into one upstream call (singleflight). `started` is signaled
|
|
// the moment a goroutine enters the upstream call so the test can be certain
|
|
// at least one fetch is in flight before releasing siblings.
|
|
type blockingFeedbackClient struct {
|
|
*MockClient
|
|
calls atomic.Int32
|
|
release chan struct{}
|
|
started chan struct{}
|
|
}
|
|
|
|
func (c *blockingFeedbackClient) GetPRFeedback(_ context.Context, owner, repo string, number int) (*PRFeedback, error) {
|
|
c.calls.Add(1)
|
|
// Non-blocking signal so only the FIRST entrant fires `started`; later
|
|
// arrivals queue behind the singleflight without re-signaling.
|
|
select {
|
|
case c.started <- struct{}{}:
|
|
default:
|
|
}
|
|
<-c.release
|
|
return &PRFeedback{PR: &PR{RepoOwner: owner, RepoName: repo, Number: number}}, nil
|
|
}
|
|
|
|
// Concurrent feedback fetches for the same PR must coalesce to a single
|
|
// upstream call. This reproduces the production pile-up: the task page fired ~8
|
|
// identical GetPRFeedback requests concurrently, and without singleflight each
|
|
// independently hammered GitHub, ballooning durations to 40-73s.
|
|
func TestService_GetPRFeedback_CoalescesConcurrentCalls(t *testing.T) {
|
|
client := &blockingFeedbackClient{
|
|
MockClient: NewMockClient(),
|
|
release: make(chan struct{}),
|
|
started: make(chan struct{}, 1),
|
|
}
|
|
svc := newTestService(client)
|
|
|
|
const n = 16
|
|
var done sync.WaitGroup
|
|
done.Add(n)
|
|
for i := 0; i < n; i++ {
|
|
go func() {
|
|
defer done.Done()
|
|
if _, err := svc.GetPRFeedback(context.Background(), "o", "r", 1); err != nil {
|
|
t.Errorf("concurrent fetch: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
// Wait for the leader to actually enter the upstream call (not just be
|
|
// scheduled). Pre-fix this used launched.Wait, which only guaranteed the
|
|
// goroutines had STARTED — late arrivals after release could have seen
|
|
// a warm cache and skipped the singleflight, masking regressions.
|
|
<-client.started
|
|
close(client.release)
|
|
done.Wait()
|
|
|
|
if got := client.calls.Load(); got != 1 {
|
|
t.Fatalf("expected concurrent calls to coalesce to 1 upstream call, got %d", got)
|
|
}
|
|
}
|