Skip to content

Commit 92108e6

Browse files
authored
feat(api): add queryable suites table populated from summary.json during indexing (#129)
Add a `suites` table with suite_hash, discovery_path, name (from metadata labels), tests_total, and indexed_at. The table is populated via UpsertSuite during indexRun when summary.json is available, and is queryable through the same PostgREST-style API as runs/test_stats. The Query Builder UI includes a new "suites" tab with column groups.
1 parent 030308d commit 92108e6

7 files changed

Lines changed: 184 additions & 3 deletions

File tree

pkg/api/handlers_index.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,32 @@ func (s *server) handleQueryTestStats(
199199
writeJSON(w, http.StatusOK, result)
200200
}
201201

202+
// handleQuerySuites handles PostgREST-style queries against the suites
203+
// table.
204+
func (s *server) handleQuerySuites(
205+
w http.ResponseWriter, r *http.Request,
206+
) {
207+
params, err := indexstore.ParseQueryParams(
208+
r.URL.Query(), indexstore.AllowedSuiteColumns(),
209+
)
210+
if err != nil {
211+
writeJSON(w, http.StatusBadRequest,
212+
errorResponse{err.Error()})
213+
214+
return
215+
}
216+
217+
result, err := s.indexStore.QuerySuites(r.Context(), params)
218+
if err != nil {
219+
writeJSON(w, http.StatusInternalServerError,
220+
errorResponse{"querying suites: " + err.Error()})
221+
222+
return
223+
}
224+
225+
writeJSON(w, http.StatusOK, result)
226+
}
227+
202228
// handleQueryTestStatsBlockLogs handles PostgREST-style queries against
203229
// the test_stats_block_logs table.
204230
func (s *server) handleQueryTestStatsBlockLogs(

pkg/api/indexer/indexer.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/ethpandaops/benchmarkoor/pkg/api/indexstore"
1212
"github.com/ethpandaops/benchmarkoor/pkg/api/storage"
13+
"github.com/ethpandaops/benchmarkoor/pkg/config"
1314
"github.com/ethpandaops/benchmarkoor/pkg/executor"
1415
"github.com/sirupsen/logrus"
1516
"golang.org/x/sync/errgroup"
@@ -375,14 +376,16 @@ func (idx *indexer) indexRun(
375376
return fmt.Errorf("building index entry: %w", err)
376377
}
377378

378-
// Override tests_total from suite summary when available.
379+
// Override tests_total from suite summary when available and
380+
// upsert the suite record.
379381
if entry.SuiteHash != "" {
380382
summaryData, sErr := idx.reader.GetSuiteFile(
381383
ctx, dp, entry.SuiteHash, "summary.json",
382384
)
383385
if sErr == nil && summaryData != nil {
384386
var summary struct {
385-
Tests json.RawMessage `json:"tests"`
387+
Tests json.RawMessage `json:"tests"`
388+
Metadata *config.MetadataConfig `json:"metadata"`
386389
}
387390

388391
if json.Unmarshal(summaryData, &summary) == nil {
@@ -392,6 +395,28 @@ func (idx *indexer) indexRun(
392395
len(tests) > 0 {
393396
entry.Tests.TestsTotal = len(tests)
394397
}
398+
399+
// Extract suite name from metadata labels.
400+
suiteName := ""
401+
if summary.Metadata != nil {
402+
if n, ok := summary.Metadata.Labels["name"]; ok {
403+
suiteName = n
404+
}
405+
}
406+
407+
suite := &indexstore.Suite{
408+
SuiteHash: entry.SuiteHash,
409+
DiscoveryPath: dp,
410+
Name: suiteName,
411+
TestsTotal: entry.Tests.TestsTotal,
412+
IndexedAt: time.Now().UTC(),
413+
}
414+
415+
if uErr := idx.store.UpsertSuite(ctx, suite); uErr != nil {
416+
idx.log.WithError(uErr).
417+
WithField("suite_hash", entry.SuiteHash).
418+
Warn("Failed to upsert suite")
419+
}
395420
}
396421
}
397422
}

pkg/api/indexstore/indexstore.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ type Store interface {
3535

3636
ListAllRuns(ctx context.Context) ([]Run, error)
3737

38+
UpsertSuite(ctx context.Context, suite *Suite) error
39+
3840
BulkInsertTestStatsBlockLogs(
3941
ctx context.Context, logs []*TestStatsBlockLog,
4042
) error
@@ -47,6 +49,9 @@ type Store interface {
4749
QueryTestStatsBlockLogs(
4850
ctx context.Context, params *QueryParams,
4951
) (*QueryResult, error)
52+
QuerySuites(
53+
ctx context.Context, params *QueryParams,
54+
) (*QueryResult, error)
5055
}
5156

5257
// Compile-time interface check.
@@ -122,6 +127,7 @@ func (s *store) Start(ctx context.Context) error {
122127
&Run{},
123128
&TestStat{},
124129
&TestStatsBlockLog{},
130+
&Suite{},
125131
); err != nil {
126132
return fmt.Errorf("running index migrations: %w", err)
127133
}
@@ -338,6 +344,19 @@ func (s *store) DeleteTestStatsBlockLogsForRun(
338344
return nil
339345
}
340346

347+
// UpsertSuite inserts or updates a suite record keyed by suite_hash.
348+
func (s *store) UpsertSuite(ctx context.Context, suite *Suite) error {
349+
result := s.db.WithContext(ctx).
350+
Where("suite_hash = ?", suite.SuiteHash).
351+
Assign(suite).
352+
FirstOrCreate(suite)
353+
if result.Error != nil {
354+
return fmt.Errorf("upserting suite: %w", result.Error)
355+
}
356+
357+
return nil
358+
}
359+
341360
// QueryRuns executes a flexible query against the runs table using the
342361
// validated QueryParams. It returns paginated results with a total count.
343362
func (s *store) QueryRuns(
@@ -462,6 +481,45 @@ func (s *store) QueryTestStatsBlockLogs(
462481
}, nil
463482
}
464483

484+
// QuerySuites executes a flexible query against the suites table using
485+
// the validated QueryParams. It returns paginated results with a total
486+
// count.
487+
func (s *store) QuerySuites(
488+
ctx context.Context, params *QueryParams,
489+
) (*QueryResult, error) {
490+
q := applyQuery(s.db.WithContext(ctx), &Suite{}, params)
491+
492+
// When select is specified, scan into maps so the JSON response
493+
// only contains the requested columns (no zero-valued extras).
494+
if len(params.Select) > 0 {
495+
return scanMaps(q, params)
496+
}
497+
498+
var total int64
499+
if err := q.Count(&total).Error; err != nil {
500+
return nil, fmt.Errorf("counting suites: %w", err)
501+
}
502+
503+
var suites []Suite
504+
if err := q.Offset(params.Offset).
505+
Limit(params.Limit).
506+
Find(&suites).Error; err != nil {
507+
return nil, fmt.Errorf("querying suites: %w", err)
508+
}
509+
510+
data := make([]SuiteResponse, 0, len(suites))
511+
for i := range suites {
512+
data = append(data, toSuiteResponse(&suites[i]))
513+
}
514+
515+
return &QueryResult{
516+
Data: data,
517+
Total: total,
518+
Limit: params.Limit,
519+
Offset: params.Offset,
520+
}, nil
521+
}
522+
465523
// scanMaps scans query results into []map[string]any so only the selected
466524
// columns appear in the JSON response.
467525
func scanMaps(

pkg/api/indexstore/query.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ var allowedTestStatColumns = map[string]bool{
9393
"test_resource_disk_write_iops": true,
9494
}
9595

96+
// allowedSuiteColumns lists columns that may be filtered, sorted, or selected
97+
// on the suites table.
98+
var allowedSuiteColumns = map[string]bool{
99+
"id": true,
100+
"suite_hash": true,
101+
"discovery_path": true,
102+
"name": true,
103+
"tests_total": true,
104+
"indexed_at": true,
105+
}
106+
96107
// allowedTestStatsBlockLogColumns lists columns that may be filtered, sorted,
97108
// or selected on the test_stats_block_logs table.
98109
var allowedTestStatsBlockLogColumns = map[string]bool{
@@ -276,6 +287,16 @@ type TestStatsBlockLogResponse struct {
276287
CacheCodeMissBytes int `json:"cache_code_miss_bytes"`
277288
}
278289

290+
// SuiteResponse is the JSON DTO for a suites row.
291+
type SuiteResponse struct {
292+
ID uint `json:"id"`
293+
SuiteHash string `json:"suite_hash"`
294+
DiscoveryPath string `json:"discovery_path"`
295+
Name string `json:"name"`
296+
TestsTotal int `json:"tests_total"`
297+
IndexedAt string `json:"indexed_at"`
298+
}
299+
279300
// AllowedRunColumns returns the set of queryable run columns.
280301
func AllowedRunColumns() map[string]bool {
281302
return allowedRunColumns
@@ -292,6 +313,11 @@ func AllowedTestStatsBlockLogColumns() map[string]bool {
292313
return allowedTestStatsBlockLogColumns
293314
}
294315

316+
// AllowedSuiteColumns returns the set of queryable suite columns.
317+
func AllowedSuiteColumns() map[string]bool {
318+
return allowedSuiteColumns
319+
}
320+
295321
// ParseQueryParams validates and parses raw URL query values against the
296322
// provided column whitelist. It returns an error for any invalid column,
297323
// operator, or parameter value.
@@ -611,3 +637,15 @@ func toTestStatsBlockLogResponse(l *TestStatsBlockLog) TestStatsBlockLogResponse
611637
CacheCodeMissBytes: l.CacheCodeMissBytes,
612638
}
613639
}
640+
641+
// toSuiteResponse converts a Suite model to its JSON DTO.
642+
func toSuiteResponse(s *Suite) SuiteResponse {
643+
return SuiteResponse{
644+
ID: s.ID,
645+
SuiteHash: s.SuiteHash,
646+
DiscoveryPath: s.DiscoveryPath,
647+
Name: s.Name,
648+
TestsTotal: s.TestsTotal,
649+
IndexedAt: s.IndexedAt.UTC().Format("2006-01-02T15:04:05Z"),
650+
}
651+
}

pkg/api/indexstore/suite.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package indexstore
2+
3+
import "time"
4+
5+
// Suite represents a unique benchmark suite discovered during indexing.
6+
type Suite struct {
7+
ID uint `gorm:"primaryKey"`
8+
SuiteHash string `gorm:"uniqueIndex;not null"`
9+
DiscoveryPath string `gorm:"not null"`
10+
Name string
11+
TestsTotal int
12+
IndexedAt time.Time
13+
}

pkg/api/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ func (s *server) buildRouter() http.Handler {
9090
s.handleQueryTestStats)
9191
r.Get("/test_stats_block_logs",
9292
s.handleQueryTestStatsBlockLogs)
93+
r.Get("/suites", s.handleQuerySuites)
9394
})
9495
})
9596
}

ui/src/pages/QueryBuilderPage.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ const TEST_STAT_COLUMN_GROUPS: ColumnGroup[] = [
4343
{ label: 'Timing', columns: ['run_start', 'run_end'] },
4444
]
4545

46+
const SUITES_COLUMN_GROUPS: ColumnGroup[] = [
47+
{ label: 'Identity', columns: ['id', 'suite_hash', 'discovery_path'] },
48+
{ label: 'Info', columns: ['name', 'tests_total'] },
49+
{ label: 'Timing', columns: ['indexed_at'] },
50+
]
51+
4652
const TEST_STATS_BLOCK_LOG_COLUMN_GROUPS: ColumnGroup[] = [
4753
{ label: 'Identity', columns: ['id', 'suite_hash', 'run_id', 'test_name', 'client'] },
4854
{ label: 'Block', columns: ['block_number', 'block_hash', 'block_gas_used', 'block_tx_count'] },
@@ -76,6 +82,7 @@ const TEST_STATS_BLOCK_LOG_COLUMN_GROUPS: ColumnGroup[] = [
7682
const RUNS_COLUMNS = RUNS_COLUMN_GROUPS.flatMap((g) => g.columns)
7783
const TEST_STAT_COLUMNS = TEST_STAT_COLUMN_GROUPS.flatMap((g) => g.columns)
7884
const TEST_STATS_BLOCK_LOG_COLUMNS = TEST_STATS_BLOCK_LOG_COLUMN_GROUPS.flatMap((g) => g.columns)
85+
const SUITES_COLUMNS = SUITES_COLUMN_GROUPS.flatMap((g) => g.columns)
7986

8087
const OPERATORS = [
8188
{ value: 'eq', label: '= equals' },
@@ -97,7 +104,7 @@ const TIMESTAMP_COLUMNS = new Set([
97104

98105
// --- Types ---
99106

100-
type Endpoint = 'runs' | 'test_stats' | 'test_stats_block_logs'
107+
type Endpoint = 'runs' | 'test_stats' | 'test_stats_block_logs' | 'suites'
101108

102109
interface FilterRow {
103110
id: string
@@ -172,6 +179,7 @@ function searchParamsToState(params: QuerySearchParams): QueryBuilderState | nul
172179
const endpoint: Endpoint =
173180
params.endpoint === 'test_stats' ? 'test_stats'
174181
: params.endpoint === 'test_stats_block_logs' ? 'test_stats_block_logs'
182+
: params.endpoint === 'suites' ? 'suites'
175183
: 'runs'
176184
const validCols = new Set(columnsForEndpoint(endpoint))
177185

@@ -244,12 +252,14 @@ function uid() {
244252
function columnsForEndpoint(ep: Endpoint) {
245253
if (ep === 'runs') return RUNS_COLUMNS
246254
if (ep === 'test_stats_block_logs') return TEST_STATS_BLOCK_LOG_COLUMNS
255+
if (ep === 'suites') return SUITES_COLUMNS
247256
return TEST_STAT_COLUMNS
248257
}
249258

250259
function columnGroupsForEndpoint(ep: Endpoint): ColumnGroup[] {
251260
if (ep === 'runs') return RUNS_COLUMN_GROUPS
252261
if (ep === 'test_stats_block_logs') return TEST_STATS_BLOCK_LOG_COLUMN_GROUPS
262+
if (ep === 'suites') return SUITES_COLUMN_GROUPS
253263
return TEST_STAT_COLUMN_GROUPS
254264
}
255265

@@ -620,6 +630,16 @@ export function QueryBuilderPage() {
620630
>
621631
test_stats_block_logs
622632
</button>
633+
<button
634+
onClick={() => dispatch({ type: 'SET_ENDPOINT', endpoint: 'suites' })}
635+
className={`px-3 py-1.5 text-sm font-medium ${
636+
state.endpoint === 'suites'
637+
? 'bg-blue-600 text-white'
638+
: 'bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
639+
}`}
640+
>
641+
suites
642+
</button>
623643
</div>
624644
</div>
625645

0 commit comments

Comments
 (0)