Skip to content

perf: api/v3/license query performance#2497

Queued
rh-jfuller wants to merge 1 commit into
guacsec:mainfrom
rh-jfuller:fix/license-list-slow-query
Queued

perf: api/v3/license query performance#2497
rh-jfuller wants to merge 1 commit into
guacsec:mainfrom
rh-jfuller:fix/license-list-slow-query

Conversation

@rh-jfuller

@rh-jfuller rh-jfuller commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

The query in api/v3/license is GET /v3/license (license/service/mod.rs:318-328) eg. the NOT EXISTS subquery in:

WHERE NOT EXISTS(
  SELECT $1 FROM "sbom_license_expanded"
  WHERE "sbom_license_expanded"."license_id" = "license"."id"
)

is very slow and ties up postgres resources.

The sbom_license_expanded table has a composite primary key (sbom_id, license_id).

In a B-tree composite index, the leading column is sbom_id which a correlated lookup on license_id alone cannot use this index.

Postgres is falling back to a sequential scan of the entire sbom_license_expanded table for every row in license. That is an O(N*M) nested loop with no index support, explaining the 277-second execution time!

The fix is to add index:

Before

EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT DISTINCT "expanded_license"."expanded_text" AS "text"
FROM "expanded_license"
UNION
(SELECT DISTINCT "license"."text" AS "text"
 FROM "license"
 WHERE NOT EXISTS(
   SELECT 1 FROM "sbom_license_expanded"
   WHERE "sbom_license_expanded"."license_id" = "license"."id"
 ))
ORDER BY text ASC
LIMIT 10 OFFSET 0;
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| QUERY PLAN                                                                                                                                                                                                          |
|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Limit  (cost=36963.58..36963.63 rows=10 width=32) (actual time=277495.012..277495.022 rows=10 loops=1)                                                                                                              |
|   Buffers: shared hit=117804069                                                                                                                                                                                     |
|   ->  Unique  (cost=36963.58..36988.75 rows=5034 width=32) (actual time=277495.011..277495.019 rows=10 loops=1)                                                                                                     |
|         Buffers: shared hit=117804069                                                                                                                                                                               |
|         ->  Sort  (cost=36963.58..36976.16 rows=5034 width=32) (actual time=277495.010..277495.014 rows=10 loops=1)                                                                                                 |
|               Sort Key: expanded_license.expanded_text                                                                                                                                                              |
|               Sort Method: quicksort  Memory: 97kB                                                                                                                                                                  |
|               Buffers: shared hit=117804069                                                                                                                                                                         |
|               ->  Append  (cost=83.33..36654.05 rows=5034 width=32) (actual time=0.896..277485.154 rows=3415 loops=1)                                                                                               |
|                     Buffers: shared hit=117803573                                                                                                                                                                   |
|                     ->  HashAggregate  (cost=83.33..110.78 rows=2746 width=62) (actual time=0.896..1.162 rows=2746 loops=1)                                                                                         |
|                           Group Key: expanded_license.expanded_text                                                                                                                                                 |
|                           Batches: 1  Memory Usage: 625kB                                                                                                                                                           |
|                           Buffers: shared hit=57                                                                                                                                                                    |
|                           ->  Seq Scan on expanded_license  (cost=0.00..76.46 rows=2746 width=62) (actual time=0.009..0.202 rows=2746 loops=1)                                                                      |
|                                 Buffers: shared hit=49                                                                                                                                                              |
|                     ->  Subquery Scan on "*SELECT* 2"  (cost=36483.78..36518.10 rows=2288 width=32) (actual time=277483.508..277483.731 rows=669 loops=1)                                                           |
|                           Buffers: shared hit=117803516                                                                                                                                                             |
|                           ->  Unique  (cost=36483.78..36495.22 rows=2288 width=62) (actual time=277483.505..277483.654 rows=669 loops=1)                                                                            |
|                                 Buffers: shared hit=117803516                                                                                                                                                       |
|                                 ->  Sort  (cost=36483.78..36489.50 rows=2288 width=62) (actual time=277483.503..277483.539 rows=669 loops=1)                                                                        |
|                                       Sort Key: license.text                                                                                                                                                        |
|                                       Sort Method: quicksort  Memory: 25kB                                                                                                                                          |
|                                       Buffers: shared hit=117803516                                                                                                                                                 |
|                                       ->  Nested Loop Anti Join  (cost=0.56..36356.11 rows=2288 width=62) (actual time=276.649..277481.399 rows=669 loops=1)                                                        |
|                                             Buffers: shared hit=117803478                                                                                                                                           |
|                                             ->  Seq Scan on license  (cost=0.00..100.33 rows=3333 width=78) (actual time=0.006..2.721 rows=3333 loops=1)                                                            |
|                                                   Buffers: shared hit=67                                                                                                                                            |
|                                             ->  Index Only Scan using sbom_license_expanded_pkey on sbom_license_expanded  (cost=0.56..95838.38 rows=12208 width=16) (actual time=83.250..83.250 rows=1 loops=3333) |
|                                                   Index Cond: (license_id = license.id)                                                                                                                             |
|                                                   Heap Fetches: 0                                                                                                                                                   |
|                                                   Buffers: shared hit=117803411                                                                                                                                     |
| Planning:                                                                                                                                                                                                           |
|   Buffers: shared hit=22                                                                                                                                                                                            |
| Planning Time: 0.254 ms                                                                                                                                                                                             |
| Execution Time: 277495.071 ms                                                                                                                                                                                       |
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
EXPLAIN 36


After

EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT DISTINCT "expanded_license"."expanded_text" AS "text"
FROM "expanded_license"
UNION
(SELECT DISTINCT "license"."text" AS "text"
 FROM "license"
 WHERE NOT EXISTS(
   SELECT 1 FROM "sbom_license_expanded"
   WHERE "sbom_license_expanded"."license_id" = "license"."id"
 ))
ORDER BY text ASC
LIMIT 10 OFFSET 0;
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| QUERY PLAN                                                                                                                                                                                            |
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Limit  (cost=2007.76..2007.78 rows=10 width=32) (actual time=10.791..10.795 rows=10 loops=1)                                                                                                          |
|   Buffers: shared hit=10640                                                                                                                                                                           |
|   ->  Sort  (cost=2007.76..2020.30 rows=5017 width=32) (actual time=10.790..10.792 rows=10 loops=1)                                                                                                   |
|         Sort Key: expanded_license.expanded_text                                                                                                                                                      |
|         Sort Method: still in progress  Memory: 0kB                                                                                                                                                   |
|         Buffers: shared hit=10640                                                                                                                                                                     |
|         ->  HashAggregate  (cost=1849.17..1899.34 rows=5017 width=32) (actual time=9.771..10.150 rows=3407 loops=1)                                                                                   |
|               Group Key: expanded_license.expanded_text                                                                                                                                               |
|               Batches: 1  Memory Usage: 721kB                                                                                                                                                         |
|               Buffers: shared hit=10630                                                                                                                                                               |
|               ->  Append  (cost=83.33..1836.63 rows=5017 width=32) (actual time=0.887..9.012 rows=3415 loops=1)                                                                                       |
|                     Buffers: shared hit=10620                                                                                                                                                         |
|                     ->  HashAggregate  (cost=83.33..110.78 rows=2746 width=62) (actual time=0.886..1.155 rows=2746 loops=1)                                                                           |
|                           Group Key: expanded_license.expanded_text                                                                                                                                   |
|                           Batches: 1  Memory Usage: 625kB                                                                                                                                             |
|                           Buffers: shared hit=57                                                                                                                                                      |
|                           ->  Seq Scan on expanded_license  (cost=0.00..76.46 rows=2746 width=62) (actual time=0.010..0.202 rows=2746 loops=1)                                                        |
|                                 Buffers: shared hit=49                                                                                                                                                |
|                     ->  Subquery Scan on "*SELECT* 2"  (cost=1655.34..1700.76 rows=2271 width=32) (actual time=7.425..7.593 rows=669 loops=1)                                                         |
|                           Buffers: shared hit=10563                                                                                                                                                   |
|                           ->  HashAggregate  (cost=1655.34..1678.05 rows=2271 width=62) (actual time=7.422..7.520 rows=669 loops=1)                                                                   |
|                                 Group Key: license.text                                                                                                                                               |
|                                 Batches: 1  Memory Usage: 241kB                                                                                                                                       |
|                                 Buffers: shared hit=10563                                                                                                                                             |
|                                 ->  Nested Loop Anti Join  (cost=0.43..1649.66 rows=2271 width=62) (actual time=0.013..7.239 rows=669 loops=1)                                                        |
|                                       Buffers: shared hit=10561                                                                                                                                       |
|                                       ->  Seq Scan on license  (cost=0.00..100.33 rows=3333 width=78) (actual time=0.006..0.295 rows=3333 loops=1)                                                    |
|                                             Buffers: shared hit=67                                                                                                                                    |
|                                       ->  Index Only Scan using idx_sle_license_id on sbom_license_expanded  (cost=0.43..214.28 rows=12012 width=16) (actual time=0.002..0.002 rows=1 loops=3333) |
|                                             Index Cond: (license_id = license.id)                                                                                                                     |
|                                             Heap Fetches: 0                                                                                                                                           |
|                                             Buffers: shared hit=10494                                                                                                                                 |
| Planning:                                                                                                                                                                                             |
|   Buffers: shared hit=14                                                                                                                                                                              |
| Planning Time: 0.214 ms                                                                                                                                                                               |
| Execution Time: 10.848 ms                                                                                                                                                                             |
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
EXPLAIN 36

going from 277 seconds to 10.8 ms is not too shabby ;)

Summary by Sourcery

Enhancements:

  • Introduce a new migration that creates and drops an index on sbom_license_expanded(license_id) to optimize NOT EXISTS lookups in the license API.

@rh-jfuller rh-jfuller self-assigned this Jul 14, 2026
@rh-jfuller rh-jfuller added the backport release/0.5.z Backport (0.5.z) label Jul 14, 2026
@sourcery-ai

sourcery-ai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

This PR adds a dedicated index on sbom_license_expanded.license_id via a new migration to dramatically improve the NOT EXISTS anti-join used by GET /v3/license, reducing query time from ~277s to ~11ms while keeping the schema otherwise unchanged.

Sequence diagram for GET v3 license using idx_sle_license_id

sequenceDiagram
    actor Client
    participant ApiService as Api_v3_license
    participant Postgres

    Client->>ApiService: GET /v3/license
    ApiService->>Postgres: SELECT DISTINCT license.text
    Postgres-->>ApiService: NestedLoopAntiJoin
    ApiService->>Postgres: Index Only Scan using idx_sle_license_id
    Postgres-->>ApiService: rows with NOT EXISTS sbom_license_expanded
    ApiService-->>Client: license texts (fast response)
Loading

Entity relationship diagram for sbom_license_expanded license_id index

erDiagram
    license {
        int id pk
        string text
    }

    sbom_license_expanded {
        int sbom_id pk
        int license_id pk
        string idx_sle_license_id "index on license_id"
    }

    license ||--o{ sbom_license_expanded : "license_id"
Loading

File-Level Changes

Change Details Files
Add a new migration that creates an index on sbom_license_expanded(license_id) to optimize the NOT EXISTS anti-join in the /v3/license API query.
  • Register a new migration module m0002230_sle_license_id_index in the migration library and the MigratorExt implementation.
  • Implement MigrationTrait::up to CREATE INDEX IF NOT EXISTS idx_sle_license_id ON sbom_license_expanded (license_id) using an unprepared SQL statement.
  • Implement MigrationTrait::down to DROP INDEX IF EXISTS idx_sle_license_id to allow rollback of the index.
migration/src/lib.rs
migration/src/m0002230_sle_license_id_index.rs

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

@sourcery-ai sourcery-ai 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.

Hey - I've left some high level feedback:

  • Consider using the SeaORM migration DSL (e.g., manager.create_index(...)) instead of execute_unprepared so the index definition stays type-safe and consistent with other migrations.
  • The index name idx_sle_license_id differs from the tmp_idx_sle_license_id shown in the explain plan in the description; aligning these names (or updating the description) will help avoid confusion when debugging.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider using the SeaORM migration DSL (e.g., `manager.create_index(...)`) instead of `execute_unprepared` so the index definition stays type-safe and consistent with other migrations.
- The index name `idx_sle_license_id` differs from the `tmp_idx_sle_license_id` shown in the explain plan in the description; aligning these names (or updating the description) will help avoid confusion when debugging.

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.

@rh-jfuller rh-jfuller requested a review from a team July 14, 2026 09:43
@ctron ctron force-pushed the fix/license-list-slow-query branch from b307cc6 to 884f252 Compare July 14, 2026 09:50
@ctron ctron enabled auto-merge July 14, 2026 09:50
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 71.68%. Comparing base (3eba74d) to head (6538864).
⚠️ Report is 7 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2497      +/-   ##
==========================================
+ Coverage   71.66%   71.68%   +0.01%     
==========================================
  Files         453      454       +1     
  Lines       27517    27525       +8     
  Branches    27517    27525       +8     
==========================================
+ Hits        19719    19730      +11     
+ Misses       6667     6657      -10     
- Partials     1131     1138       +7     

☔ View full report in Codecov by Harness.
📢 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.

@ctron ctron force-pushed the fix/license-list-slow-query branch from 884f252 to 6538864 Compare July 14, 2026 12:24
@ctron

ctron commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

@sourcery-ai review

@sourcery-ai sourcery-ai 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.

Hey - I've left some high level feedback:

  • Consider using the schema builder (Index::create / Index::drop) instead of execute_unprepared so the migration stays consistent with the rest of the migration code and benefits from compile-time structure and database abstraction.
  • Using CREATE INDEX IF NOT EXISTS and DROP INDEX IF EXISTS in a migration can hide unexpected schema drift; if this index is required for performance, you may want the migration to fail loudly instead of silently skipping when the index state is different than expected.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider using the schema builder (`Index::create` / `Index::drop`) instead of `execute_unprepared` so the migration stays consistent with the rest of the migration code and benefits from compile-time structure and database abstraction.
- Using `CREATE INDEX IF NOT EXISTS` and `DROP INDEX IF EXISTS` in a migration can hide unexpected schema drift; if this index is required for performance, you may want the migration to fail loudly instead of silently skipping when the index state is different than expected.

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.

@ctron ctron added this pull request to the merge queue Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport release/0.5.z Backport (0.5.z)

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants