Skip to content

perf(manager): preaggregate cached CVE subquery - #2438

Open
pindo696 wants to merge 1 commit into
RedHatInsights:masterfrom
pindo696:cves-endpoint-timing-query
Open

perf(manager): preaggregate cached CVE subquery#2438
pindo696 wants to merge 1 commit into
RedHatInsights:masterfrom
pindo696:cves-endpoint-timing-query

Conversation

@pindo696

@pindo696 pindo696 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Postgres planner misestimated row count due to workspaces selectivity, choosing a nested loop join instead of hash join on the cve_account_granular_cache table. This led to multiple rows to be compared and filtered out. By moving GROUP BY into the inner subquery and isolating group filtering into a separate IN subquery, we reduce the number of rows joined, which leads to more performatiove query and planner can (hopefully) better estimate the number of rows as well.

Secure Coding Practices Checklist GitHub Link

Secure Coding Checklist

  • Input Validation
  • Output Encoding
  • Authentication and Password Management
  • Session Management
  • Access Control
  • Cryptographic Practices
  • Error Handling and Logging
  • Data Protection
  • Communication Security
  • System Configuration
  • Database Security
  • File Management
  • Memory Management
  • General Coding Practices

Summary by Sourcery

Optimize CVE vulnerabilities query planning by pre-aggregating cached counts and isolating group and OS filters into the cached subquery.

Enhancements:

  • Introduce a pre-aggregated execution path for the cached CVE count subquery to reduce joined rows and improve Postgres planner estimates.
  • Move operating system joins, RHEL version aggregation, and advisory aggregation into the granular cached subquery to return fully aggregated per-CVE metrics.
  • Refine application of inventory group and RHEL version filters so that group filtering is done via a dedicated subquery and RHEL filters are applied at the appropriate query level.

Postgres planner misestimated row count due to workspaces selectivity, choosing a nested loop join instead of hash join on the cve_account_granular_cache table. This led to multiple rows to be compared and filtered out. By moving GROUP BY into the inner subquery and isolating group filtering into a separate IN subquery, we reduce the number of rows joined, which leads to more performatiove query and planner can (hopefully) better estimate the number of rows as well.
@sourcery-ai

sourcery-ai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Reviewer's Guide

Refactors the vulnerabilities manager CVE query to support a pre-aggregated cached path that aggregates per‑CVE metrics and RHEL versions inside the granular cache subquery, adjusts joins and filters accordingly, and preserves the existing aggregation path for non‑cached queries.

Entity relationship diagram for pre-aggregated granular cache subquery

erDiagram
    CveAccountGranularCache {
        int cve_id
        int group_set_id
        int operating_system_id
        int systems_affected_rpmdnf
        int systems_affected_edge
        int systems_status_divergent
    }

    SystemGroupSet {
        int id
        jsonb groups
    }

    OperatingSystem {
        int id
        text rhel_version
    }

    CveAccountGranularCache ||--|| SystemGroupSet : group_set_id
    CveAccountGranularCache }o--|| OperatingSystem : operating_system_id

    %% group_set_subquery selects SystemGroupSet.id filtered by groups
    %% _granular_cached_count_subquery aggregates per CveAccountGranularCache.cve_id and uses IN on group_set_id
Loading

File-Level Changes

Change Details Files
Add a pre-aggregated execution path for cached CVE queries and adjust filter application.
  • When a request is served from cache, build the count subquery with _granular_cached_count_subquery and call _full_query with pre_aggregated=True.
  • For cached requests, apply all filters except the RHEL version filter at the outer query level, deferring RHEL version filtering to the cached subquery.
  • Keep the existing non-cached/unpatched paths using _count_subquery and _unpatched_count_subquery, still calling _full_query with pre_aggregated=False, then applying the full filter set.
manager/vulnerabilities_handler.py
Make _full_query support both pre-aggregated and non-aggregated count subqueries.
  • Introduce a pre_aggregated boolean parameter to _full_query that selects between using raw aggregated columns from the count subquery or computing aggregates in the outer query.
  • In pre-aggregated mode, read systems_affected, systems_status_divergent, advisory_available, and rhel_versions directly from the count subquery without additional aggregation or OperatingSystem join.
  • In non-aggregated mode, retain the previous behavior: sum system counts, aggregate divergence and advisory flags, compute distinct ordered rhel_versions, join OperatingSystem, and group by CVE and account fields only in this mode.
manager/vulnerabilities_handler.py
Refactor _granular_cached_count_subquery to pre-aggregate per-CVE metrics and isolate group filtering into an inner subquery.
  • Introduce a group_set_subquery that selects allowed SystemGroupSet.id values, applies group-based filters (INVENTORY_GROUP_IDS, INVENTORY_GROUP_NAMES), and is used in an IN condition to restrict the main cache query, improving planner selectivity.
  • Modify the cache query to aggregate per-CVE by grouping on CveAccountGranularCache.cve_id and computing sums for systems_affected_rpmdnf, systems_affected_edge, systems_status_divergent, and a Bool_Or of advisory_available_column.
  • Join OperatingSystem inside the cached query and compute a distinct, ordered rhel_versions_ array there, so the outer query can consume precomputed RHEL version lists.
  • Apply the CVE_RHEL_VERSION filter inside the cached subquery instead of on the outer query, and remove the previous direct join to SystemGroupSet and group filters from the main cached query.
manager/vulnerabilities_handler.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@pindo696

pindo696 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

@sourcery-ai review

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • Consider removing the commented-out group_set_subquery = group_set_subquery.offset(0) line or adding a brief explanation if it’s intentionally left as a placeholder, to avoid confusion about dead/debug code.
  • The special handling of CVE_RHEL_VERSION (being filtered inside the cached subquery but outside for non-cached) is now spread across multiple places; it may be worth centralizing this logic or clearly documenting it to reduce the risk of the two paths diverging in future changes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider removing the commented-out `group_set_subquery = group_set_subquery.offset(0)` line or adding a brief explanation if it’s intentionally left as a placeholder, to avoid confusion about dead/debug code.
- The special handling of `CVE_RHEL_VERSION` (being filtered inside the cached subquery but outside for non-cached) is now spread across multiple places; it may be worth centralizing this logic or clearly documenting it to reduce the risk of the two paths diverging in future changes.

## Individual Comments

### Comment 1
<location path="manager/vulnerabilities_handler.py" line_range="183" />
<code_context>
-                          fn.COALESCE(BusinessRisk.name, DEFAULT_BUSINESS_RISK).alias("business_risk"),
-                          fn.COALESCE(CveAccountData.status_id, 0).alias("status_id"),
-                          CveAccountData.status_text.alias("status_text")))
+    def _full_query(rh_account_id, join_type, count_subquery, pre_aggregated=False):
+        if pre_aggregated:
+            systems_affected = (fn.COALESCE(count_subquery.c.systems_affected_rpmdnf_, 0) +
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Align advisory-availability semantics between pre-aggregated and non-pre-aggregated modes.

In the non‑pre‑aggregated path, `advisory_available` uses `Bool_Or(...)` over joined rows, but in the pre‑aggregated path you `COALESCE(count_subquery.c.advisory_available_, ...)`. Since `_granular_cached_count_subquery` now does `Bool_Or(advisory_available_column)`, this should usually match, but the behavior can diverge if the subquery returns NULL or no rows (e.g., after `WHERE` filtering). Consider making both paths structurally consistent and explicitly aligning how "no rows" vs "all false" are handled so cached and uncached results stay in sync.

Suggested implementation:

```python
        if pre_aggregated:
            systems_affected = (fn.COALESCE(count_subquery.c.systems_affected_rpmdnf_, 0) +
                                fn.COALESCE(count_subquery.c.systems_affected_edge_, 0)).alias("systems_affected")
            systems_status_divergent = fn.COALESCE(count_subquery.c.systems_status_divergent_, 0).alias("systems_status_divergent")
            advisory_available = fn.Bool_Or(
                fn.COALESCE(
                    count_subquery.c.advisory_available_,
                    fn.COALESCE(CveMetadata.advisories_list, '[]') != SQL("'[]'")
                )
            ).alias("advisory_available")
            rhel_versions = fn.COALESCE(count_subquery.c.rhel_versions_, "{}").alias("rhel_versions")
        else:
            systems_affected = fn.Sum((fn.COALESCE(count_subquery.c.systems_affected_rpmdnf_, 0) +
                                       fn.COALESCE(count_subquery.c.systems_affected_edge_, 0))).alias("systems_affected")
            systems_status_divergent = fn.Sum(fn.COALESCE(count_subquery.c.systems_status_divergent_, 0)).alias("systems_status_divergent")
            advisory_available = fn.Bool_Or(
                fn.COALESCE(
                    count_subquery.c.advisory_available_,
                    fn.COALESCE(CveMetadata.advisories_list, '[]') != SQL("'[]'")
                )
            ).alias("advisory_available")

```

1. This change assumes `_granular_cached_count_subquery` is already aggregating `advisory_available_` with `Bool_Or` so that using `Bool_Or` again in the pre-aggregated branch is idempotent (single-row aggregation). If that assumption is incorrect and `advisory_available_` is no longer aggregated in the cached subquery, you may want to keep the pre-aggregated path as a plain `COALESCE` without `Bool_Or`.
2. If you need stricter handling of the “no rows” case (e.g., explicitly falling back to `CveMetadata.advisories_list` when the join produces no count-subquery rows), you’ll need to verify the join type (`join_type`) and possibly adjust the join or add additional `COALESCE` around the outer `Bool_Or` to enforce a default value.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

fn.COALESCE(BusinessRisk.name, DEFAULT_BUSINESS_RISK).alias("business_risk"),
fn.COALESCE(CveAccountData.status_id, 0).alias("status_id"),
CveAccountData.status_text.alias("status_text")))
def _full_query(rh_account_id, join_type, count_subquery, pre_aggregated=False):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (bug_risk): Align advisory-availability semantics between pre-aggregated and non-pre-aggregated modes.

In the non‑pre‑aggregated path, advisory_available uses Bool_Or(...) over joined rows, but in the pre‑aggregated path you COALESCE(count_subquery.c.advisory_available_, ...). Since _granular_cached_count_subquery now does Bool_Or(advisory_available_column), this should usually match, but the behavior can diverge if the subquery returns NULL or no rows (e.g., after WHERE filtering). Consider making both paths structurally consistent and explicitly aligning how "no rows" vs "all false" are handled so cached and uncached results stay in sync.

Suggested implementation:

        if pre_aggregated:
            systems_affected = (fn.COALESCE(count_subquery.c.systems_affected_rpmdnf_, 0) +
                                fn.COALESCE(count_subquery.c.systems_affected_edge_, 0)).alias("systems_affected")
            systems_status_divergent = fn.COALESCE(count_subquery.c.systems_status_divergent_, 0).alias("systems_status_divergent")
            advisory_available = fn.Bool_Or(
                fn.COALESCE(
                    count_subquery.c.advisory_available_,
                    fn.COALESCE(CveMetadata.advisories_list, '[]') != SQL("'[]'")
                )
            ).alias("advisory_available")
            rhel_versions = fn.COALESCE(count_subquery.c.rhel_versions_, "{}").alias("rhel_versions")
        else:
            systems_affected = fn.Sum((fn.COALESCE(count_subquery.c.systems_affected_rpmdnf_, 0) +
                                       fn.COALESCE(count_subquery.c.systems_affected_edge_, 0))).alias("systems_affected")
            systems_status_divergent = fn.Sum(fn.COALESCE(count_subquery.c.systems_status_divergent_, 0)).alias("systems_status_divergent")
            advisory_available = fn.Bool_Or(
                fn.COALESCE(
                    count_subquery.c.advisory_available_,
                    fn.COALESCE(CveMetadata.advisories_list, '[]') != SQL("'[]'")
                )
            ).alias("advisory_available")
  1. This change assumes _granular_cached_count_subquery is already aggregating advisory_available_ with Bool_Or so that using Bool_Or again in the pre-aggregated branch is idempotent (single-row aggregation). If that assumption is incorrect and advisory_available_ is no longer aggregated in the cached subquery, you may want to keep the pre-aggregated path as a plain COALESCE without Bool_Or.
  2. If you need stricter handling of the “no rows” case (e.g., explicitly falling back to CveMetadata.advisories_list when the join produces no count-subquery rows), you’ll need to verify the join type (join_type) and possibly adjust the join or add additional COALESCE around the outer Bool_Or to enforce a default value.

@pindo696
pindo696 marked this pull request as ready for review August 4, 2026 09:59
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.

1 participant