Files
fleet/server/datastore/mysql/vulnerabilities_bench_test.go
Dante Catalfamo ad30812054 Speed up /vulnerabilities and filtered /software/versions queries (#45564)
Address 100% DB CPU spikes observed when paginating these endpoints with
filters. Two root causes: cve_meta and vulnerability_host_counts lack
indexes for the actual filter shapes, and ListVulnerabilities ran
correlated scalar subqueries on every row of vulnerability_host_counts
that matched the scope (1.1M rows examined per call).

Add three online indexes:
- cve_meta(cisa_known_exploit, cve) for the exploit filter
- cve_meta(cvss_score, cve) for the CVSS range filter
- vulnerability_host_counts(global_stats, team_id, host_count, cve) for
the scope filter on the vulnerabilities endpoint

Refactor ListVulnerabilities into a two-stage query: an inner query
filters, sorts, and paginates vulnerability_host_counts (with an
optional LEFT JOIN to cve_meta for sort/filter columns), and an outer
query enriches only the paginated page with the created_at and source
scalar subqueries. Net effect: the heavy subqueries run perPage times
(~20-100) instead of millions. Sort-by-created_at falls back to the
single-statement form since the sort key is itself the subquery result.

CountVulnerabilities drops the redundant DISTINCT (vhc.cve is already
unique within a (global_stats, team_id) scope via the existing UNIQUE
KEY) and only joins cve_meta when KnownExploit filtering is requested.

**Related issue:** Resolves #45415
2026-06-08 17:02:47 -04:00

577 lines
16 KiB
Go

package mysql
import (
"context"
"fmt"
"math/rand/v2"
"os"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
)
// To run:
//
// MYSQL_TEST=1 go test -bench=BenchmarkListVulnerabilities -benchtime=10x \
// -run='^$' ./server/datastore/mysql/
//
// To compare before/after with benchstat:
//
// git checkout main
// MYSQL_TEST=1 go test -bench=. -benchtime=10x -run='^$' \
// ./server/datastore/mysql/ -count=5 > /tmp/before.txt
//
// git checkout <feature-branch>
// MYSQL_TEST=1 go test -bench=. -benchtime=10x -run='^$' \
// ./server/datastore/mysql/ -count=5 > /tmp/after.txt
//
// benchstat /tmp/before.txt /tmp/after.txt
//
// Tune the dataset size with FLEET_BENCH_SIZE: "smoke" (default, ~5s seed),
// "realistic" (~30s seed), or "large" (~3min seed). EXPLAIN plan differences
// show up at any size; wall-time differences widen with scale.
type benchSize struct {
numSoftware int
numCVEs int
cvesPerSW int
// Fraction of CVEs that also appear in operating_system_vulnerabilities,
// so the (EXISTS software_cve OR EXISTS operating_system_vulnerabilities)
// branch in ListVulnerabilities actually has to consider both sides.
osVulnFraction float64
// Number of fake operating_system rows the OS vulns are spread across.
// Doesn't have to be realistic — we just need ids to point at.
numOperatingSystems int
// teamSoftwareFraction: fraction of software that also has a team-scoped
// software_host_counts row (team_id=1, global_stats=0). Same idea for vulns.
teamSoftwareFraction float64
teamVulnFraction float64
}
var benchSizes = map[string]benchSize{
"smoke": {
numSoftware: 2_000, numCVEs: 5_000, cvesPerSW: 3,
osVulnFraction: 0.3, numOperatingSystems: 20,
teamSoftwareFraction: 0.4, teamVulnFraction: 0.4,
},
"realistic": {
numSoftware: 50_000, numCVEs: 50_000, cvesPerSW: 4,
osVulnFraction: 0.3, numOperatingSystems: 50,
teamSoftwareFraction: 0.4, teamVulnFraction: 0.4,
},
"large": {
numSoftware: 200_000, numCVEs: 200_000, cvesPerSW: 5,
osVulnFraction: 0.3, numOperatingSystems: 100,
teamSoftwareFraction: 0.4, teamVulnFraction: 0.4,
},
}
// benchTeamID is the team_id seeded for team-scoped rows. Benchmarks that
// exercise the team path should pass &benchTeamID as the TeamID option.
const benchTeamID uint = 1
func pickBenchSize(tb testing.TB) benchSize {
tb.Helper()
name := os.Getenv("FLEET_BENCH_SIZE")
if name == "" {
name = "smoke"
}
sz, ok := benchSizes[name]
if !ok {
tb.Fatalf("unknown FLEET_BENCH_SIZE=%q (want smoke|realistic|large)", name)
}
return sz
}
// seedVulnPerfData populates a Fleet schema with enough data to make the
// query planner exercise the indexed paths. It bypasses Fleet's ingestion
// code in favor of direct multi-row INSERTs so seeding stays under a minute
// even at "large" size.
func seedVulnPerfData(tb testing.TB, ds *Datastore, sz benchSize) {
tb.Helper()
ctx := context.Background()
w := ds.writer(ctx)
// FK checks slow batch insert significantly; safe to disable for seeding.
if _, err := w.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
tb.Fatalf("disable FK: %v", err)
}
defer func() {
if _, err := w.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1"); err != nil {
tb.Logf("re-enable FK: %v", err)
}
}()
r := rand.New(rand.NewPCG(1, 2)) // nolint:gosec,G404 // benchmark seed, not security-sensitive
start := time.Now()
// software_titles — one per software for simplicity
batchInsert(tb, ds,
"INSERT INTO software_titles (id, name, source) VALUES ",
sz.numSoftware, 3, func(i int) []any {
return []any{i + 1, fmt.Sprintf("title-%d", i+1), "programs"}
})
// software — title_id maps 1:1. checksum is a unique binary(16) so we
// derive it from the row index deterministically.
batchInsert(tb, ds,
"INSERT INTO software (id, name, version, source, title_id, checksum) VALUES ",
sz.numSoftware, 6, func(i int) []any {
checksum := make([]byte, 16)
for j := range 8 {
checksum[j] = byte((i + 1) >> (8 * j) & 0xff)
}
return []any{
i + 1,
fmt.Sprintf("software-%d", i+1),
fmt.Sprintf("1.%d.%d", i%10, i%100),
"programs",
i + 1,
checksum,
}
})
// cve_meta — distribute cvss_score and a CISA known-exploit bit
batchInsert(tb, ds,
"INSERT INTO cve_meta (cve, cvss_score, epss_probability, cisa_known_exploit, published, description) VALUES ",
sz.numCVEs, 6, func(i int) []any {
return []any{
fmt.Sprintf("CVE-2024-%07d", i),
r.Float64() * 10,
r.Float64(),
r.IntN(20) == 0, // ~5% are CISA known exploits
time.Now().Add(-time.Duration(r.IntN(365*24)) * time.Hour),
fmt.Sprintf("Description for CVE-2024-%07d", i),
}
})
// software_cve — every software gets [0, cvesPerSW] random CVEs
type swcve struct {
swID int
cve string
}
pairs := make([]swcve, 0, sz.numSoftware*sz.cvesPerSW)
seen := make(map[swcve]struct{})
for i := 0; i < sz.numSoftware; i++ {
n := r.IntN(sz.cvesPerSW + 1)
for range n {
p := swcve{swID: i + 1, cve: fmt.Sprintf("CVE-2024-%07d", r.IntN(sz.numCVEs))}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
pairs = append(pairs, p)
}
}
batchInsert(tb, ds,
"INSERT INTO software_cve (software_id, cve, source) VALUES ",
len(pairs), 3, func(i int) []any {
return []any{pairs[i].swID, pairs[i].cve, 1}
})
// operating_system_vulnerabilities — a fraction of CVEs also appear as
// OS vulns so the OR-EXISTS branch in ListVulnerabilities is actually
// exercised (in prod about half of vuln catalog entries are OS-side).
// Unique key is (operating_system_id, cve), so we spread CVEs across a
// small pool of OS ids.
if sz.osVulnFraction > 0 && sz.numOperatingSystems > 0 {
osPairs := make([]struct {
osID int
cve string
}, 0, int(float64(sz.numCVEs)*sz.osVulnFraction))
osSeen := make(map[[2]int]struct{})
want := int(float64(sz.numCVEs) * sz.osVulnFraction)
for len(osPairs) < want {
cveIdx := r.IntN(sz.numCVEs)
osID := 1 + r.IntN(sz.numOperatingSystems)
key := [2]int{osID, cveIdx}
if _, dup := osSeen[key]; dup {
continue
}
osSeen[key] = struct{}{}
osPairs = append(osPairs, struct {
osID int
cve string
}{osID, fmt.Sprintf("CVE-2024-%07d", cveIdx)})
}
batchInsert(tb, ds,
"INSERT INTO operating_system_vulnerabilities (operating_system_id, cve, source) VALUES ",
len(osPairs), 3, func(i int) []any {
return []any{osPairs[i].osID, osPairs[i].cve, 1}
})
}
// software_host_counts — global row per software, plus a team-scoped row
// for a fraction of software so team_id-filtered benchmarks have data.
batchInsert(tb, ds,
"INSERT INTO software_host_counts (software_id, hosts_count, team_id, global_stats) VALUES ",
sz.numSoftware, 4, func(i int) []any {
return []any{i + 1, 1 + r.IntN(500), 0, 1}
})
numTeamSW := int(float64(sz.numSoftware) * sz.teamSoftwareFraction)
if numTeamSW > 0 {
batchInsert(tb, ds,
"INSERT INTO software_host_counts (software_id, hosts_count, team_id, global_stats) VALUES ",
numTeamSW, 4, func(i int) []any {
return []any{i + 1, 1 + r.IntN(200), benchTeamID, 0}
})
}
// vulnerability_host_counts — global row per CVE, plus team-scoped rows
// for a fraction of CVEs.
batchInsert(tb, ds,
"INSERT INTO vulnerability_host_counts (cve, team_id, host_count, global_stats) VALUES ",
sz.numCVEs, 4, func(i int) []any {
return []any{fmt.Sprintf("CVE-2024-%07d", i), 0, 1 + r.IntN(500), 1}
})
numTeamVuln := int(float64(sz.numCVEs) * sz.teamVulnFraction)
if numTeamVuln > 0 {
batchInsert(tb, ds,
"INSERT INTO vulnerability_host_counts (cve, team_id, host_count, global_stats) VALUES ",
numTeamVuln, 4, func(i int) []any {
return []any{fmt.Sprintf("CVE-2024-%07d", i), benchTeamID, 1 + r.IntN(200), 0}
})
}
tb.Logf("seeded %d software (%d team-scoped), %d cves (%d team-scoped, %d also OS-vuln), %d software_cve rows in %s",
sz.numSoftware, numTeamSW, sz.numCVEs, numTeamVuln,
int(float64(sz.numCVEs)*sz.osVulnFraction), len(pairs),
time.Since(start).Round(time.Millisecond))
}
// batchInsert issues multi-row INSERTs of `cols` placeholders per row in
// chunks small enough to stay under MySQL's default max_allowed_packet.
func batchInsert(
tb testing.TB,
ds *Datastore,
prefix string,
n, cols int,
row func(i int) []any,
) {
tb.Helper()
ctx := context.Background()
const rowsPerStmt = 500
placeholder := "(" + strings.Repeat("?,", cols-1) + "?)"
for start := 0; start < n; start += rowsPerStmt {
end := min(start+rowsPerStmt, n)
parts := make([]string, 0, end-start)
args := make([]any, 0, (end-start)*cols)
for i := start; i < end; i++ {
parts = append(parts, placeholder)
args = append(args, row(i)...)
}
stmt := prefix + strings.Join(parts, ",")
if _, err := ds.writer(ctx).ExecContext(ctx, stmt, args...); err != nil {
tb.Fatalf("batch insert (rows %d-%d): %v", start, end, err)
}
}
}
func BenchmarkListVulnerabilities(b *testing.B) {
ds := CreateMySQLDS(b)
sz := pickBenchSize(b)
seedVulnPerfData(b, ds, sz)
cases := []struct {
name string
opt fleet.VulnListOptions
}{
{
name: "cvss_score_page0_per20",
opt: fleet.VulnListOptions{
IsEE: true,
ListOptions: fleet.ListOptions{
OrderKey: "cvss_score",
OrderDirection: fleet.OrderDescending,
PerPage: 20,
IncludeMetadata: true,
},
},
},
{
name: "cvss_score_page50_per20",
opt: fleet.VulnListOptions{
IsEE: true,
ListOptions: fleet.ListOptions{
OrderKey: "cvss_score",
OrderDirection: fleet.OrderDescending,
PerPage: 20,
Page: 50,
IncludeMetadata: true,
},
},
},
{
name: "exploit_filter_page0",
opt: fleet.VulnListOptions{
IsEE: true,
KnownExploit: true,
ListOptions: fleet.ListOptions{
OrderKey: "cvss_score",
OrderDirection: fleet.OrderDescending,
PerPage: 20,
IncludeMetadata: true,
},
},
},
{
name: "created_at_page0_legacy",
opt: fleet.VulnListOptions{
IsEE: true,
ListOptions: fleet.ListOptions{
OrderKey: "created_at",
OrderDirection: fleet.OrderDescending,
PerPage: 20,
IncludeMetadata: true,
},
},
},
{
name: "team_cvss_score_page0",
opt: fleet.VulnListOptions{
IsEE: true,
TeamID: new(benchTeamID),
ListOptions: fleet.ListOptions{
OrderKey: "cvss_score",
OrderDirection: fleet.OrderDescending,
PerPage: 20,
IncludeMetadata: true,
},
},
},
{
name: "team_exploit_page0",
opt: fleet.VulnListOptions{
IsEE: true,
TeamID: new(benchTeamID),
KnownExploit: true,
ListOptions: fleet.ListOptions{
OrderKey: "cvss_score",
OrderDirection: fleet.OrderDescending,
PerPage: 20,
IncludeMetadata: true,
},
},
},
}
for _, c := range cases {
b.Run(c.name, func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _, err := ds.ListVulnerabilities(context.Background(), c.opt)
if err != nil {
b.Fatal(err)
}
}
})
}
}
func BenchmarkCountVulnerabilities(b *testing.B) {
ds := CreateMySQLDS(b)
sz := pickBenchSize(b)
seedVulnPerfData(b, ds, sz)
cases := []struct {
name string
opt fleet.VulnListOptions
}{
{"global_no_filter", fleet.VulnListOptions{IsEE: true}},
{"global_exploit", fleet.VulnListOptions{IsEE: true, KnownExploit: true}},
{"match_query", fleet.VulnListOptions{
IsEE: true,
ListOptions: fleet.ListOptions{MatchQuery: "CVE-2024-0001"},
}},
{"team_no_filter", fleet.VulnListOptions{IsEE: true, TeamID: new(benchTeamID)}},
{"team_exploit", fleet.VulnListOptions{IsEE: true, TeamID: new(benchTeamID), KnownExploit: true}},
}
for _, c := range cases {
b.Run(c.name, func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := ds.CountVulnerabilities(context.Background(), c.opt)
if err != nil {
b.Fatal(err)
}
}
})
}
}
func BenchmarkListSoftwareVersions(b *testing.B) {
ds := CreateMySQLDS(b)
sz := pickBenchSize(b)
seedVulnPerfData(b, ds, sz)
cases := []struct {
name string
opt fleet.SoftwareListOptions
}{
{
name: "no_filter_page0",
opt: fleet.SoftwareListOptions{
WithHostCounts: true,
IncludeCVEScores: true,
ListOptions: fleet.ListOptions{
OrderKey: "hosts_count",
OrderDirection: fleet.OrderDescending,
PerPage: 20,
IncludeMetadata: true,
},
},
},
{
name: "vulnerable_page0",
opt: fleet.SoftwareListOptions{
VulnerableOnly: true,
WithHostCounts: true,
IncludeCVEScores: true,
ListOptions: fleet.ListOptions{
OrderKey: "hosts_count",
OrderDirection: fleet.OrderDescending,
PerPage: 20,
IncludeMetadata: true,
},
},
},
{
name: "vulnerable_exploit_cvss_page0",
opt: fleet.SoftwareListOptions{
VulnerableOnly: true,
KnownExploit: true,
MinimumCVSS: 3,
WithHostCounts: true,
IncludeCVEScores: true,
ListOptions: fleet.ListOptions{
OrderKey: "hosts_count",
OrderDirection: fleet.OrderDescending,
PerPage: 20,
IncludeMetadata: true,
},
},
},
{
name: "vulnerable_narrow_query_page0",
opt: fleet.SoftwareListOptions{
VulnerableOnly: true,
WithHostCounts: true,
IncludeCVEScores: true,
ListOptions: fleet.ListOptions{
MatchQuery: "software-1",
OrderKey: "hosts_count",
OrderDirection: fleet.OrderDescending,
PerPage: 20,
IncludeMetadata: true,
},
},
},
{
// Broad match (1-char LIKE) — matches almost every software row;
// useful for spotting plan flips driven by predicate selectivity.
name: "vulnerable_broad_query_page0",
opt: fleet.SoftwareListOptions{
VulnerableOnly: true,
WithHostCounts: true,
IncludeCVEScores: true,
ListOptions: fleet.ListOptions{
MatchQuery: "s",
OrderKey: "hosts_count",
OrderDirection: fleet.OrderDescending,
PerPage: 20,
IncludeMetadata: true,
},
},
},
{
name: "team_vulnerable_page0",
opt: fleet.SoftwareListOptions{
TeamID: new(benchTeamID),
VulnerableOnly: true,
WithHostCounts: true,
IncludeCVEScores: true,
ListOptions: fleet.ListOptions{
OrderKey: "hosts_count",
OrderDirection: fleet.OrderDescending,
PerPage: 20,
IncludeMetadata: true,
},
},
},
{
// Matches the customer's full Q1 pattern: team + exploit + cvss
// floor + vulnerable + search.
name: "team_full_customer_pattern_page0",
opt: fleet.SoftwareListOptions{
TeamID: new(benchTeamID),
VulnerableOnly: true,
KnownExploit: true,
MinimumCVSS: 3,
WithHostCounts: true,
IncludeCVEScores: true,
ListOptions: fleet.ListOptions{
MatchQuery: "s",
OrderKey: "hosts_count",
OrderDirection: fleet.OrderDescending,
PerPage: 20,
IncludeMetadata: true,
},
},
},
}
for _, c := range cases {
b.Run(c.name, func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _, err := ds.ListSoftware(context.Background(), c.opt)
if err != nil {
b.Fatal(err)
}
}
})
}
}
func BenchmarkCountSoftware(b *testing.B) {
ds := CreateMySQLDS(b)
sz := pickBenchSize(b)
seedVulnPerfData(b, ds, sz)
cases := []struct {
name string
opt fleet.SoftwareListOptions
}{
{"no_filter", fleet.SoftwareListOptions{}},
{"vulnerable", fleet.SoftwareListOptions{VulnerableOnly: true}},
{"vulnerable_exploit", fleet.SoftwareListOptions{
VulnerableOnly: true, KnownExploit: true, IncludeCVEScores: true,
}},
{"vulnerable_cvss", fleet.SoftwareListOptions{
VulnerableOnly: true, MinimumCVSS: 3, IncludeCVEScores: true,
}},
{"team_vulnerable", fleet.SoftwareListOptions{
TeamID: new(benchTeamID), VulnerableOnly: true,
}},
{"team_vulnerable_exploit", fleet.SoftwareListOptions{
TeamID: new(benchTeamID), VulnerableOnly: true,
KnownExploit: true, IncludeCVEScores: true,
}},
}
for _, c := range cases {
b.Run(c.name, func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := ds.CountSoftware(context.Background(), c.opt)
if err != nil {
b.Fatal(err)
}
}
})
}
}