diff --git a/syz-cluster/dashboard/handler.go b/syz-cluster/dashboard/handler.go index 537e5413661f..d443d134c1c6 100644 --- a/syz-cluster/dashboard/handler.go +++ b/syz-cluster/dashboard/handler.go @@ -117,6 +117,7 @@ func (h *dashboardHandler) seriesList(w http.ResponseWriter, r *http.Request) er WithFindings: r.FormValue("with_findings") != "", Limit: perPage, Offset: offset, + Name: r.FormValue("name"), }, // If the filters are changed, the old offset value is irrelevant. FilterFormURL: urlutil.DropParam(baseURL, "offset", ""), @@ -171,6 +172,7 @@ func (h *dashboardHandler) seriesInfo(w http.ResponseWriter, r *http.Request) er *db.Series Patches []*db.Patch Sessions []SessionData + Versions []*db.Series TotalPatches int } var data SeriesData @@ -187,6 +189,11 @@ func (h *dashboardHandler) seriesInfo(w http.ResponseWriter, r *http.Request) er return fmt.Errorf("failed to query patches: %w", err) } data.TotalPatches = len(data.Patches) + // Note: There may be some false positives, but there's no straightforward way to filter them out. + data.Versions, err = h.seriesRepo.ListAllVersions(ctx, data.Series.Title) + if err != nil { + return fmt.Errorf("failed to query all series versions: %w", err) + } sessions, err := h.sessionRepo.ListForSeries(ctx, data.Series) if err != nil { return fmt.Errorf("failed to query sessions: %w", err) diff --git a/syz-cluster/dashboard/templates/index.html b/syz-cluster/dashboard/templates/index.html index b52da738f4ef..afaa87d73df7 100644 --- a/syz-cluster/dashboard/templates/index.html +++ b/syz-cluster/dashboard/templates/index.html @@ -6,6 +6,10 @@ +
+ + +
+ {{$currentSeriesVersion := .Version}} + {{range .Versions}} + + {{end}} + + Cc diff --git a/syz-cluster/pkg/db/migrations/8_add_series_and_patch_tokenization.down.sql b/syz-cluster/pkg/db/migrations/8_add_series_and_patch_tokenization.down.sql new file mode 100644 index 000000000000..814f3d40ede7 --- /dev/null +++ b/syz-cluster/pkg/db/migrations/8_add_series_and_patch_tokenization.down.sql @@ -0,0 +1,5 @@ +-- Revert search by patch and series names +DROP INDEX SeriesIndex; +ALTER TABLE Series DROP COLUMN TitleTokens; +DROP INDEX PatchesIndex; +ALTER TABLE Patches DROP COLUMN TitleTokens; diff --git a/syz-cluster/pkg/db/migrations/8_add_series_and_patch_tokenization.up.sql b/syz-cluster/pkg/db/migrations/8_add_series_and_patch_tokenization.up.sql new file mode 100644 index 000000000000..b0a1b7afcf5f --- /dev/null +++ b/syz-cluster/pkg/db/migrations/8_add_series_and_patch_tokenization.up.sql @@ -0,0 +1,5 @@ +-- Enable search by patch and series names +ALTER TABLE Series ADD COLUMN TitleTokens TOKENLIST AS (TOKENIZE_FULLTEXT(Title)) HIDDEN; +CREATE SEARCH INDEX SeriesIndex ON Series(TitleTokens); +ALTER TABLE Patches ADD COLUMN TitleTokens TOKENLIST AS (TOKENIZE_FULLTEXT(Title)) HIDDEN; +CREATE SEARCH INDEX PatchesIndex ON Patches(TitleTokens); diff --git a/syz-cluster/pkg/db/series_repo.go b/syz-cluster/pkg/db/series_repo.go index bb8bf7a8dce3..f0c353e49eda 100644 --- a/syz-cluster/pkg/db/series_repo.go +++ b/syz-cluster/pkg/db/series_repo.go @@ -9,6 +9,7 @@ import ( "context" "errors" "fmt" + "strings" "sync" "time" @@ -132,6 +133,7 @@ type SeriesFilter struct { WithFindings bool Limit int Offset int + Name string } // ListLatest() returns the list of series ordered by the decreasing PublishedAt value. @@ -141,41 +143,57 @@ func (repo *SeriesRepository) ListLatest(ctx context.Context, filter SeriesFilte defer ro.Close() stmt := spanner.Statement{ - SQL: "SELECT Series.* FROM Series WHERE 1=1", + SQL: "SELECT Series.* FROM Series", Params: map[string]any{}, } + var conds []string + if !maxPublishedAt.IsZero() { - stmt.SQL += " AND PublishedAt < @toTime" + conds = append(conds, "PublishedAt < @toTime") stmt.Params["toTime"] = maxPublishedAt } if filter.Cc != "" { - stmt.SQL += " AND @cc IN UNNEST(Cc)" + conds = append(conds, "@cc IN UNNEST(Cc)") stmt.Params["cc"] = filter.Cc } + if filter.Name != "" { + conds = append(conds, `ID IN( +SELECT ID FROM SERIES +WHERE SEARCH(Series.TitleTokens, @name) +UNION DISTINCT +SELECT SeriesID FROM Patches +WHERE SEARCH(Patches.TitleTokens, @name) +)`) + stmt.Params["name"] = filter.Name + } if filter.Status != SessionStatusAny { // It could have been an INNER JOIN in the main query, but let's favor the simpler code // in this function. // The optimizer should transform the query to a JOIN anyway. - stmt.SQL += " AND EXISTS(SELECT 1 FROM Sessions WHERE" + var statusCond = "EXISTS(SELECT 1 FROM Sessions WHERE" switch filter.Status { case SessionStatusWaiting: - stmt.SQL += " Sessions.SeriesID = Series.ID AND Sessions.StartedAt IS NULL" + statusCond += " Sessions.SeriesID = Series.ID AND Sessions.StartedAt IS NULL" case SessionStatusInProgress: - stmt.SQL += " Sessions.ID = Series.LatestSessionID AND Sessions.FinishedAt IS NULL" + statusCond += " Sessions.ID = Series.LatestSessionID AND Sessions.FinishedAt IS NULL" case SessionStatusFinished: - stmt.SQL += " Sessions.ID = Series.LatestSessionID AND Sessions.FinishedAt IS NOT NULL" + + statusCond += " Sessions.ID = Series.LatestSessionID AND Sessions.FinishedAt IS NOT NULL" + " AND Sessions.SkipReason IS NULL" case SessionStatusSkipped: - stmt.SQL += " Sessions.ID = Series.LatestSessionID AND Sessions.SkipReason IS NOT NULL" + statusCond += " Sessions.ID = Series.LatestSessionID AND Sessions.SkipReason IS NOT NULL" default: return nil, fmt.Errorf("unknown status value: %q", filter.Status) } - stmt.SQL += ")" + statusCond += ")" + conds = append(conds, statusCond) } if filter.WithFindings { - stmt.SQL += " AND Series.LatestSessionID IS NOT NULL AND EXISTS(" + - "SELECT 1 FROM Findings WHERE " + - "Findings.SessionID = Series.LatestSessionID AND Findings.InvalidatedAt IS NULL)" + conds = append(conds, "Series.LatestSessionID IS NOT NULL AND EXISTS("+ + "SELECT 1 FROM Findings WHERE "+ + "Findings.SessionID = Series.LatestSessionID AND Findings.InvalidatedAt IS NULL)") + } + if len(conds) != 0 { + stmt.SQL += " WHERE " + strings.Join(conds, " AND ") } stmt.SQL += " ORDER BY PublishedAt DESC, ID" if filter.Limit > 0 { @@ -210,6 +228,17 @@ func (repo *SeriesRepository) ListLatest(ctx context.Context, filter SeriesFilte return ret, nil } +func (repo *SeriesRepository) ListAllVersions(ctx context.Context, title string) ([]*Series, error) { + ro := repo.client.ReadOnlyTransaction() + defer ro.Close() + return readEntities[Series](ctx, ro, spanner.Statement{ + SQL: "SELECT ID, Version FROM SERIES where Title = @title ORDER BY Version", + Params: map[string]any{ + "title": title, + }, + }) +} + func (repo *SeriesRepository) querySessions(ctx context.Context, ro *spanner.ReadOnlyTransaction, seriesList []*SeriesWithSession) error { idToSeries := map[string]*SeriesWithSession{} diff --git a/syz-cluster/pkg/db/series_repo_test.go b/syz-cluster/pkg/db/series_repo_test.go index 576d48b94af6..b6bc74ca681e 100644 --- a/syz-cluster/pkg/db/series_repo_test.go +++ b/syz-cluster/pkg/db/series_repo_test.go @@ -174,6 +174,94 @@ func TestSeriesRepositoryList(t *testing.T) { }) } +// nolint: dupl +func TestSeriesRepositorySearch(t *testing.T) { + client, ctx := NewTransientDB(t) + repo := NewSeriesRepository(client) + + series1 := &Series{ + ExtID: "series-search-1", + Title: "Kernel Series for ARM64", + PublishedAt: time.Date(2020, time.January, 1, 1, 0, 0, 0, time.UTC), + } + patches1 := []*Patch{ + { + Title: "arm64: patch for CPU", + Seq: 1, + }, + { + Title: "arm64: another patch for memory", + Seq: 2, + }, + } + err := repo.Insert(ctx, series1, func() ([]*Patch, error) { + return patches1, nil + }) + assert.NoError(t, err) + + series2 := &Series{ + ExtID: "series-search-2", + Title: "X86 Specific Patch Series", + PublishedAt: time.Date(2020, time.January, 1, 2, 0, 0, 0, time.UTC), + } + patches2 := []*Patch{ + { + Title: "x86: new feature", + Seq: 1, + }, + } + err = repo.Insert(ctx, series2, func() ([]*Patch, error) { + return patches2, nil + }) + assert.NoError(t, err) + + series3 := &Series{ + ExtID: "series-search-3", + Title: "Generic Bug Fixes", + PublishedAt: time.Date(2020, time.January, 1, 3, 0, 0, 0, time.UTC), + } + patches3 := []*Patch{ + { + Title: "net: fix double free", + Seq: 1, + }, + } + err = repo.Insert(ctx, series3, func() ([]*Patch, error) { + return patches3, nil + }) + assert.NoError(t, err) + + t.Run("by_series_name", func(t *testing.T) { + list, err := repo.ListLatest(ctx, SeriesFilter{Name: "Kernel Series"}, time.Time{}) + assert.NoError(t, err) + assert.Len(t, list, 1) + assert.Equal(t, series1.Title, list[0].Series.Title) + }) + t.Run("by_patch_name", func(t *testing.T) { + list, err := repo.ListLatest(ctx, SeriesFilter{Name: "double free"}, time.Time{}) + assert.NoError(t, err) + assert.Len(t, list, 1) + assert.Equal(t, series3.Title, list[0].Series.Title) + }) + t.Run("no_match", func(t *testing.T) { + list, err := repo.ListLatest(ctx, SeriesFilter{Name: "nonexistent"}, time.Time{}) + assert.NoError(t, err) + assert.Len(t, list, 0) + }) + t.Run("empty_search_string", func(t *testing.T) { + list, err := repo.ListLatest(ctx, SeriesFilter{Name: ""}, time.Time{}) + assert.NoError(t, err) + assert.Len(t, list, 3) // All series should be returned if search strings are empty. + }) + t.Run("search_across_series_and_patch", func(t *testing.T) { + list, err := repo.ListLatest(ctx, SeriesFilter{Name: "patch"}, time.Time{}) + assert.NoError(t, err) + assert.Len(t, list, 2) + assert.Equal(t, series2.Title, list[0].Series.Title) + assert.Equal(t, series1.Title, list[1].Series.Title) + }) +} + func TestSeriesRepositoryUpdate(t *testing.T) { client, ctx := NewTransientDB(t) repo := NewSeriesRepository(client)