blob: ea6833f4839504e2c0ca0872190b148393815411 [file]
package api
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.skia.org/infra/go/paramtools"
"go.skia.org/infra/go/query"
"go.skia.org/infra/perf/go/tracestore"
"go.skia.org/infra/perf/go/types"
)
type mockTraceStore struct {
tracestore.TraceStore
mock.Mock
}
func (m *mockTraceStore) GetLatestTile(ctx context.Context) (types.TileNumber, error) {
args := m.Called(ctx)
return args.Get(0).(types.TileNumber), args.Error(1)
}
func (m *mockTraceStore) GetWasmCache(ctx context.Context, tileNumber types.TileNumber, ps paramtools.ReadOnlyParamSet) (*tracestore.WasmCacheData, error) {
args := m.Called(ctx, tileNumber, ps)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*tracestore.WasmCacheData), args.Error(1)
}
type mockPsRefresher struct {
mock.Mock
}
func (m *mockPsRefresher) GetAll() paramtools.ReadOnlyParamSet {
args := m.Called()
return args.Get(0).(paramtools.ReadOnlyParamSet)
}
func (m *mockPsRefresher) GetParamSetForQuery(ctx context.Context, q *query.Query, values url.Values) (int64, paramtools.ParamSet, error) {
args := m.Called(ctx, q, values)
return args.Get(0).(int64), args.Get(1).(paramtools.ParamSet), args.Error(2)
}
func (m *mockPsRefresher) Start(period time.Duration) error {
args := m.Called(period)
return args.Error(0)
}
func TestWasmApi_Handlers(t *testing.T) {
ts := &mockTraceStore{}
ps := &mockPsRefresher{}
cacheDir, err := os.MkdirTemp("", "wasm_cache_test")
require.NoError(t, err)
defer func() {
_ = os.RemoveAll(cacheDir)
}()
api := NewWasmApi(ts, ps, cacheDir)
ts.On("GetLatestTile", mock.Anything).Return(types.TileNumber(1), nil)
paramSet := paramtools.ParamSet{"config": []string{"8888"}}
ps.On("GetAll").Return(paramSet.Freeze())
cacheData := &tracestore.WasmCacheData{
Meta: []byte(`{"version":"123","stride":8,"count":1}`),
Params: []byte(`[{"id":1,"key":"config","value":"8888"}]`),
Traces: []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, // 8 uint16s
}
ts.On("GetWasmCache", mock.Anything, types.TileNumber(1), mock.Anything).Return(cacheData, nil)
// Test meta.json
{
req := httptest.NewRequest("GET", "/_/wasm/meta.json", nil)
w := httptest.NewRecorder()
api.metaHandler(w, req)
require.Equal(t, http.StatusOK, w.Result().StatusCode)
require.Equal(t, "application/json", w.Result().Header.Get("Content-Type"))
require.Equal(t, cacheData.Meta, w.Body.Bytes())
}
// Test params.json
{
req := httptest.NewRequest("GET", "/_/wasm/params.json", nil)
w := httptest.NewRecorder()
api.paramsHandler(w, req)
require.Equal(t, http.StatusOK, w.Result().StatusCode)
require.Equal(t, "application/json", w.Result().Header.Get("Content-Type"))
require.Equal(t, cacheData.Params, w.Body.Bytes())
}
// Test traces.bin
{
req := httptest.NewRequest("GET", "/_/wasm/traces.bin", nil)
w := httptest.NewRecorder()
api.tracesHandler(w, req)
require.Equal(t, http.StatusOK, w.Result().StatusCode)
require.Equal(t, "application/octet-stream", w.Result().Header.Get("Content-Type"))
require.Equal(t, cacheData.Traces, w.Body.Bytes())
}
}
func TestWasmApi_StaleWhileRevalidate(t *testing.T) {
ts := &mockTraceStore{}
ps := &mockPsRefresher{}
cacheDir, err := os.MkdirTemp("", "wasm_cache_test_swr")
require.NoError(t, err)
defer func() {
_ = os.RemoveAll(cacheDir)
}()
api := NewWasmApi(ts, ps, cacheDir)
api.defaultCacheTTL = 10 * time.Millisecond
// --- 1. Initial Synchronous Populate (Tile 1) ---
ts.On("GetLatestTile", mock.Anything).Return(types.TileNumber(1), nil).Once()
paramSet := paramtools.ParamSet{"config": []string{"8888"}}
ps.On("GetAll").Return(paramSet.Freeze())
cacheData1 := &tracestore.WasmCacheData{
Meta: []byte(`{"version":"123","stride":8,"count":1}`),
Params: []byte(`[{"id":1,"key":"config","value":"8888"}]`),
Traces: []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
}
ts.On("GetWasmCache", mock.Anything, types.TileNumber(1), mock.Anything).Return(cacheData1, nil).Once()
// Initial request blocks and populates cache
req := httptest.NewRequest("GET", "/_/wasm/meta.json", nil)
w := httptest.NewRecorder()
api.metaHandler(w, req)
require.Equal(t, http.StatusOK, w.Result().StatusCode)
require.Equal(t, cacheData1.Meta, w.Body.Bytes())
// --- 2. Fresh Cache Request (Tile 1) ---
// Should return instantly from memory, no DB mocks should be called (Once mocks would fail if called)
w = httptest.NewRecorder()
api.metaHandler(w, req)
require.Equal(t, http.StatusOK, w.Result().StatusCode)
require.Equal(t, cacheData1.Meta, w.Body.Bytes())
// --- 3. Stale Cache Request (Tile 2 available) ---
// Make it stale by sleeping
time.Sleep(15 * time.Millisecond)
// Setup mocks for the background update to Tile 2
ts.On("GetLatestTile", mock.Anything).Return(types.TileNumber(2), nil).Once()
cacheData2 := &tracestore.WasmCacheData{
Meta: []byte(`{"version":"456","stride":8,"count":1}`),
Params: []byte(`[{"id":1,"key":"config","value":"8888"}]`),
Traces: []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
}
ts.On("GetWasmCache", mock.Anything, types.TileNumber(2), mock.Anything).Return(cacheData2, nil).Once()
// This request should return STALE data (cacheData1) instantly,
// and trigger the background update to Tile 2.
w = httptest.NewRecorder()
api.metaHandler(w, req)
require.Equal(t, http.StatusOK, w.Result().StatusCode)
require.Equal(t, cacheData1.Meta, w.Body.Bytes()) // Still got old data
// Wait for background update to complete by polling
success := false
for start := time.Now(); time.Since(start) < 2*time.Second; {
c := api.getCache()
if c != nil && string(c.meta) == string(cacheData2.Meta) {
success = true
break
}
time.Sleep(10 * time.Millisecond)
}
require.True(t, success, "Cache was not updated in background")
// --- 4. Request after Background Update ---
// Should now return the new data (cacheData2)
w = httptest.NewRecorder()
api.metaHandler(w, req)
require.Equal(t, http.StatusOK, w.Result().StatusCode)
require.Equal(t, cacheData2.Meta, w.Body.Bytes()) // Got new data!
}
func TestWasmApi_SingleFlightUpdates(t *testing.T) {
ts := &mockTraceStore{}
ps := &mockPsRefresher{}
cacheDir, err := os.MkdirTemp("", "wasm_cache_test_sf")
require.NoError(t, err)
defer func() {
_ = os.RemoveAll(cacheDir)
}()
api := NewWasmApi(ts, ps, cacheDir)
api.defaultCacheTTL = 10 * time.Millisecond
// Populate initially
ts.On("GetLatestTile", mock.Anything).Return(types.TileNumber(1), nil).Once()
paramSet := paramtools.ParamSet{"config": []string{"8888"}}
ps.On("GetAll").Return(paramSet.Freeze())
cacheData1 := &tracestore.WasmCacheData{
Meta: []byte(`{"version":"123","stride":8,"count":1}`),
Params: []byte(`[{"id":1,"key":"config","value":"8888"}]`),
Traces: []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
}
ts.On("GetWasmCache", mock.Anything, types.TileNumber(1), mock.Anything).Return(cacheData1, nil).Once()
req := httptest.NewRequest("GET", "/_/wasm/meta.json", nil)
w := httptest.NewRecorder()
api.metaHandler(w, req)
require.Equal(t, cacheData1.Meta, w.Body.Bytes())
// Make stale
time.Sleep(15 * time.Millisecond)
// Setup background update mock that blocks
blockChan := make(chan struct{})
ts.On("GetLatestTile", mock.Anything).Return(types.TileNumber(2), nil).Run(func(args mock.Arguments) {
<-blockChan // Block here
}).Once() // Crucial: Only Once! If a second update is triggered, it will panic because no mock matches.
// Trigger first stale request (starts background update, which blocks)
w1 := httptest.NewRecorder()
api.metaHandler(w1, req)
require.Equal(t, cacheData1.Meta, w1.Body.Bytes()) // Returns stale instantly
// Trigger multiple concurrent requests.
// They should all return stale data instantly and NOT trigger new updates.
// If they tried to start a new update, they would call GetLatestTile again,
// which would panic/fail the test because we only mocked it Once().
for i := 0; i < 5; i++ {
w2 := httptest.NewRecorder()
api.metaHandler(w2, req)
require.Equal(t, cacheData1.Meta, w2.Body.Bytes())
}
// Unblock the background update
close(blockChan)
// We need to mock GetWasmCache for Tile 2 because the blocked update will now proceed to it.
cacheData2 := &tracestore.WasmCacheData{
Meta: []byte(`{"version":"456","stride":8,"count":1}`),
Params: []byte(`[{"id":1,"key":"config","value":"8888"}]`),
Traces: []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
}
ts.On("GetWasmCache", mock.Anything, types.TileNumber(2), mock.Anything).Return(cacheData2, nil).Once()
// Wait for update to finish
success := false
for start := time.Now(); time.Since(start) < 2*time.Second; {
c := api.getCache()
if c != nil && string(c.meta) == string(cacheData2.Meta) {
success = true
break
}
time.Sleep(10 * time.Millisecond)
}
require.True(t, success, "Cache was not updated")
}