Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions internal/api/graphql/graph/baseResolver/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,19 +204,24 @@ func ComponentIssueCountsBaseResolver(
}
}

f := &entity.ComponentFilter{
Id: componentId,
ServiceCCRN: filter.ServiceCcrn,
if len(componentId) == 0 {
return &model.SeverityCounts{}, nil
}

var severityCounts model.SeverityCounts
ids := make([]int64, 0, len(componentId))
for _, id := range componentId {
if id != nil {
ids = append(ids, *id)
}
}

counts, err := app.GetComponentVulnerabilityCounts(ctx, f)
countsMap, err := app.GetIssueCountsByComponentIDs(ctx, ids, filter.ServiceCcrn)
if err != nil {
return nil, ToGraphQLError(err)
}

for _, c := range counts {
var severityCounts model.SeverityCounts
for _, c := range countsMap {
severityCounts.Critical += int(c.Critical)
severityCounts.High += int(c.High)
severityCounts.Medium += int(c.Medium)
Expand Down
25 changes: 13 additions & 12 deletions internal/api/graphql/graph/baseResolver/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,23 +78,24 @@ func ImageBaseResolver(
edges = append(edges, &edge)
}

// Parse component IDs once; reused for both per-image preloads and ImageConnection.counts
componentIDs := make([]int64, 0, len(edges))
for _, edge := range edges {
id, err := strconv.ParseInt(edge.Node.ID, 10, 64)
if err != nil {
logrus.WithField("id", edge.Node.ID).Warn("ImageBaseResolver: failed to parse component ID for batch preload")
continue
}

componentIDs = append(componentIDs, id)
}

// Batch pre-load for nested fields
needVersions := lo.Contains(requestedFields, "edges.node.versions")
needVulnCounts := lo.Contains(requestedFields, "edges.node.vulnerabilityCounts")
needVulnerabilities := containsField(requestedFields, "edges.node.vulnerabilities")

if (needVersions || needVulnCounts || needVulnerabilities) && len(edges) > 0 {
componentIDs := make([]int64, 0, len(edges))
for _, edge := range edges {
id, err := strconv.ParseInt(edge.Node.ID, 10, 64)
if err != nil {
logrus.WithField("id", edge.Node.ID).Warn("ImageBaseResolver: failed to parse component ID for batch preload")
continue
}

componentIDs = append(componentIDs, id)
}

if (needVersions || needVulnCounts || needVulnerabilities) && len(componentIDs) > 0 {
var (
versionsMap map[int64][]entity.ComponentVersionResult
issueCountsMap map[int64]entity.IssueSeverityCounts
Expand Down
53 changes: 37 additions & 16 deletions internal/api/graphql/graph/baseResolver/vulnerability.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,23 +149,44 @@ func VulnerabilityBaseResolver(app app.Heureka, ctx context.Context,
}

if lo.Contains(requestedFields, "counts") {
icFilter := &model.IssueFilter{
SupportGroupCcrn: filter.SupportGroup,
IssueType: []*model.IssueTypes{lo.ToPtr(model.IssueTypesVulnerability)},
IssueMatchStatus: []*model.IssueMatchStatusValues{
lo.ToPtr(model.IssueMatchStatusValuesNew),
},
Search: filter.Search,
ServiceCcrn: filter.Service,
ComponentVersionID: util.ConvertIntToStrSlice(f.ComponentVersionId),
AllServices: new(true),
}
var severityCounts *model.SeverityCounts

severityCounts, err := IssueCountsBaseResolver(app, ctx, icFilter, &model.NodeParent{
ParentName: model.VulnerabilityNodeName,
})
if err != nil {
return nil, NewResolverError("VulnerabilityBaseResolver", err.Error())
if parent != nil && parent.ParentName == model.ImageNodeName {
// For an Image parent, count distinct vulnerabilities per component using
// the same mvVulnerabilityList source as the vulnerability list so the
// severity breakdown is consistent with the items shown in the list.
countsMap, err := app.GetIssueCountsByComponentIDs(ctx, []int64{*f.ComponentId[0]}, filter.Service)
if err != nil {
return nil, NewResolverError("VulnerabilityBaseResolver", err.Error())
}

sc := model.NewSeverityCounts(&entity.IssueSeverityCounts{})
if counts, ok := countsMap[*f.ComponentId[0]]; ok {
sc = model.NewSeverityCounts(&counts)
}

severityCounts = &sc
} else {
icFilter := &model.IssueFilter{
SupportGroupCcrn: filter.SupportGroup,
IssueType: []*model.IssueTypes{lo.ToPtr(model.IssueTypesVulnerability)},
IssueMatchStatus: []*model.IssueMatchStatusValues{
lo.ToPtr(model.IssueMatchStatusValuesNew),
},
Search: filter.Search,
ServiceCcrn: filter.Service,
ComponentVersionID: util.ConvertIntToStrSlice(f.ComponentVersionId),
AllServices: new(true),
}

sc, err := IssueCountsBaseResolver(app, ctx, icFilter, &model.NodeParent{
ParentName: model.VulnerabilityNodeName,
})
if err != nil {
return nil, NewResolverError("VulnerabilityBaseResolver", err.Error())
}

severityCounts = sc
}

connection.Counts = severityCounts
Expand Down
6 changes: 1 addition & 5 deletions internal/app/component/component_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ func (cs *componentHandler) GetComponentVulnerabilityCounts(
filter *entity.ComponentFilter,
) ([]entity.IssueSeverityCounts, error) {
l := logrus.WithFields(logrus.Fields{
"event": GetComponentIssueSeverityCountsEventName,
"event": "GetComponentVulnerabilityCounts",
"filter": filter,
})

Expand All @@ -336,9 +336,5 @@ func (cs *componentHandler) GetComponentVulnerabilityCounts(
)
}

cs.eventRegistry.PushEvent(
&GetComponentIssueSeverityCountsEvent{Filter: filter, Counts: counts},
)

return counts, nil
}
20 changes: 5 additions & 15 deletions internal/app/component/component_handler_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,11 @@ import (
)

const (
ListComponentsEventName event.EventName = "ListComponents"
CreateComponentEventName event.EventName = "CreateComponent"
UpdateComponentEventName event.EventName = "UpdateComponent"
DeleteComponentEventName event.EventName = "DeleteComponent"
ListComponentCcrnsEventName event.EventName = "ListComponentCcrns"
GetComponentIssueSeverityCountsEventName event.EventName = "GetComponentIssueSeverityCounts"
ListComponentsEventName event.EventName = "ListComponents"
CreateComponentEventName event.EventName = "CreateComponent"
UpdateComponentEventName event.EventName = "UpdateComponent"
DeleteComponentEventName event.EventName = "DeleteComponent"
ListComponentCcrnsEventName event.EventName = "ListComponentCcrns"
)

type ListComponentsEvent struct {
Expand Down Expand Up @@ -65,15 +64,6 @@ func (e *ListComponentCcrnsEvent) Name() event.EventName {
return ListComponentCcrnsEventName
}

type GetComponentIssueSeverityCountsEvent struct {
Filter *entity.ComponentFilter
Counts []entity.IssueSeverityCounts
}

func (e *GetComponentIssueSeverityCountsEvent) Name() event.EventName {
return GetComponentIssueSeverityCountsEventName
}

// OnComponentCreateAuthz is a handler for the CreateComponentEvent
// It creates an OpenFGA relation tuple for the component and the current user
func OnComponentCreateAuthz(db database.Database, e event.Event, authz openfga.Authorization) {
Expand Down
26 changes: 3 additions & 23 deletions internal/database/mariadb/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,6 @@ var componentObject = DbObject[*entity.Component, *entity.ComponentFilter, entit
return !f.UseMvComponentService && needSingleComponentByServiceVulnerabilityCounts(f, order)
},
},
{
Name: "mvACBSVC",
Type: LeftJoin,
Table: "mvAllComponentsByServiceVulnerabilityCounts CVR",
On: "S.service_id = CVR.service_id",
DependsOn: []string{"S"},
Condition: func(f *entity.ComponentFilter, order *Order) bool {
return false
},
},

// --- Optimized MV path: mvComponentService replaces the CV→CI→S chain ---
// Uses a pre-computed junction table of (service_id, component_id) to avoid
// scanning millions of ComponentInstance rows at query time.
Expand Down Expand Up @@ -192,32 +181,23 @@ func (s *SqlDatabase) CountComponentVulnerabilities(
filter = EnsureFilter(filter)

query := `
SELECT CVR.critical_count, CVR.high_count, CVR.medium_count, CVR.low_count, CVR.none_count FROM %s AS CVR
SELECT CVR.critical_count, CVR.high_count, CVR.medium_count, CVR.low_count, CVR.none_count
FROM mvSingleComponentByServiceVulnerabilityCounts AS CVR
`

joins := ""
groupBy := ""

if len(filter.Id) == 0 && len(filter.Repository) == 0 {
query = fmt.Sprintf(query, "mvAllComponentsByServiceVulnerabilityCounts")
} else {
query = fmt.Sprintf(query, "mvSingleComponentByServiceVulnerabilityCounts")
groupBy = "GROUP BY CVR.component_id"
}

if len(filter.ServiceCCRN) > 0 {
joins = fmt.Sprintf("%s INNER JOIN Service S ON S.service_id = CVR.service_id", joins)

fl = append(fl, buildFilterQuery(filter.ServiceCCRN, "S.service_ccrn = ?", OP_OR))

filterParameters = buildQueryParameters(filterParameters, filter.ServiceCCRN)
}

if len(filter.Repository) > 0 {
joins = fmt.Sprintf("%s INNER JOIN Component C ON C.component_id = CVR.component_id", joins)

fl = append(fl, buildFilterQuery(filter.Repository, "C.component_repository = ?", OP_OR))

filterParameters = buildQueryParameters(filterParameters, filter.Repository)
}

Expand All @@ -233,7 +213,7 @@ func (s *SqlDatabase) CountComponentVulnerabilities(
query = fmt.Sprintf("%s WHERE %s", query, filterStr)
}

query = fmt.Sprintf("%s %s", query, groupBy)
query = fmt.Sprintf("%s GROUP BY CVR.component_id", query)

stmt, err := s.db.PreparexContext(ctx, query)
if err != nil {
Expand Down
35 changes: 22 additions & 13 deletions internal/database/mariadb/image_batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,11 @@ func (s *SqlDatabase) GetVersionsByComponentIDs(ctx context.Context, componentID
return result, nil
}

// GetIssueCountsByComponentIDs returns issue severity counts grouped by component ID in a single query.
// This eliminates N+1 queries when loading vulnerability counts for multiple images.
// The optional serviceCCRN filter restricts counts to the specified services.
// GetIssueCountsByComponentIDs returns vulnerability severity counts grouped by component ID.
// Counts are derived from the same mvVulnerabilityList source used by GetVulnerabilitiesByComponentIDs,
// so the badge counts always match the number of items shown in the vulnerability list.
// The optional serviceCCRN filter restricts counts to vulnerabilities active in the specified services
// (via mvVulnerabilityService).
func (s *SqlDatabase) GetIssueCountsByComponentIDs(ctx context.Context, componentIDs []int64, serviceCCRN []*string) (map[int64]entity.IssueSeverityCounts, error) {
l := logrus.WithFields(logrus.Fields{
"event": "database.GetIssueCountsByComponentIDs",
Expand All @@ -141,15 +143,21 @@ func (s *SqlDatabase) GetIssueCountsByComponentIDs(ctx context.Context, componen
}

query := sq.Select(
"CVR.component_id",
"SUM(CVR.critical_count) as critical_count",
"SUM(CVR.high_count) as high_count",
"SUM(CVR.medium_count) as medium_count",
"SUM(CVR.low_count) as low_count",
"SUM(CVR.none_count) as none_count",
"CV.componentversion_component_id",
"COUNT(DISTINCT CASE WHEN MVL.max_severity = 'Critical' THEN I.issue_id END) as critical_count",
"COUNT(DISTINCT CASE WHEN MVL.max_severity = 'High' THEN I.issue_id END) as high_count",
"COUNT(DISTINCT CASE WHEN MVL.max_severity = 'Medium' THEN I.issue_id END) as medium_count",
"COUNT(DISTINCT CASE WHEN MVL.max_severity = 'Low' THEN I.issue_id END) as low_count",
"COUNT(DISTINCT CASE WHEN MVL.max_severity = 'None' THEN I.issue_id END) as none_count",
).
From("mvSingleComponentByServiceVulnerabilityCounts CVR").
Where(sq.Eq{"CVR.component_id": componentIDs})
From("ComponentVersionIssue CVI").
Join("ComponentVersion CV ON CVI.componentversionissue_component_version_id = CV.componentversion_id").
Join("Issue I ON CVI.componentversionissue_issue_id = I.issue_id").
Join("mvVulnerabilityList MVL ON I.issue_id = MVL.issue_id").
Where(sq.Eq{"CV.componentversion_component_id": componentIDs}).
Where("CVI.componentversionissue_deleted_at IS NULL").
Where("CV.componentversion_deleted_at IS NULL").
Where("I.issue_deleted_at IS NULL")

if len(serviceCCRN) > 0 {
nonNilCCRNs := make([]string, 0, len(serviceCCRN))
Expand All @@ -161,12 +169,13 @@ func (s *SqlDatabase) GetIssueCountsByComponentIDs(ctx context.Context, componen

if len(nonNilCCRNs) > 0 {
query = query.
Join("Service S ON CVR.service_id = S.service_id").
Join("mvVulnerabilityService MVS ON I.issue_id = MVS.issue_id").
Join("Service S ON MVS.service_id = S.service_id").
Where(sq.Eq{"S.service_ccrn": nonNilCCRNs})
Comment on lines 171 to 174
}
}

query = query.GroupBy("CVR.component_id")
query = query.GroupBy("CV.componentversion_component_id")

sqlStr, args, err := query.ToSql()
if err != nil {
Expand Down
1 change: 0 additions & 1 deletion internal/database/mariadb/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ func registerEvents(cfg util.Config) error {
events := []string{
"refresh_mvServiceIssueCounts",
"refresh_mvSingleComponentByServiceVulnerabilityCounts",
"refresh_mvAllComponentsByServiceVulnerabilityCounts",
"refresh_mvCountIssueRatingsUniqueService",
"refresh_mvCountIssueRatingsService",
"refresh_mvCountIssueRatingsServiceWithoutSupportGroup",
Expand Down
Loading
Loading