Skip to content

Fixed MySQL DB performance regressions - #33184

Merged
getvictor merged 7 commits into
mainfrom
victor/33147-increased-db-load
Sep 19, 2025
Merged

Fixed MySQL DB performance regressions#33184
getvictor merged 7 commits into
mainfrom
victor/33147-increased-db-load

Conversation

@getvictor

@getvictor getvictor commented Sep 18, 2025

Copy link
Copy Markdown
Member

Resolves #33147

Checklist for submitter

If some of the following don't apply, delete the relevant line.

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.
    See Changes files for more information.

Testing

Database migrations

  • Checked table schema to confirm autoupdate

Summary by CodeRabbit

  • Bug Fixes

    • Resolved MySQL performance regressions from 4.73.0/4.73.1 affecting OS versions and software titles views, improving load times and reducing timeouts.
  • Refactor

    • Optimized OS vulnerabilities fetching by batching multiple OS versions in a single request.
    • Added a supporting database index to speed kernel-related vulnerability queries.
  • Tests

    • Added comprehensive tests for multi-OS vulnerability retrieval, CVSS enrichment, team-scoped data, and service endpoint behavior.

…3.1 affecting OS versions and software titles read queries.
@codecov

codecov Bot commented Sep 18, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.47368% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.96%. Comparing base (84715f4) to head (0c15c63).
⚠️ Report is 30 commits behind head on main.

Files with missing lines Patch % Lines
...atastore/mysql/operating_system_vulnerabilities.go 90.63% 15 Missing and 7 partials ⚠️
...18154557_AddKernelHostCountsIndexForVulnQueries.go 72.22% 4 Missing and 1 partial ⚠️
server/service/hosts.go 86.95% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #33184      +/-   ##
==========================================
+ Coverage   63.94%   63.96%   +0.02%     
==========================================
  Files        2048     2050       +2     
  Lines      202292   202618     +326     
  Branches     6664     6664              
==========================================
+ Hits       129351   129602     +251     
- Misses      62752    62817      +65     
- Partials    10189    10199      +10     
Flag Coverage Δ
backend 65.07% <89.47%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@getvictor

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Sep 18, 2025

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@coderabbitai

coderabbitai Bot commented Sep 18, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds a batch vulnerabilities lookup across multiple OS versions, updates the OS versions service to use it, optimizes software title lookups via composite IN, and introduces a MySQL index and migration to speed kernel host count queries. Updates datastore interface and mocks, adjusts tests, and updates schema metadata.

Changes

Cohort / File(s) Summary of edits
Batch OS vulnerabilities API
server/fleet/datastore.go, server/datastore/mysql/operating_system_vulnerabilities.go, server/mock/datastore_mock.go
Introduces Datastore method to list vulnerabilities for multiple OS versions; implements MySQL batch query with optional CVSS/team filter and kernel vuln join; adds mock implementation.
Service: OS versions endpoint
server/service/hosts.go, server/service/hosts_test.go
Refactors OSVersions handler to use the new batch vuln method; adjusts tests to mock the new API and return map-based results.
DB migration and schema
server/datastore/mysql/migrations/tables/20250918154557_AddKernelHostCountsIndexForVulnQueries.go, server/datastore/mysql/schema.sql
Adds index on kernel_host_counts (os_version_id,software_id,hosts_count); registers migration (no-op down); updates schema to include the index and TABLESPACE metadata.
Software titles query optimization
server/datastore/mysql/software.go
Replaces OR-based tuple matching with composite (name, source, browser) IN (...) for software_titles lookups without bundle_identifier.
Tests for batch OS vulnerabilities
server/datastore/mysql/operating_system_vulnerabilities_batch_test.go
Adds comprehensive tests covering batching, empty input, CVSS enrichment, team filtering, non-existent OS, and mixed platform scenarios, with helpers to seed data.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Client as Client
  participant API as Service: OSVersions
  participant DS as Datastore
  participant MySQL as MySQL

  Client->>API: GET /api/latest/fleet/os_versions?team_id&include_cvss
  API->>DS: ListVulnsByMultipleOSVersions(osVersions[], includeCVSS, teamID)
  DS->>MySQL: Resolve OS IDs for (name,version) batch
  MySQL-->>DS: OS IDs map
  DS->>MySQL: Query OS vulns for all OS IDs
  MySQL-->>DS: Rows (CVE, created_at, resolved_in, ...)
  DS->>MySQL: Query kernel vulns filtered by linux OS IDs (+team filter)
  MySQL-->>DS: Kernel rows
  alt includeCVSS
    DS->>MySQL: Batch fetch CVE metadata
    MySQL-->>DS: CVSS/EPSS/CISA/published/description
    DS->>DS: Apply metadata per CVE
  end
  DS-->>API: map[name-version] -> Vulnerabilities
  API->>API: Populate OSVersion entries (vulns, CPEs)
  API-->>Client: JSON: OS versions with vulnerabilities
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

customer-starchik

Suggested reviewers

  • iansltx
  • dantecatalfamo
  • lucasmrod

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title Check ✅ Passed The PR title "Fixed MySQL DB performance regressions" is concise and accurately describes the primary intent of the changeset (addressing MySQL query performance regressions via batching and indexing), so it summarizes the main change in a way that a teammate scanning history will understand.
Linked Issues Check ✅ Passed The code changes address the primary coding objectives from [#33147]: batching OS vulnerability lookups via ListVulnsByMultipleOSVersions to avoid N+1/group_concat hotspots, adding an index on kernel_host_counts to speed kernel-related joins and hosts_count filters, and improving software_titles lookups with a composite IN clause; associated service, mock, and test updates are present, so the PR aligns with the heavy-query symptoms and affected routes described in the issue.
Out of Scope Changes Check ✅ Passed Most edits are in-scope for the performance-fix objective (query batching, index addition, service and mock updates, and tests); the only items that appear incidental are schema formatting changes (TABLESPACE annotations and an AUTO_INCREMENT bump in schema.sql) and the migration Down being a no-op, which should be confirmed but do not appear to introduce unrelated functionality.
Description Check ✅ Passed The PR description follows the repository checklist, marks key items complete (changes file, tests, QA, and DB migration check) and links the change to issue #33147, meeting the template's high-level requirements; however it omits a brief technical summary of the concrete code and migration changes (the new index, the batched vulnerability API and service changes) and does not explicitly state input-validation/placeholder usage or autoupdate/migration rollout expectations.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch victor/33147-increased-db-load

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/datastore/mysql/software.go (1)

659-666: Compile-time bug: undefined variable at return + inadvertent map reset.

  • existingBundleIDsToUpdate is defined inside the if-block; it’s out of scope at the return on Line 684.
  • incomingChecksumToTitle is reinitialized on Line 663, wiping entries populated for non-bundle titles.

Fix by hoisting existingBundleIDsToUpdate to function scope and removing the unintended reinit:

@@
 func (ds *Datastore) getIncomingSoftwareChecksumsToExistingTitles(
@@
 ) (map[string]fleet.SoftwareTitle, map[string]fleet.Software, error) {
   var (
     incomingChecksumToTitle     = make(map[string]fleet.SoftwareTitle, len(newSoftwareChecksums))
     argsWithoutBundleIdentifier []any
     argsWithBundleIdentifier    []any
     uniqueTitleStrToChecksum    = make(map[string]string)
   )
+  // keep second return value stable and accessible to the whole function
+  existingBundleIDsToUpdate := make(map[string]fleet.Software)
@@
   if len(argsWithBundleIdentifier) > 0 {
-    // no-op code change
-    incomingChecksumToTitle = make(map[string]fleet.SoftwareTitle, len(newSoftwareChecksums))
     stmtBundleIdentifier := `SELECT id, name, source, browser, bundle_identifier FROM software_titles WHERE bundle_identifier IN (?)`
🧹 Nitpick comments (16)
server/datastore/mysql/software.go (2)

630-637: Guard against placeholder/arg mismatch for composite IN.

Add a sanity check to avoid hard-to-debug SQL errors if the args slice ever gets out of sync.

@@
-    numItems := len(argsWithoutBundleIdentifier) / 3
+    if len(argsWithoutBundleIdentifier)%3 != 0 {
+      return nil, nil, ctxerr.New(ctx, "internal: expected (name,source,browser) triplets")
+    }
+    numItems := len(argsWithoutBundleIdentifier) / 3
     valuePlaceholders := make([]string, 0, numItems)

613-626: Optional: de-duplicate identical (name, source, browser) triplets before building IN.

If multiple new software entries share the same title triplet, you can shrink the IN list and reduce planning time.

I can provide a small set-based prepass if you want it.

changes/33147-increased-db-load (1)

1-2: Enrich the change note with user impact and upgrade guidance

Add specifics to help operators scanning release notes:

  • Name affected endpoints (/api/latest/fleet/os_versions, /api/fleet/orbit/software_install/result, device software install APIs).
  • Mention the added MySQL index and batch OS vuln lookup, and that a DB migration runs on upgrade.
  • State affected versions (regression in 4.73.0–4.73.1; fixed in this PR’s version).

Apply:

-Fixed MySQL DB performance regressions introduced in Fleet 4.73.0/4.73.1 affecting OS versions and software titles read queries.
+Fix MySQL performance regressions introduced in Fleet 4.73.0–4.73.1 that increased load and caused timeouts on large deployments.
+
+Details:
+- Optimizes /api/latest/fleet/os_versions and software install result endpoints by batching OS vulnerability lookups and improving title lookups.
+- Adds MySQL index on kernel_host_counts to speed team‑scoped OS version aggregations.
+- Includes a DB migration; run `fleet prepare db` during upgrade.
server/fleet/datastore.go (2)

1119-1121: Document the key format and input semantics of the new batch API (and consider a helper).

The return type is map[string]Vulnerabilities, but the key format isn’t specified. Please document exactly how keys are constructed (e.g., "platform|name|version"), nil vs. empty input behavior, deduplication of duplicate OS versions, and the effect of includeCVSS and teamID. Optionally, export a helper like fleet.MakeOSVersionKey(OSVersion) to avoid caller-side drift.

Apply this comment update:

- // ListVulnsByMultipleOSVersions is an optimized batch query that fetches vulnerabilities for multiple OS versions
- // in a single efficient operation.
+ // ListVulnsByMultipleOSVersions fetches vulnerabilities for multiple OS versions in one call.
+ // Contract:
+ //  - osVersions may be nil/empty: returns an empty map and no error.
+ //  - Returned map is keyed by a canonical OS version key (e.g., "platform|name|version").
+ //  - Duplicate inputs are deduplicated by that key.
+ //  - includeCVSS toggles inclusion of CVSS metadata (may be more expensive).
+ //  - teamID (if non-nil) scopes team-specific data (e.g., kernel vulns/counts).

1119-1121: Future-proof the API by preferring an ID-based key (optional).

If feasible, prefer map[uint]Vulnerabilities keyed by OS version ID to avoid string canonicalization and reduce ambiguity. If IDs aren’t always available at call sites, keep the string key but add the documented helper to standardize key construction.

server/service/hosts_test.go (3)

1301-1304: Return an empty map instead of nil from the mock for clarity.

Explicit empty map avoids surprises if callers iterate or range over results.

-        teamID *uint) (map[string]fleet.Vulnerabilities, error) {
-        return nil, nil
+        teamID *uint) (map[string]fleet.Vulnerabilities, error) {
+        return map[string]fleet.Vulnerabilities{}, nil

1346-1349: Same here: prefer an empty map over nil in the mock.

-        teamID *uint) (map[string]fleet.Vulnerabilities, error) {
-        return nil, nil
+        teamID *uint) (map[string]fleet.Vulnerabilities, error) {
+        return map[string]fleet.Vulnerabilities{}, nil

1301-1304: Optionally assert the batch API is invoked at least once.

Add a require.True(t, ds.ListVulnsByMultipleOSVersionsFuncInvoked) in one of these tests (after svc.OSVersions) to ensure the new path is wired.

Also applies to: 1346-1349

server/service/hosts.go (1)

2185-2190: Consider extracting CPE generation to a shared function

The Darwin CPE generation logic appears in both the batch population (lines 2185-2190) and the single OS version details (line 2326-2331). Consider extracting this to avoid duplication.

+func generateDarwinCPEs(version string) []string {
+	return []string{
+		fmt.Sprintf("cpe:2.3:o:apple:macos:%s:*:*:*:*:*:*:*", version),
+		fmt.Sprintf("cpe:2.3:o:apple:mac_os_x:%s:*:*:*:*:*:*:*", version),
+	}
+}

 // In the batch population section:
 if osV.Platform == "darwin" {
-	osV.GeneratedCPEs = []string{
-		fmt.Sprintf("cpe:2.3:o:apple:macos:%s:*:*:*:*:*:*:*", osV.Version),
-		fmt.Sprintf("cpe:2.3:o:apple:mac_os_x:%s:*:*:*:*:*:*:*", osV.Version),
-	}
+	osV.GeneratedCPEs = generateDarwinCPEs(osV.Version)
 }
server/datastore/mysql/operating_system_vulnerabilities.go (4)

335-337: Tone down perf claim in comment.

"700x+ improvement" can rot or be environment-specific. Prefer "significant improvement" or reference a benchmark doc.


447-462: Dedup is O(n^2) per OS key; switch to an index map.

On large OS cohorts this loop becomes quadratic. Use an index map to track positions by CVE and update CreatedAt in O(1).

Apply this diff (plus a small addition above):

@@
-	// Step 2: Execute queries
-	vulnsByKey := make(map[string][]fleet.CVE)
+	// Step 2: Execute queries
+	vulnsByKey := make(map[string][]fleet.CVE)
+	idxByKey := make(map[string]map[string]int) // key -> CVE -> index
@@
-		// Check if we already have this CVE for this key (deduplication across architectures)
-		found := false
-		for i, existing := range vulnsByKey[key] {
-			if existing.CVE == r.CVE {
-				found = true
-				// Keep the earliest CreatedAt time
-				if r.CreatedAt.Before(existing.CreatedAt) {
-					vulnsByKey[key][i].CreatedAt = r.CreatedAt
-				}
-				break
-			}
-		}
-		if !found {
-			vulnsByKey[key] = append(vulnsByKey[key], vuln)
-		}
+		if idxByKey[key] == nil {
+			idxByKey[key] = make(map[string]int)
+		}
+		if i, ok := idxByKey[key][r.CVE]; ok {
+			if r.CreatedAt.Before(vulnsByKey[key][i].CreatedAt) {
+				vulnsByKey[key][i].CreatedAt = r.CreatedAt
+			}
+		} else {
+			idxByKey[key][r.CVE] = len(vulnsByKey[key])
+			vulnsByKey[key] = append(vulnsByKey[key], vuln)
+		}

Do the same pattern in the kernel loop if you expect duplicates there as well.


596-617: Safer double-pointer population for CVE metadata.

Using &meta.Field takes the address of a local copy; it escapes and is technically safe but non-idiomatic and easy to misuse. Copy to a local var first for clarity.

Apply this style (repeat for other fields):

-					if meta.CVSSScore != nil {
-						vulns[i].CVSSScore = &meta.CVSSScore
-					}
+					if meta.CVSSScore != nil {
+						cvss := meta.CVSSScore
+						vulns[i].CVSSScore = &cvss
+					}

530-534: Comment mismatch.

This block applies CVE metadata to all CVEs, not "Linux kernels only." Update the comment.

server/datastore/mysql/operating_system_vulnerabilities_batch_test.go (3)

74-112: Good CVSS/EPSS assertions; consider asserting nil for non-enriched CVEs.

Add one negative assertion to ensure only targeted CVE got metadata when includeCVSS=true.


114-134: Team filter test could add a negative case.

Also call without teamID (or with a different team) and assert kernel CVE is absent to prove the filter works.


223-275: Helper seeds realistic data; minor nit on rune math.

string(rune('0'+i)) works; fmt.Sprintf("%d", i) is clearer but optional.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 84715f4 and 6c3ac29.

📒 Files selected for processing (10)
  • changes/33147-increased-db-load (1 hunks)
  • server/datastore/mysql/migrations/tables/20250918154557_AddKernelHostCountsIndexForVulnQueries.go (1 hunks)
  • server/datastore/mysql/operating_system_vulnerabilities.go (2 hunks)
  • server/datastore/mysql/operating_system_vulnerabilities_batch_test.go (1 hunks)
  • server/datastore/mysql/schema.sql (2 hunks)
  • server/datastore/mysql/software.go (1 hunks)
  • server/fleet/datastore.go (1 hunks)
  • server/mock/datastore_mock.go (3 hunks)
  • server/service/hosts.go (1 hunks)
  • server/service/hosts_test.go (2 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.go

⚙️ CodeRabbit configuration file

When reviewing SQL queries that are added or modified, ensure that appropriate filtering criteria are applied—especially when a query is intended to return data for a specific entity (e.g., a single host). Check for missing WHERE clauses or incorrect filtering that could lead to incorrect or non-deterministic results (e.g., returning the first row instead of the correct one). Flag any queries that may return unintended results due to lack of precise scoping.

Files:

  • server/fleet/datastore.go
  • server/datastore/mysql/software.go
  • server/service/hosts_test.go
  • server/service/hosts.go
  • server/datastore/mysql/operating_system_vulnerabilities.go
  • server/mock/datastore_mock.go
  • server/datastore/mysql/migrations/tables/20250918154557_AddKernelHostCountsIndexForVulnQueries.go
  • server/datastore/mysql/operating_system_vulnerabilities_batch_test.go
🧠 Learnings (3)
📚 Learning: 2025-08-08T07:40:05.301Z
Learnt from: getvictor
PR: fleetdm/fleet#31726
File: server/datastore/mysql/labels_test.go:2031-2031
Timestamp: 2025-08-08T07:40:05.301Z
Learning: In fleetdm/fleet repository tests (server/datastore/mysql/labels_test.go and similar), using testing.T.Context() is valid because the project targets a recent Go version where testing.T.Context() exists. Do not suggest replacing t.Context() with context.Background() in this codebase.

Applied to files:

  • server/datastore/mysql/operating_system_vulnerabilities.go
📚 Learning: 2025-08-08T07:40:05.301Z
Learnt from: getvictor
PR: fleetdm/fleet#31726
File: server/datastore/mysql/labels_test.go:2031-2031
Timestamp: 2025-08-08T07:40:05.301Z
Learning: Fleet repo targets Go 1.24.5 (root go.mod), which supports testing.T.Context(). Do not flag usage of t.Context() or suggest replacing it with context.Background() in tests (e.g., server/datastore/mysql/labels_test.go Line 2031 and similar).

Applied to files:

  • server/datastore/mysql/operating_system_vulnerabilities.go
📚 Learning: 2025-08-01T15:08:16.858Z
Learnt from: sgress454
PR: fleetdm/fleet#31508
File: server/datastore/mysql/schema.sql:102-116
Timestamp: 2025-08-01T15:08:16.858Z
Learning: The schema.sql file in server/datastore/mysql/ is auto-generated from migrations for use with tests, so it cannot be manually edited. Any changes must be made through migrations.

Applied to files:

  • server/datastore/mysql/schema.sql
🔇 Additional comments (14)
server/datastore/mysql/migrations/tables/20250918154557_AddKernelHostCountsIndexForVulnQueries.go (1)

29-31: Provide a reversible Down migration.

Drop the index on rollback.

-func Down_20250918154557(_ *sql.Tx) error {
-  return nil
-}
+func Down_20250918154557(tx *sql.Tx) error {
+  if _, err := tx.Exec(`DROP INDEX idx_kernel_host_counts_os_version_software ON kernel_host_counts`); err != nil {
+    // tolerate absence to keep Down idempotent
+    _ = err
+  }
+  return nil
+}
⛔ Skipped due to learnings
Learnt from: getvictor
PR: fleetdm/fleet#30589
File: server/datastore/mysql/migrations/tables/20250707095725_HostIdentitySCEPCertificates.go:53-55
Timestamp: 2025-07-08T16:13:39.114Z
Learning: In the Fleet codebase, Down migration functions are intentionally left empty/no-op. The team does not implement rollback functionality for database migrations, so empty Down_* functions in migration files are correct and should not be flagged as issues.
server/datastore/mysql/schema.sql (2)

1501-1503: schema.sql is generated; confirm no manual edits and that dump settings are stable

TABLESPACE clause and AUTO_INCREMENT bump look like dump‑format churn from the new migration. Per our learnings, schema.sql must be auto‑generated from migrations; ensure these diffs came from the generator, not hand edits, and that the dump tooling/version remains stable to avoid noisy diffs.


1084-1086: Add a team‑prefixed covering index on kernel_host_counts

Found: operating_system_vulnerabilities.go conditionally filters on kernel_host_counts.team_id (multiple places) while the migration adds only idx_kernel_host_counts_os_version_software (os_version_id, software_id, hosts_count) — team_id is not a leading column, so team‑scoped queries will do a residual filter and may not use index ordering for hosts_count.
Action: add a covering index:
CREATE INDEX idx_kernel_host_counts_team_os_sw_hosts ON kernel_host_counts (team_id, os_version_id, software_id, hosts_count)
Keep the existing index until EXPLAINs confirm it’s safe to drop.
Please run EXPLAIN FORMAT=JSON for the vuln queries (e.g. the JOIN around server/datastore/mysql/operating_system_vulnerabilities.go ~lines 54–63 and the aggregation around ~lines 223–236) with and without team_id and paste the outputs to confirm index selection/filesort.

server/fleet/datastore.go (1)

1119-1121: Verify MySQL impl — dedupe present; add chunking for OS IN lists; team scoping present; confirm index usage

  • Dedupe: confirmed in server/datastore/mysql/operating_system_vulnerabilities.go — SQL-level DISTINCT / GROUP_CONCAT(DISTINCT) + MIN(created_at) and app-level dedupe loop (found := false).
  • Chunking: partial — CVE/meta queries are batched (use of batch / metaArgs), but OS ID lists are passed directly into IN(...) via strings.Repeat for len(osIDs) / len(linuxOSIDs) (no sub-chunking). Add chunking for those IN lists to avoid huge parameter lists.
  • Team scoping: implemented — teamFilter / khc.team_id appended when teamID != nil in the same file.
  • Index: repo contains idx_kernel_host_counts_os_version_software (os_version_id, software_id, hosts_count) (schema + migration). Run EXPLAIN on the kernel_host_counts JOIN queries to confirm the index is used and that predicates align with the index column order.
server/service/hosts.go (3)

2171-2203: Performance optimization: Batch vulnerability lookup successfully implemented

The change from per-OS vulnerability queries to a single batch query via ListVulnsByMultipleOSVersions is an effective solution to the N+1 query problem that was causing high database load. This should significantly reduce database queries when teams have many OS versions (e.g., 60k+ hosts scenario mentioned in the issue).


2193-2202: Good defensive programming with slice initialization

Initializing empty slices for Vulnerabilities and Kernels ensures JSON responses don't contain null values, maintaining API consistency.


2181-2183: Verified: composite key format matches datastore implementation and tests

ListVulnsByMultipleOSVersions builds keys as NameOnly + "-" + Version (server/datastore/mysql/operating_system_vulnerabilities.go) and the batch tests use the same (server/datastore/mysql/operating_system_vulnerabilities_batch_test.go).

server/mock/datastore_mock.go (3)

2684-2686: DataStore fields added for the new mock; LGTM.

Naming and pattern are consistent with the rest of the mocks.


800-801: Approve — mock func signature matches Datastore interface

Verified: server/fleet/datastore.go declares ListVulnsByMultipleOSVersions with the same signature and the mock in server/mock/datastore_mock.go matches; LGTM.


6479-6484: LGTM — mock sets invoked flag and delegates; tests assign the func.
Assignments found in server/service/hosts_test.go (lines 1301, 1346); call sites include server/service/hosts.go:2174 and datastore tests — no action required.

server/datastore/mysql/operating_system_vulnerabilities.go (2)

10-10: Import looks correct.

common_mysql is used for batched CVE metadata fetches. No issues.


357-372: Composite index present; row-constructor usage acceptable

  • UNIQUE KEY idx_unique_os (name,version,arch,kernel_version,platform,display_version) exists in server/datastore/mysql/schema.sql (≈ line 1787) — this provides the (name, version) composite index.
  • MySQL supports tuple/row comparisons, so WHERE (name, version) IN ((?, ?), ...) can use that composite index.
  • Verify migrations and deployed DBs actually create/keep this index (check migration SQLs and production schema).
server/datastore/mysql/operating_system_vulnerabilities_batch_test.go (2)

37-64: Happy path coverage looks good.

Covers 3 OS versions, asserts presence and counts without depending on order. 👍


277-319: Kernel seeding path is solid.

Creates title/software and counts atomically; ON DUP keys prevent flakes. 👍

Comment on lines +17 to +21
createIndexStmt := `
CREATE INDEX idx_kernel_host_counts_os_version_software
ON kernel_host_counts (os_version_id, software_id, hosts_count)
`

@coderabbitai coderabbitai Bot Sep 18, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

Confirm column order matches hottest query predicates.

If the common pattern is WHERE os_version_id = ? AND hosts_count > 0 then joining on software_id, consider (os_version_id, hosts_count, software_id). Please EXPLAIN the affected vuln queries before finalizing.

Run to find affected SQL and review join/filter patterns:


🏁 Script executed:

#!/bin/bash
# Inspect queries touching kernel_host_counts
rg -n -C3 --type=go "kernel_host_counts" server/datastore/mysql | sed -n '1,200p'

Length of output: 13999


Change index to (os_version_id, hosts_count, software_id)

Multiple vuln queries filter by kernel_host_counts.os_version_id = ? (or via operating_systems.name/version) AND kernel_host_counts.hosts_count > 0 then join on software_id — the current index (os_version_id, software_id, hosts_count) prevents using hosts_count for the range predicate. Update migration server/datastore/mysql/migrations/tables/20250918154557_AddKernelHostCountsIndexForVulnQueries.go to create: CREATE INDEX idx_kernel_host_counts_os_version_hosts_software ON kernel_host_counts (os_version_id, hosts_count, software_id). EXPLAIN the queries in server/datastore/mysql/operating_system_vulnerabilities.go (≈lines 51–62, 223–236, 470–476) and confirm the new index is used before merging.

🤖 Prompt for AI Agents
In
server/datastore/mysql/migrations/tables/20250918154557_AddKernelHostCountsIndexForVulnQueries.go
around lines 17–21, the created index currently lists columns as (os_version_id,
software_id, hosts_count) which prevents using hosts_count for the range
predicate; change the CREATE INDEX to name
idx_kernel_host_counts_os_version_hosts_software and order columns as
(os_version_id, hosts_count, software_id). Update the migration to create that
index (and if necessary drop the old index name), then run EXPLAIN on the vuln
queries in server/datastore/mysql/operating_system_vulnerabilities.go (approx
lines 51–62, 223–236, 470–476) to confirm the new index is chosen for the
os_version_id = ? AND hosts_count > 0 predicates before merging.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai Are you sure? Please think more about this and give specific code examples where this different index would benefit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

Based on the script results, let me examine the actual query patterns:

@getvictor You raise a good point - let me analyze the actual query patterns more carefully. Looking at the SQL queries in the codebase, I need to examine whether the current index column order (os_version_id, software_id, hosts_count) is actually optimal or if my suggested change would provide benefit.

From the query patterns I can see, the typical WHERE clauses are:

  1. os_version_id IN (...) - exact match on multiple values
  2. hosts_count > 0 - range predicate
  3. Joins on software_id

For a query like:

SELECT ... FROM kernel_host_counts khc 
JOIN software_cve scv ON scv.software_id = khc.software_id
WHERE khc.os_version_id IN (?, ?, ?) AND khc.hosts_count > 0

Current index (os_version_id, software_id, hosts_count):

  • Can use os_version_id for the IN clause efficiently
  • Can use software_id for the join condition
  • Cannot efficiently use hosts_count > 0 for range filtering (it's the last column)

Proposed index (os_version_id, hosts_count, software_id):

  • Can use os_version_id for the IN clause efficiently
  • Can use hosts_count > 0 for range filtering
  • Can still cover software_id for joins (though not for range filtering after the hosts_count predicate)

However, you're right to question this. If the join selectivity on software_id is more important than filtering on hosts_count > 0, or if most rows already have hosts_count > 0, then the current index might be better.

Could you run EXPLAIN on one of the kernel vulnerability queries with the current index to see the execution plan? This would help determine if the hosts_count > 0 filtering is actually a bottleneck or if the current index is performing well.


🏁 Script executed:

#!/bin/bash
# Extract the specific SQL queries that use kernel_host_counts to analyze WHERE clause patterns
echo "=== Finding SQL queries with kernel_host_counts ==="
rg -n -A 20 -B 5 --type=go "kernel_host_counts.*WHERE\|WHERE.*kernel_host_counts\|JOIN.*kernel_host_counts" server/datastore/mysql/

echo -e "\n=== Looking for hosts_count filtering patterns ==="
rg -n -A 10 -B 5 --type=go "hosts_count\s*>\s*0\|hosts_count\s*>\s*\?\|hosts_count\s*>\s*\$" server/datastore/mysql/

echo -e "\n=== Finding the specific queries mentioned in my previous analysis ==="
# Look at the specific line ranges I mentioned: 51-62, 223-236, 470-476
sed -n '45,70p' server/datastore/mysql/operating_system_vulnerabilities.go
echo -e "\n--- Lines 220-240 ---"
sed -n '220,240p' server/datastore/mysql/operating_system_vulnerabilities.go
echo -e "\n--- Lines 465-480 ---"
sed -n '465,480p' server/datastore/mysql/operating_system_vulnerabilities.go

Length of output: 2189


🏁 Script executed:

#!/bin/bash
# Let's look at the complete SQL queries to understand the join and filter patterns
echo "=== Complete SQL queries in operating_system_vulnerabilities.go ==="
cat server/datastore/mysql/operating_system_vulnerabilities.go | grep -A 30 -B 5 "kernel_host_counts"

Length of output: 8664


🏁 Script executed:

#!/bin/bash
# Let's examine the new batched query method to see how it uses the index
echo "=== Examining the new ListVulnsByMultipleOSVersions method ==="
ast-grep --pattern 'func (ds *Datastore) ListVulnsByMultipleOSVersions($$$) {
  $$$
}'

Length of output: 62


@getvictor You're absolutely right to question this - let me provide specific analysis based on the actual query patterns in the code.

Looking at the three main queries that use kernel_host_counts:

Query 1 (ListVulnsByOsNameAndVersion):

FROM software_cve
JOIN kernel_host_counts khc ON khc.software_id = software_cve.software_id
JOIN operating_systems os ON os.os_version_id = khc.os_version_id
WHERE os.name = ? AND os.version = ? 
  AND khc.hosts_count > 0

Query 2 (ListKernelsByOS):

FROM software s
JOIN kernel_host_counts khc ON khc.software_id = s.id
WHERE khc.os_version_id = ?
  AND khc.hosts_count > 0

Query 3 (New batch method ListVulnsByMultipleOSVersions):

FROM software_cve sc
JOIN kernel_host_counts khc ON khc.software_id = sc.software_id
WHERE khc.os_version_id IN (?, ?, ...)
  AND khc.hosts_count > 0

All queries follow the pattern: filter by os_version_idfilter by hosts_count > 0join on software_id.

However, you're right to be skeptical. The current index (os_version_id, software_id, hosts_count) might actually be optimal if:

  1. High join selectivity: If the join on software_id is very selective (few matching software IDs), MySQL might prefer to use the index to satisfy the join first
  2. Low hosts_count = 0 ratio: If most rows already have hosts_count > 0, then filtering on this condition provides little benefit

The proposed index (os_version_id, hosts_count, software_id) would only be better if filtering hosts_count > 0 significantly reduces the result set before the join.

My recommendation: Keep the current index as-is. The join condition is likely more selective than the hosts_count > 0 filter, and changing the index order could actually hurt performance for the join operations. The current index supports both the exact match on os_version_id and efficient joins on software_id.

Comment thread server/datastore/mysql/operating_system_vulnerabilities.go
Comment thread server/datastore/mysql/operating_system_vulnerabilities.go
@getvictor
getvictor marked this pull request as ready for review September 18, 2025 22:07
@getvictor
getvictor requested a review from a team as a code owner September 18, 2025 22:07

@mostlikelee mostlikelee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, just a nit

}

if r.ResolvedInVersion != nil {
resolvedVersion := r.ResolvedInVersion // avoid address of range var field

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we shouldn't need this in go 1.22+

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai What do you think? You gave the recommendation to do this since we're referencing the internal field of the var.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

# Check the Go module version and examine the specific code pattern
cat go.mod | grep "^go "

Length of output: 9


🏁 Script executed:

# Let's examine the exact code pattern in the operating_system_vulnerabilities.go file
rg -A10 -B5 "resolvedVersion.*ResolvedInVersion" server/datastore/mysql/operating_system_vulnerabilities.go

Length of output: 895


Let me examine the code structure to understand the exact types and context:

@getvictor You're absolutely right to question this! After examining the code, I believe mostlikelee is correct, and there's actually an issue with the current pattern regardless of Go version.

Looking at the code:

if r.ResolvedInVersion != nil {
    resolvedVersion := r.ResolvedInVersion // avoid address of range var field
    vuln.ResolvedInVersion = &resolvedVersion
}

The problem is that this pattern is creating a double pointer:

  1. r.ResolvedInVersion is already of type *string (a pointer to string)
  2. resolvedVersion := r.ResolvedInVersion makes resolvedVersion a *string
  3. &resolvedVersion creates a **string (pointer to pointer to string)

But vuln.ResolvedInVersion should expect a *string, not a **string.

In Go 1.22+, the loop variable semantics changed so that each iteration gets fresh variables, which means the original concern about taking addresses of range variable fields is no longer valid. The correct code should simply be:

if r.ResolvedInVersion != nil {
    vuln.ResolvedInVersion = r.ResolvedInVersion
}

The same issue appears to exist in the kernel vulnerabilities section as well. This pattern was likely introduced as a workaround for pre-Go 1.22 loop variable capture issues, but:

  1. It's creating incorrect double pointers
  2. It's no longer needed in Go 1.22+ due to the loop variable semantics changes
  3. Even for earlier Go versions, direct assignment of the pointer value would be safe

mostlikelee is correct - this workaround isn't needed in Go 1.22+, and the current implementation appears to be creating incorrect pointer types.


🏁 Script executed:

# Check the Go module version and examine the specific code pattern
cat go.mod | grep "^go "

Length of output: 9


🏁 Script executed:

# Let's examine the exact code pattern and surrounding context
rg -A10 -B5 "resolvedVersion.*ResolvedInVersion" server/datastore/mysql/operating_system_vulnerabilities.go

Length of output: 895


🏁 Script executed:

# Let's also check the struct definition for CVE to understand the expected types
rg -A20 -B5 "type.*CVE.*struct" --type=go

Length of output: 37041


🏁 Script executed:

# Check if there's a similar pattern elsewhere in the same function
rg -A3 -B3 "avoid address of range var" server/datastore/mysql/operating_system_vulnerabilities.go

Length of output: 341


@getvictor After examining the code and struct definitions more carefully, I need to revise my initial assessment. The pattern is actually correct for this specific use case, but the comment is misleading about the reason.

Looking at the data types involved:

  1. From the database query: r.ResolvedInVersion is *string (nullable database field)
  2. The target field vuln.ResolvedInVersion is **string (double pointer from the CVE struct definition)

The pattern:

if r.ResolvedInVersion != nil {
    resolvedVersion := r.ResolvedInVersion // avoid address of range var field  
    vuln.ResolvedInVersion = &resolvedVersion
}

Is doing the correct pointer level conversion:

  • r.ResolvedInVersion is *string
  • resolvedVersion := r.ResolvedInVersion creates a *string variable
  • &resolvedVersion creates **string which matches the expected type

However, the comment is misleading. This isn't about Go 1.22 loop variable changes at all. The double pointer design in the CVE struct allows for three states:

  1. Field omitted (for free tier)
  2. Field present but null (**string where outer pointer is non-nil, inner is nil)
  3. Field present with value (**string where both pointers are non-nil)

mostlikelee is right that the Go 1.22 loop variable concerns don't apply here since we're using Go 1.25.1, but the pattern is still needed for the correct type conversion from *string to **string.

The comment should be updated to reflect the real reason:

resolvedVersion := r.ResolvedInVersion // convert *string to **string for CVE struct
vuln.ResolvedInVersion = &resolvedVersion

So to answer your question: The pattern is necessary for the type conversion, but not because of loop variable capture issues.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair, forgot it's a doublepointer

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai so why would this not work?

vuln.ResolvedInVersion = &r.ResolvedInVersion

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@getvictor That's a great question! The direct approach vuln.ResolvedInVersion = &r.ResolvedInVersion wouldn't work due to a memory aliasing issue.

Here's the problem:

In the range loop for _, r := range osVulnResults, the variable r is reused for each iteration. When you do &r.ResolvedInVersion, you're taking the address of a field in that loop variable r.

Since r is the same memory location across all iterations (just with different values assigned), all the &r.ResolvedInVersion addresses would point to the exact same memory location. This means:

  1. All vulnerabilities would end up sharing the same ResolvedInVersion pointer
  2. After the loop completes, all of them would have the value from the last iteration
  3. You'd get incorrect/duplicate data across all vulnerabilities

The current pattern:

resolvedVersion := r.ResolvedInVersion // Creates a copy of the *string value
vuln.ResolvedInVersion = &resolvedVersion // Takes address of the copy

Creates a fresh copy of the pointer value for each iteration, so each vulnerability gets its own unique pointer.

This is actually still related to range variable behavior, but it's about memory reuse rather than the closure capture issue that Go 1.22 fixed. The struct field address problem persists regardless of Go version.

So the current approach is correct and necessary to avoid the memory aliasing issue!


🧠 Learnings used
Learnt from: lucasmrod
PR: fleetdm/fleet#30550
File: server/datastore/mysql/policies_test.go:6368-6378
Timestamp: 2025-07-03T15:13:36.494Z
Learning: Go 1.22 introduced a significant change to loop variable semantics where each iteration of a for loop creates a new variable, automatically fixing the classic closure capture bug that existed in earlier Go versions. Code that captures loop variables in closures no longer needs explicit variable copying in Go 1.22+. This fix is only enabled if the module's go.mod file specifies go 1.22 or higher.

Learnt from: lucasmrod
PR: fleetdm/fleet#30550
File: server/datastore/mysql/policies_test.go:6368-6378
Timestamp: 2025-07-03T15:13:36.494Z
Learning: Go 1.22 introduced a significant change to loop variable semantics where each iteration of a for loop creates a new variable, automatically fixing the classic closure capture bug that existed in earlier Go versions. Code that captures loop variables in closures no longer needs explicit variable copying in Go 1.22+.

@getvictor
getvictor merged commit d6695bf into main Sep 19, 2025
61 of 63 checks passed
@getvictor
getvictor deleted the victor/33147-increased-db-load branch September 19, 2025 20:35
getvictor added a commit that referenced this pull request Sep 22, 2025
Resolves #33147

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

## Database migrations

- [x] Checked table schema to confirm autoupdate

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- Bug Fixes
- Resolved MySQL performance regressions from 4.73.0/4.73.1 affecting OS
versions and software titles views, improving load times and reducing
timeouts.

- Refactor
- Optimized OS vulnerabilities fetching by batching multiple OS versions
in a single request.
- Added a supporting database index to speed kernel-related
vulnerability queries.

- Tests
- Added comprehensive tests for multi-OS vulnerability retrieval, CVSS
enrichment, team-scoped data, and service endpoint behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

(cherry picked from commit d6695bf)
getvictor added a commit that referenced this pull request Sep 22, 2025
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #33147

Cherry pick + schema update
getvictor added a commit that referenced this pull request Sep 23, 2025
Resolves #33147

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

## Database migrations

- [x] Checked table schema to confirm autoupdate

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- Bug Fixes
- Resolved MySQL performance regressions from 4.73.0/4.73.1 affecting OS
versions and software titles views, improving load times and reducing
timeouts.

- Refactor
- Optimized OS vulnerabilities fetching by batching multiple OS versions
in a single request.
- Added a supporting database index to speed kernel-related
vulnerability queries.

- Tests
- Added comprehensive tests for multi-OS vulnerability retrieval, CVSS
enrichment, team-scoped data, and service endpoint behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

(cherry picked from commit d6695bf)
getvictor added a commit that referenced this pull request Sep 23, 2025
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #33147

Cherry pick + schema generation
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Increased DB Load and 502s After upgrading v4.72.0->v4.73.1

2 participants