Files
graphql-monitoring-proxy/request_coalescing_test.go
T
lukaszraczylo fcdf76b8e8 fix(coalesce): surface panic error to coalesced waiters
When the primary request panicked, waiters received a nil response
and nil error, which the proxy turned into an empty HTTP 200.

Recover in the deferred release, store an error response for the
waiters, then re-panic so the primary panic semantics are
unchanged. The test gates the panic on the live waiter count, so
every waiter is coalesced before the release.
2026-08-14 00:01:31 +01:00

543 lines
15 KiB
Go

package main
import (
"errors"
"sync"
"sync/atomic"
"testing"
"time"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
"github.com/stretchr/testify/assert"
)
func TestNewRequestCoalescer(t *testing.T) {
tests := []struct {
name string
enabled bool
}{
{
name: "enabled coalescer",
enabled: true,
},
{
name: "disabled coalescer",
enabled: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := libpack_logger.New()
monitoring := libpack_monitoring.NewMonitoring(&libpack_monitoring.InitConfig{})
rc := NewRequestCoalescer(tt.enabled, logger, monitoring)
assert.NotNil(t, rc)
assert.Equal(t, tt.enabled, rc.enabled)
})
}
}
func TestRequestCoalescer_Do_SingleRequest(t *testing.T) {
rc := NewRequestCoalescer(true, libpack_logger.New(), nil)
executed := false
response := &CoalescedResponse{
Body: []byte("test response"),
StatusCode: 200,
}
fn := func() (*CoalescedResponse, error) {
executed = true
return response, nil
}
result, err := rc.Do("test-key", fn)
assert.NoError(t, err)
assert.True(t, executed)
assert.Equal(t, response, result)
stats := rc.GetStats()
assert.Equal(t, int64(1), stats["total_requests"])
assert.Equal(t, int64(1), stats["primary_requests"])
assert.Equal(t, int64(0), stats["coalesced_requests"])
}
func TestRequestCoalescer_Do_ConcurrentRequests(t *testing.T) {
rc := NewRequestCoalescer(true, libpack_logger.New(), nil)
var executionCount atomic.Int32
response := &CoalescedResponse{
Body: []byte("test response"),
StatusCode: 200,
}
fn := func() (*CoalescedResponse, error) {
executionCount.Add(1)
time.Sleep(50 * time.Millisecond) // Simulate work
return response, nil
}
// Launch concurrent requests with the same key
concurrentRequests := 10
var wg sync.WaitGroup
wg.Add(concurrentRequests)
results := make([]*CoalescedResponse, concurrentRequests)
errs := make([]error, concurrentRequests)
for i := 0; i < concurrentRequests; i++ {
go func(index int) {
defer wg.Done()
results[index], errs[index] = rc.Do("same-key", fn)
}(i)
}
wg.Wait()
// Function should only execute once
assert.Equal(t, int32(1), executionCount.Load())
// All requests should get the same response
for i := 0; i < concurrentRequests; i++ {
assert.NoError(t, errs[i])
assert.Equal(t, response, results[i])
}
stats := rc.GetStats()
assert.Equal(t, int64(concurrentRequests), stats["total_requests"])
assert.Equal(t, int64(1), stats["primary_requests"])
assert.Equal(t, int64(concurrentRequests-1), stats["coalesced_requests"])
// Check backend savings
backendSavings := stats["backend_savings_pct"].(float64)
assert.Greater(t, backendSavings, 0.0)
}
func TestRequestCoalescer_Do_DifferentKeys(t *testing.T) {
rc := NewRequestCoalescer(true, libpack_logger.New(), nil)
var executionCount atomic.Int32
fn := func() (*CoalescedResponse, error) {
executionCount.Add(1)
return &CoalescedResponse{Body: []byte("response")}, nil
}
// Concurrent requests with different keys
var wg sync.WaitGroup
keys := []string{"key1", "key2", "key3"}
for _, key := range keys {
wg.Add(1)
go func(k string) {
defer wg.Done()
rc.Do(k, fn)
}(key)
}
wg.Wait()
// Function should execute for each unique key
assert.Equal(t, int32(len(keys)), executionCount.Load())
stats := rc.GetStats()
assert.Equal(t, int64(3), stats["primary_requests"])
assert.Equal(t, int64(0), stats["coalesced_requests"])
}
func TestRequestCoalescer_Do_Error(t *testing.T) {
rc := NewRequestCoalescer(true, libpack_logger.New(), nil)
expectedErr := errors.New("test error")
fn := func() (*CoalescedResponse, error) {
return nil, expectedErr
}
result, err := rc.Do("error-key", fn)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Error(t, result.Err)
assert.Equal(t, expectedErr, result.Err)
}
func TestRequestCoalescer_Do_ConcurrentWithError(t *testing.T) {
rc := NewRequestCoalescer(true, libpack_logger.New(), nil)
expectedErr := errors.New("test error")
var executionCount atomic.Int32
fn := func() (*CoalescedResponse, error) {
executionCount.Add(1)
time.Sleep(50 * time.Millisecond)
return nil, expectedErr
}
// Launch concurrent requests
concurrentRequests := 5
var wg sync.WaitGroup
wg.Add(concurrentRequests)
results := make([]*CoalescedResponse, concurrentRequests)
for i := 0; i < concurrentRequests; i++ {
go func(index int) {
defer wg.Done()
results[index], _ = rc.Do("error-key", fn)
}(i)
}
wg.Wait()
// Function should only execute once
assert.Equal(t, int32(1), executionCount.Load())
// All requests should get the same error in response
for i := 0; i < concurrentRequests; i++ {
assert.NotNil(t, results[i])
assert.Error(t, results[i].Err)
assert.Equal(t, expectedErr, results[i].Err)
}
}
func TestRequestCoalescer_Do_Disabled(t *testing.T) {
rc := NewRequestCoalescer(false, libpack_logger.New(), nil)
var executionCount atomic.Int32
fn := func() (*CoalescedResponse, error) {
executionCount.Add(1)
return &CoalescedResponse{Body: []byte("response")}, nil
}
// Launch concurrent requests with the same key
concurrentRequests := 5
var wg sync.WaitGroup
wg.Add(concurrentRequests)
for i := 0; i < concurrentRequests; i++ {
go func() {
defer wg.Done()
rc.Do("same-key", fn)
}()
}
wg.Wait()
// When disabled, function should execute for each request
assert.Equal(t, int32(concurrentRequests), executionCount.Load())
stats := rc.GetStats()
assert.Equal(t, false, stats["enabled"])
}
func TestRequestCoalescer_GetStats(t *testing.T) {
rc := NewRequestCoalescer(true, libpack_logger.New(), nil)
fn := func() (*CoalescedResponse, error) {
time.Sleep(10 * time.Millisecond)
return &CoalescedResponse{Body: []byte("response")}, nil
}
// Simulate some coalesced requests
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
rc.Do("key1", fn)
}()
}
wg.Wait()
// Add some non-coalesced requests
rc.Do("key2", fn)
rc.Do("key3", fn)
stats := rc.GetStats()
assert.Equal(t, true, stats["enabled"])
assert.Equal(t, int64(12), stats["total_requests"])
assert.Equal(t, int64(3), stats["primary_requests"])
assert.Equal(t, int64(9), stats["coalesced_requests"])
assert.Equal(t, int64(0), stats["inflight_count"])
coalescingRate := stats["coalescing_rate_pct"].(float64)
assert.Greater(t, coalescingRate, 0.0)
assert.LessOrEqual(t, coalescingRate, 100.0)
backendSavings := stats["backend_savings_pct"].(float64)
assert.Greater(t, backendSavings, 0.0)
}
func TestRequestCoalescer_Reset(t *testing.T) {
rc := NewRequestCoalescer(true, libpack_logger.New(), nil)
fn := func() (*CoalescedResponse, error) {
return &CoalescedResponse{Body: []byte("response")}, nil
}
// Generate some activity
rc.Do("key1", fn)
rc.Do("key2", fn)
statsBefore := rc.GetStats()
assert.Greater(t, statsBefore["total_requests"].(int64), int64(0))
// Reset
rc.Reset()
statsAfter := rc.GetStats()
assert.Equal(t, int64(0), statsAfter["total_requests"])
assert.Equal(t, int64(0), statsAfter["primary_requests"])
assert.Equal(t, int64(0), statsAfter["coalesced_requests"])
}
func TestRequestCoalescer_RaceCondition(t *testing.T) {
rc := NewRequestCoalescer(true, libpack_logger.New(), nil)
var executionCount atomic.Int32
fn := func() (*CoalescedResponse, error) {
executionCount.Add(1)
time.Sleep(5 * time.Millisecond)
return &CoalescedResponse{Body: []byte("response")}, nil
}
// Launch many concurrent requests in waves
waves := 5
requestsPerWave := 20
for wave := 0; wave < waves; wave++ {
var wg sync.WaitGroup
wg.Add(requestsPerWave)
for i := 0; i < requestsPerWave; i++ {
go func() {
defer wg.Done()
rc.Do("race-key", fn)
}()
}
wg.Wait()
time.Sleep(10 * time.Millisecond) // Small delay between waves
}
// Execution count should be much less than total requests
totalRequests := waves * requestsPerWave
assert.Less(t, int(executionCount.Load()), totalRequests)
stats := rc.GetStats()
assert.Equal(t, int64(totalRequests), stats["total_requests"])
}
func TestRequestCoalescer_BackendSavingsCalculation(t *testing.T) {
tests := []struct {
name string
totalRequests int64
coalescedRequests int64
expectedSavings float64
}{
{
name: "50% savings",
totalRequests: 100,
coalescedRequests: 50,
expectedSavings: 100.0, // 50 coalesced / 50 primary = 100%
},
{
name: "90% savings",
totalRequests: 100,
coalescedRequests: 90,
expectedSavings: 900.0, // 90 coalesced / 10 primary = 900%
},
{
name: "no savings",
totalRequests: 100,
coalescedRequests: 0,
expectedSavings: 0.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rc := NewRequestCoalescer(true, libpack_logger.New(), nil)
rc.totalRequests.Store(tt.totalRequests)
rc.coalescedRequests.Store(tt.coalescedRequests)
stats := rc.GetStats()
savings := stats["backend_savings_pct"].(float64)
assert.InDelta(t, tt.expectedSavings, savings, 0.1)
})
}
}
func TestRequestCoalescer_GlobalInstance(t *testing.T) {
rc := InitializeRequestCoalescer(true, libpack_logger.New(), nil)
assert.NotNil(t, rc)
// Should return the same instance
rc2 := GetRequestCoalescer()
assert.Equal(t, rc, rc2)
}
func TestMin(t *testing.T) {
tests := []struct {
a int
b int
expected int
}{
{a: 5, b: 10, expected: 5},
{a: 10, b: 5, expected: 5},
{a: 5, b: 5, expected: 5},
{a: 0, b: 10, expected: 0},
{a: -5, b: 5, expected: -5},
}
for _, tt := range tests {
result := min(tt.a, tt.b)
assert.Equal(t, tt.expected, result)
}
}
// TestRequestCoalescer_PanicSafety verifies that when the primary fn() panics,
// the coalescer still releases waiters and clears the inflight key instead of
// permanently deadlocking that key (waiters hang on wg.Wait and the entry
// leaks).
func TestRequestCoalescer_PanicSafety(t *testing.T) {
rc := NewRequestCoalescer(true, nil, nil)
// First call's fn() panics. Recover in a goroutine to emulate the
// caller catching/handling the panic.
panicDone := make(chan struct{})
go func() {
defer close(panicDone)
defer func() { _ = recover() }()
_, _ = rc.Do("key", func() (*CoalescedResponse, error) {
panic("boom")
})
}()
<-panicDone
// A subsequent call for the same key must complete (not hang), proving the
// key was released and the entry dropped.
done := make(chan struct{})
go func() {
defer close(done)
resp, err := rc.Do("key", func() (*CoalescedResponse, error) {
return &CoalescedResponse{StatusCode: 200}, nil
})
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("coalescer key still blocked after primary fn() panic")
}
}
// TestRequestCoalescer_PanicSafety_WaitersGetError verifies that when the
// primary (in-flight owner) request panics, coalesced waiters receive a
// non-nil error instead of (nil, nil). A silent (nil, nil) response lets the
// consumer fall through to a blank HTTP 200 for every waiter instead of
// surfacing the failure. It also verifies the panic still propagates to the
// primary caller unchanged.
func TestRequestCoalescer_PanicSafety_WaitersGetError(t *testing.T) {
rc := NewRequestCoalescer(true, nil, nil)
const numWaiters = 5
const panicKey = "panic-key"
primaryStarted := make(chan struct{})
var primaryStartedOnce sync.Once
releasePrimary := make(chan struct{})
// close(primaryStarted) must be idempotent: if a waiter is scheduled so
// late that it misses the coalescing window below (e.g. after the
// primary's panic-recovery defer has already deleted the inflight map
// entry for panicKey), rc.Do treats it as a brand-new primary and
// re-invokes fn. Without sync.Once, that second invocation would call
// close on an already-closed channel and panic unrecovered in that
// waiter's goroutine, killing the whole test binary. The deterministic
// gate below is what actually prevents that scenario; this is a
// belt-and-braces guard against it.
fn := func() (*CoalescedResponse, error) {
primaryStartedOnce.Do(func() { close(primaryStarted) })
<-releasePrimary
panic("boom")
}
// Launch the primary request first so subsequent calls coalesce onto it.
primaryPanicked := make(chan any, 1)
primaryDone := make(chan struct{})
go func() {
defer close(primaryDone)
defer func() {
primaryPanicked <- recover()
}()
_, _ = rc.Do(panicKey, fn)
}()
<-primaryStarted
// Launch waiters that coalesce onto the in-flight (panicking) primary.
var wg sync.WaitGroup
wg.Add(numWaiters)
responses := make([]*CoalescedResponse, numWaiters)
errs := make([]error, numWaiters)
for i := 0; i < numWaiters; i++ {
go func(idx int) {
defer wg.Done()
responses[idx], errs[idx] = rc.Do(panicKey, fn)
}(i)
}
// Deterministically wait until every waiter has coalesced onto the
// primary's inflightRequest before releasing the panic, instead of a
// fixed sleep. rc.Do increments the shared inflightRequest.waiters
// counter synchronously, before it blocks on wg.Wait(), so observing
// numWaiters+1 (the primary plus every coalesced waiter) proves each
// waiter goroutine has already registered against this exact in-flight
// entry - even if the entry is deleted from rc.inflight moments later,
// the waiters already hold a reference to it. A fixed sleep cannot make
// that guarantee: a waiter scheduled late enough would miss the window,
// see no in-flight entry, and become a new primary that re-runs fn.
deadline := time.Now().Add(3 * time.Second)
for {
existing, ok := rc.inflight.Load(panicKey)
if ok && existing.(*inflightRequest).waiters.Load() >= int32(numWaiters+1) {
break
}
if time.Now().After(deadline) {
t.Fatal("waiters did not coalesce onto the primary within deadline")
}
time.Sleep(time.Millisecond)
}
close(releasePrimary)
wg.Wait()
<-primaryDone
// The primary caller's own panic semantics are unchanged: the panic
// propagates out of rc.Do rather than being swallowed.
recovered := <-primaryPanicked
assert.NotNil(t, recovered, "panic must propagate to the primary caller")
assert.Equal(t, "boom", recovered)
// Every coalesced waiter must observe a non-nil error, never the silent
// (nil, nil) that would cause a blank HTTP 200 downstream.
for i := 0; i < numWaiters; i++ {
assert.NoError(t, errs[i], "rc.Do itself must not return an error")
if assert.NotNil(t, responses[i], "waiter %d must receive a non-nil response", i) {
assert.Error(t, responses[i].Err, "waiter %d must receive a non-nil error", i)
assert.Contains(t, responses[i].Err.Error(), "boom")
}
}
}