[perf] Implement results.Loader interface.

Bug: skia: 10844
Change-Id: I4ff519175ca8a05e542904604b7202b51bd21ae6
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/329219
Commit-Queue: Joe Gregorio <jcgregorio@google.com>
Reviewed-by: Kevin Lubick <kjlubick@google.com>
diff --git a/perf/go/trybot/results/dfloader/dfloader.go b/perf/go/trybot/results/dfloader/dfloader.go
new file mode 100644
index 0000000..1ec4ff4
--- /dev/null
+++ b/perf/go/trybot/results/dfloader/dfloader.go
@@ -0,0 +1,147 @@
+// Package dfloader implements results.Loader using a DataFrameBuilder.
+package dfloader
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"go.skia.org/infra/go/paramtools"
+	"go.skia.org/infra/go/query"
+	"go.skia.org/infra/go/skerr"
+	"go.skia.org/infra/go/sklog"
+	"go.skia.org/infra/go/vec32"
+	"go.skia.org/infra/perf/go/dataframe"
+	perfgit "go.skia.org/infra/perf/go/git"
+	"go.skia.org/infra/perf/go/trybot/results"
+	"go.skia.org/infra/perf/go/trybot/store"
+	"go.skia.org/infra/perf/go/types"
+)
+
+// TraceHistorySize is the number of points we load for each trace.
+const TraceHistorySize = 20
+
+// ErrQueryMustNotBeEmpty is returned if an empty query is passed in the TryBotRequest.
+var ErrQueryMustNotBeEmpty = fmt.Errorf("Query must not be empty.")
+
+// Loader implements results.Loader.
+type Loader struct {
+	dfb   dataframe.DataFrameBuilder
+	store store.TryBotStore
+	git   *perfgit.Git
+}
+
+// New returns a new Loader instance.
+func New(dfb dataframe.DataFrameBuilder, store store.TryBotStore, git *perfgit.Git) Loader {
+	return Loader{
+		dfb:   dfb,
+		store: store,
+		git:   git,
+	}
+}
+
+// Load implements the results.Loader interface.
+func (l *Loader) Load(ctx context.Context, request results.TryBotRequest, progress types.Progress) (results.TryBotResponse, error) {
+	timestamp := time.Now()
+	if request.Kind == results.Commit {
+		commit, err := l.git.CommitFromCommitNumber(ctx, request.CommitNumber)
+		if err != nil {
+			return results.TryBotResponse{}, skerr.Wrap(err)
+		}
+		timestamp = time.Unix(commit.Timestamp, 0)
+	}
+
+	q, err := query.NewFromString(request.Query)
+	if err != nil {
+		return results.TryBotResponse{}, skerr.Wrap(err)
+	}
+	if request.Kind == results.Commit && q.Empty() {
+		return results.TryBotResponse{}, ErrQueryMustNotBeEmpty
+	}
+
+	var df *dataframe.DataFrame
+	rebuildParamSet := false
+	if request.Kind == results.Commit {
+		// Always pull in TraceHistorySize+1 trace values. The TraceHistorySize
+		// represents the history of the trace, and the TraceHistorySize+1 point
+		// represents either the commit under inspection or a placeholder for the
+		// trybot value, which lets us avoid a second memory allocation, which we'd
+		// get if we had only queried for TraceHistorySize values.
+		df, err = l.dfb.NewNFromQuery(ctx, timestamp, q, TraceHistorySize+1, progress)
+		if err != nil {
+			return results.TryBotResponse{}, skerr.Wrap(err)
+		}
+	} else {
+		// Load the trybot results.
+		storeResults, err := l.store.Get(ctx, request.CL, request.PatchNumber)
+		if err != nil {
+			return results.TryBotResponse{}, skerr.Wrap(err)
+		}
+		traceNames := make([]string, 0, len(storeResults))
+		for _, results := range storeResults {
+			traceNames = append(traceNames, results.TraceName)
+		}
+		// Query for all traces that match up with the trybot results.
+		df, err = l.dfb.NewNFromKeys(ctx, timestamp, traceNames, TraceHistorySize+1, progress)
+		if err != nil {
+			return results.TryBotResponse{}, skerr.Wrap(err)
+		}
+		// Replace the last value in each trace with the trybot result.
+
+		for _, results := range storeResults {
+			values, ok := df.TraceSet[results.TraceName]
+			if !ok {
+				delete(df.TraceSet, results.TraceName)
+				// At this point the df.ParamSet is no longer valid and we should rebuild it.
+				rebuildParamSet = true
+				continue
+			}
+			values[len(values)-1] = results.Value
+		}
+	}
+
+	ret := results.TryBotResponse{}
+	ret.Header = df.Header
+	if request.Kind == results.TryBot && len(ret.Header) > 0 {
+		ret.Header[len(ret.Header)-1].Offset = types.BadCommitNumber
+	}
+	ret.ParamSet = df.ParamSet
+
+	res := make([]results.TryBotResult, 0, len(df.TraceSet))
+	// Loop over all the traces and parse the key into params and pass the
+	// values to vec32.StdDevRatio.
+	for traceName, values := range df.TraceSet {
+		params, err := query.ParseKey(traceName)
+		if err != nil {
+			sklog.Errorf("Failed to parse %q: %s", traceName, err)
+			rebuildParamSet = true
+			continue
+		}
+		stddevRatio, median, lower, upper, err := vec32.StdDevRatio(values)
+		if err != nil {
+			rebuildParamSet = true
+			continue
+		}
+		res = append(res, results.TryBotResult{
+			Params:      params,
+			Median:      median,
+			Lower:       lower,
+			Upper:       upper,
+			StdDevRatio: stddevRatio,
+			Values:      values,
+		})
+	}
+	ret.Results = res
+	if rebuildParamSet {
+		ps := paramtools.NewParamSet()
+		for _, res := range ret.Results {
+			ps.AddParams(res.Params)
+		}
+		ret.ParamSet = ps
+	}
+
+	return ret, nil
+}
+
+// Assert that we fulfill the interface.
+var _ results.Loader = (*Loader)(nil)
diff --git a/perf/go/trybot/results/dfloader/dfloader_test.go b/perf/go/trybot/results/dfloader/dfloader_test.go
new file mode 100644
index 0000000..5a6fd71
--- /dev/null
+++ b/perf/go/trybot/results/dfloader/dfloader_test.go
@@ -0,0 +1,404 @@
+// Package dfloader implements results.Loader using a DataFrameBuilder.
+package dfloader
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
+	"go.skia.org/infra/go/paramtools"
+	"go.skia.org/infra/go/testutils/unittest"
+	"go.skia.org/infra/go/vec32"
+	"go.skia.org/infra/perf/go/dataframe"
+	"go.skia.org/infra/perf/go/dataframe/mocks"
+	perfgit "go.skia.org/infra/perf/go/git"
+	"go.skia.org/infra/perf/go/git/gittest"
+	"go.skia.org/infra/perf/go/trybot/results"
+	"go.skia.org/infra/perf/go/trybot/store"
+	storeMocks "go.skia.org/infra/perf/go/trybot/store/mocks"
+	"go.skia.org/infra/perf/go/types"
+)
+
+var errFromMock = fmt.Errorf("MockError")
+
+const testTileSize = 4
+
+const e = vec32.MissingDataSentinel
+
+func setupForTest(t *testing.T) (context.Context, *perfgit.Git, []string) {
+	ctx, db, _, hashes, instanceConfig, _, gitCleanup := gittest.NewForTest(t)
+	instanceConfig.DataStoreConfig.TileSize = testTileSize
+	g, err := perfgit.New(ctx, true, db, instanceConfig)
+	require.NoError(t, err)
+	t.Cleanup(gitCleanup)
+	return ctx, g, hashes
+}
+
+func TestLoader_UnknownCommit_ReturnsError(t *testing.T) {
+	unittest.LargeTest(t)
+	ctx, g, _ := setupForTest(t)
+
+	dfb := &mocks.DataFrameBuilder{}
+	storeMock := &storeMocks.TryBotStore{}
+	loader := New(dfb, storeMock, g)
+	request := results.TryBotRequest{
+		Kind:         results.Commit,
+		Query:        "config=8888",
+		CommitNumber: 200, // Not a valid commit.
+	}
+	_, err := loader.Load(ctx, request, nil)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "Failed to get details for CommitNumber")
+}
+
+func TestLoader_InvalidQuery_ReturnsError(t *testing.T) {
+	unittest.LargeTest(t)
+	ctx, g, _ := setupForTest(t)
+
+	dfb := &mocks.DataFrameBuilder{}
+	storeMock := &storeMocks.TryBotStore{}
+	loader := New(dfb, storeMock, g)
+	request := results.TryBotRequest{
+		Kind:         results.Commit,
+		CommitNumber: 2, // Valid commit that gittest.NewForTest has added.
+		Query:        "%gh&%ij",
+	}
+	_, err := loader.Load(ctx, request, nil)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "invalid URL")
+}
+
+func TestLoader_EmptyQuery_LoadReturnsError(t *testing.T) {
+	unittest.LargeTest(t)
+	ctx, g, _ := setupForTest(t)
+
+	dfb := &mocks.DataFrameBuilder{}
+	storeMock := &storeMocks.TryBotStore{}
+	loader := New(dfb, storeMock, g)
+	request := results.TryBotRequest{
+		Kind:         results.Commit,
+		Query:        "",
+		CommitNumber: 2, // Valid commit that gittest.NewForTest has added.
+	}
+	_, err := loader.Load(ctx, request, nil)
+	require.Error(t, err)
+	assert.Equal(t, ErrQueryMustNotBeEmpty, err)
+}
+
+func TestLoader_LoadWithDataFrameBuilderThatErrorsNewNFromQuery_LoadReturnsError(t *testing.T) {
+	unittest.LargeTest(t)
+	ctx, g, _ := setupForTest(t)
+
+	dfb := &mocks.DataFrameBuilder{}
+	dfb.On("NewNFromQuery", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errFromMock)
+
+	storeMock := &storeMocks.TryBotStore{}
+	loader := New(dfb, storeMock, g)
+	request := results.TryBotRequest{
+		Kind:         results.Commit,
+		Query:        "config=8888",
+		CommitNumber: 2, // Valid commit that gittest.NewForTest has added.
+	}
+	_, err := loader.Load(ctx, request, nil)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), errFromMock.Error())
+}
+
+func TestLoader_LoadWithTryBotStoreThatErrors_LoadReturnsError(t *testing.T) {
+	unittest.LargeTest(t)
+	ctx, g, _ := setupForTest(t)
+
+	dfb := &mocks.DataFrameBuilder{}
+	storeMock := &storeMocks.TryBotStore{}
+	const cl = types.CL("123456")
+	const patch = int(1)
+	storeMock.On("Get", mock.Anything, cl, patch).Return(nil, errFromMock)
+
+	loader := New(dfb, storeMock, g)
+	request := results.TryBotRequest{
+		Kind:        results.TryBot,
+		CL:          cl,
+		PatchNumber: patch,
+	}
+	_, err := loader.Load(ctx, request, nil)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), errFromMock.Error())
+}
+
+func TestLoader_LoadDataFrameBuilderThatErrorsNewNFromKeys_LoadReturnsError(t *testing.T) {
+	unittest.LargeTest(t)
+	ctx, g, _ := setupForTest(t)
+
+	dfb := &mocks.DataFrameBuilder{}
+	dfb.On("NewNFromKeys", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errFromMock)
+
+	storeMock := &storeMocks.TryBotStore{}
+	const cl = types.CL("123456")
+	const patch = int(1)
+	storeMock.On("Get", mock.Anything, cl, patch).Return(nil, nil)
+
+	loader := New(dfb, storeMock, g)
+	request := results.TryBotRequest{
+		Kind:        results.TryBot,
+		CL:          cl,
+		PatchNumber: patch,
+	}
+	_, err := loader.Load(ctx, request, nil)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), errFromMock.Error())
+}
+
+func TestLoader_ZeroLengthResponseFromTryBotStore_LoadReturnsSuccess(t *testing.T) {
+	unittest.LargeTest(t)
+	ctx, g, _ := setupForTest(t)
+
+	dfb := &mocks.DataFrameBuilder{}
+	df := &dataframe.DataFrame{
+		Header:   []*dataframe.ColumnHeader{},
+		ParamSet: paramtools.ParamSet{},
+	}
+	dfb.On("NewNFromKeys", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(df, nil)
+
+	storeMock := &storeMocks.TryBotStore{}
+	const cl = types.CL("123456")
+	const patch = int(1)
+	storeMock.On("Get", mock.Anything, cl, patch).Return(nil, nil)
+
+	loader := New(dfb, storeMock, g)
+	request := results.TryBotRequest{
+		Kind:        results.TryBot,
+		CL:          cl,
+		PatchNumber: patch,
+	}
+	resp, err := loader.Load(ctx, request, nil)
+	require.NoError(t, err)
+	assert.Empty(t, resp.Results)
+	assert.Empty(t, resp.Header)
+	assert.Empty(t, resp.ParamSet)
+}
+
+func TestLoader_OneTraceTryBotHappyPath_LoadReturnsSuccess(t *testing.T) {
+	unittest.LargeTest(t)
+	ctx, g, _ := setupForTest(t)
+
+	dfb := &mocks.DataFrameBuilder{}
+	df := &dataframe.DataFrame{
+		Header: []*dataframe.ColumnHeader{
+			{Offset: 0, Timestamp: gittest.StartTime.Unix()},
+			{Offset: 1, Timestamp: gittest.StartTime.Unix() + 1},
+			{Offset: 2, Timestamp: gittest.StartTime.Unix() + 2},
+			{Offset: 3, Timestamp: gittest.StartTime.Unix() + 3},
+			{Offset: 4, Timestamp: gittest.StartTime.Unix() + 4},
+			{Offset: 5, Timestamp: gittest.StartTime.Unix() + 5},
+			{Offset: 6, Timestamp: gittest.StartTime.Unix() + 6},
+			{Offset: 7, Timestamp: gittest.StartTime.Unix() + 7},
+			{Offset: 8, Timestamp: gittest.StartTime.Unix() + 8},
+			{Offset: 9, Timestamp: gittest.StartTime.Unix() + 9},
+		},
+		ParamSet: paramtools.ParamSet{"config": []string{"gpu"}},
+		TraceSet: types.TraceSet{
+			",config=gpu,": []float32{1, 1, 0.9, 0.9, 1.1, 1.1, 0.8, 0.8, 1.2, 1.2},
+		},
+	}
+	dfb.On("NewNFromKeys", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(df, nil)
+
+	storeMock := &storeMocks.TryBotStore{}
+	const cl = types.CL("123456")
+	const patch = int(1)
+	storeResults := []store.GetResult{
+		{
+			TraceName: ",config=gpu,",
+			Value:     3.0,
+		},
+	}
+	storeMock.On("Get", mock.Anything, cl, patch).Return(storeResults, nil)
+
+	loader := New(dfb, storeMock, g)
+	request := results.TryBotRequest{
+		Kind:        results.TryBot,
+		CL:          cl,
+		PatchNumber: patch,
+	}
+	resp, err := loader.Load(ctx, request, nil)
+	require.NoError(t, err)
+	expected := results.TryBotResult{
+		Params:      paramtools.Params{"config": "gpu"},
+		Median:      1,
+		Lower:       0.1825742,
+		Upper:       0.122474514,
+		StdDevRatio: 16.329927,
+		Values:      []float32{1, 1, 0.9, 0.9, 1.1, 1.1, 0.8, 0.8, 1.2, 3},
+	}
+	assert.Len(t, resp.Results, 1)
+	assert.Equal(t, expected, resp.Results[0])
+	assert.Equal(t, types.BadCommitNumber, df.Header[len(df.Header)-1].Offset)
+	assert.Equal(t, df.ParamSet, resp.ParamSet)
+}
+
+func TestLoader_UnknownTracesAreIgnored_LoadReturnsSuccess(t *testing.T) {
+	unittest.LargeTest(t)
+	ctx, g, _ := setupForTest(t)
+
+	// The trybotStore has two results, but there are trace values for only one of those results (config=8888).
+	dfb := &mocks.DataFrameBuilder{}
+	df := &dataframe.DataFrame{
+		Header: []*dataframe.ColumnHeader{
+			{Offset: 0, Timestamp: gittest.StartTime.Unix()},
+			{Offset: 1, Timestamp: gittest.StartTime.Unix() + 1},
+			{Offset: 2, Timestamp: gittest.StartTime.Unix() + 2},
+			{Offset: 3, Timestamp: gittest.StartTime.Unix() + 3},
+			{Offset: 4, Timestamp: gittest.StartTime.Unix() + 4},
+			{Offset: 5, Timestamp: gittest.StartTime.Unix() + 5},
+			{Offset: 6, Timestamp: gittest.StartTime.Unix() + 6},
+			{Offset: 7, Timestamp: gittest.StartTime.Unix() + 7},
+			{Offset: 8, Timestamp: gittest.StartTime.Unix() + 8},
+			{Offset: 9, Timestamp: gittest.StartTime.Unix() + 9},
+		},
+		ParamSet: paramtools.ParamSet{"config": []string{"565", "8888"}},
+		TraceSet: types.TraceSet{
+			",config=8888,": []float32{1, 1, 0.9, 0.9, 1.1, 1.1, 0.8, 0.8, 1.2, 1.2},
+		},
+	}
+	dfb.On("NewNFromKeys", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(df, nil)
+
+	storeMock := &storeMocks.TryBotStore{}
+	const cl = types.CL("123456")
+	const patch = int(1)
+	storeResults := []store.GetResult{
+		{
+			TraceName: ",config=8888,",
+			Value:     3.0,
+		},
+		{
+			TraceName: ",config=565,",
+			Value:     4.0,
+		},
+	}
+	storeMock.On("Get", mock.Anything, cl, patch).Return(storeResults, nil)
+
+	loader := New(dfb, storeMock, g)
+	request := results.TryBotRequest{
+		Kind:        results.TryBot,
+		CL:          cl,
+		PatchNumber: patch,
+	}
+	resp, err := loader.Load(ctx, request, nil)
+	require.NoError(t, err)
+	expected := results.TryBotResult{
+		Params:      paramtools.Params{"config": "8888"},
+		Median:      1,
+		Lower:       0.1825742,
+		Upper:       0.122474514,
+		StdDevRatio: 16.329927,
+		Values:      []float32{1, 1, 0.9, 0.9, 1.1, 1.1, 0.8, 0.8, 1.2, 3},
+	}
+	assert.Len(t, resp.Results, 1)
+	assert.Equal(t, expected, resp.Results[0])
+	assert.Equal(t, types.BadCommitNumber, df.Header[len(df.Header)-1].Offset)
+	assert.Equal(t, paramtools.ParamSet{"config": []string{"8888"}}, resp.ParamSet)
+}
+
+func TestLoader_InsufficientNonMissingDataSentinel_ResultIsSkipped(t *testing.T) {
+	unittest.LargeTest(t)
+	ctx, g, _ := setupForTest(t)
+
+	// The trybotStore has two results, but there are trace values for only one of those results (config=8888).
+	dfb := &mocks.DataFrameBuilder{}
+	df := &dataframe.DataFrame{
+		Header: []*dataframe.ColumnHeader{
+			{Offset: 0, Timestamp: gittest.StartTime.Unix()},
+			{Offset: 1, Timestamp: gittest.StartTime.Unix() + 1},
+			{Offset: 2, Timestamp: gittest.StartTime.Unix() + 2},
+			{Offset: 3, Timestamp: gittest.StartTime.Unix() + 3},
+			{Offset: 4, Timestamp: gittest.StartTime.Unix() + 4},
+			{Offset: 5, Timestamp: gittest.StartTime.Unix() + 5},
+			{Offset: 6, Timestamp: gittest.StartTime.Unix() + 6},
+			{Offset: 7, Timestamp: gittest.StartTime.Unix() + 7},
+			{Offset: 8, Timestamp: gittest.StartTime.Unix() + 8},
+			{Offset: 9, Timestamp: gittest.StartTime.Unix() + 9},
+		},
+		ParamSet: paramtools.ParamSet{"config": []string{"565", "8888"}},
+		TraceSet: types.TraceSet{
+			",config=8888,": []float32{1, 1, 0.9, 0.9, 1.1, 1.1, 0.8, 0.8, 1.2, 1.2},
+			",config=565,":  []float32{e, e, e, e, e, e, e, e, e, 1.2}, // Should be dropped from results since there isn't enough valid data.
+		},
+	}
+	dfb.On("NewNFromKeys", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(df, nil)
+
+	storeMock := &storeMocks.TryBotStore{}
+	const cl = types.CL("123456")
+	const patch = int(1)
+	storeResults := []store.GetResult{
+		{
+			TraceName: ",config=8888,",
+			Value:     3.0,
+		},
+		{
+			TraceName: ",config=565,",
+			Value:     4.0,
+		},
+	}
+	storeMock.On("Get", mock.Anything, cl, patch).Return(storeResults, nil)
+
+	loader := New(dfb, storeMock, g)
+	request := results.TryBotRequest{
+		Kind:        results.TryBot,
+		CL:          cl,
+		PatchNumber: patch,
+	}
+	resp, err := loader.Load(ctx, request, nil)
+	require.NoError(t, err)
+	expected := results.TryBotResult{
+		Params:      paramtools.Params{"config": "8888"},
+		Median:      1,
+		Lower:       0.1825742,
+		Upper:       0.122474514,
+		StdDevRatio: 16.329927,
+		Values:      []float32{1, 1, 0.9, 0.9, 1.1, 1.1, 0.8, 0.8, 1.2, 3},
+	}
+	assert.Len(t, resp.Results, 1)
+	assert.Equal(t, expected, resp.Results[0])
+	assert.Equal(t, types.BadCommitNumber, df.Header[len(df.Header)-1].Offset)
+	assert.Equal(t, paramtools.ParamSet{"config": []string{"8888"}}, resp.ParamSet)
+}
+
+func TestLoader_InvalidTraceKeysAreIgnored_LoadReturnsSuccess(t *testing.T) {
+	unittest.LargeTest(t)
+	ctx, g, _ := setupForTest(t)
+
+	dfb := &mocks.DataFrameBuilder{}
+	df := &dataframe.DataFrame{
+		Header:   []*dataframe.ColumnHeader{},
+		ParamSet: paramtools.ParamSet{},
+		TraceSet: types.TraceSet{
+			"this-isnt-a-valid-key": []float32{1, 1, 0.9, 0.9, 1.1, 1.1, 0.8, 0.8, 1.2, 1.2},
+		},
+	}
+	dfb.On("NewNFromKeys", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(df, nil)
+
+	storeMock := &storeMocks.TryBotStore{}
+	const cl = types.CL("123456")
+	const patch = int(1)
+	storeResults := []store.GetResult{
+		{
+			TraceName: ",config=gpu,",
+			Value:     3.0,
+		},
+	}
+	storeMock.On("Get", mock.Anything, cl, patch).Return(storeResults, nil)
+
+	loader := New(dfb, storeMock, g)
+	request := results.TryBotRequest{
+		Kind:        results.TryBot,
+		CL:          cl,
+		PatchNumber: patch,
+	}
+	resp, err := loader.Load(ctx, request, nil)
+	require.NoError(t, err)
+	assert.Empty(t, resp.Results)
+	assert.Empty(t, resp.Header)
+	assert.Empty(t, resp.ParamSet)
+}
diff --git a/perf/go/trybot/results/results.go b/perf/go/trybot/results/results.go
index f93cb9a..bdd0a21 100644
--- a/perf/go/trybot/results/results.go
+++ b/perf/go/trybot/results/results.go
@@ -5,6 +5,8 @@
 package results
 
 import (
+	"context"
+
 	"go.skia.org/infra/go/paramtools"
 	"go.skia.org/infra/perf/go/dataframe"
 	"go.skia.org/infra/perf/go/types"
@@ -35,6 +37,9 @@
 	// CL is the ID of the changelist to analyze. Only use if Kind is TryBot.
 	CL types.CL `json:"cl"`
 
+	// PatchNumber is the index of the patch.
+	PatchNumber int `json:"patch_number"`
+
 	// CommitNumber is the commit to analyze. Only use if Kind is Commit.
 	CommitNumber types.CommitNumber `json:"cid"`
 
@@ -61,12 +66,13 @@
 
 // TryBotResponse is the response sent to a TryBotRequest.
 type TryBotResponse struct {
-	Header  []dataframe.ColumnHeader
-	Results []TryBotResult
+	Header   []*dataframe.ColumnHeader `json:"header"`
+	Results  []TryBotResult            `json:"results"`
+	ParamSet paramtools.ParamSet       `json:"paramset"`
 }
 
 // Loader returns the data for the given TryBotRequest.
 type Loader interface {
 	// Load the TryBot results for the given TryBotRequest.
-	Load(TryBotRequest) (TryBotResponse, error)
+	Load(context.Context, TryBotRequest, types.Progress) (TryBotResponse, error)
 }