Materialize ancestor information [Backport release/0.5.z]#2414
Conversation
(cherry picked from commit fb539d7)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 5eb989b)
Co-authored-by: James Fuller <jfuller@jfuller-thinkpadp16vgen1.rmtcz.csb> (cherry picked from commit 6d35c64)
Building a condition like this causes a stack overflow when serialzing the SQL. Passing in arrays and using UNNEST prevents this. Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit f041141)
(cherry picked from commit 64413b9)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 67bc0e5)
Instead of using IN () and "too many" parameters for psql, we use "ANY" and an array to check. Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 9532fb1)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit f91e19f)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit b297c88)
(cherry picked from commit fd3b925)
Co-authored-by: James Fuller <jfuller@jfuller-thinkpadp16vgen1.rmtcz.csb> (cherry picked from commit b204c23)
Co-authored-by: James Fuller <jfuller@jfuller-thinkpadp16vgen1.rmtcz.csb> (cherry picked from commit 11685fa)
Adds a configurable cutoff (TRUSTIFY_LATEST_SBOM_PUBLISHED_CUTOFF_DAYS) that excludes SBOMs with a published date older than N days when resolving the latest SBOM for a component. Defaults to 1000 days. Also fixes a missing `tokio` feature on async-compression in trustify-common and migrates the analysis module from chrono to time for the Row/RankedSbom date fields. Co-Authored-By: JimFuller-RedHat <jfuller@redhat.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> (cherry picked from commit 70496b6)
(cherry picked from commit 8710bcb)
Co-authored-by: Claude <noreply@anthropic.com> Author: JimFuller-RedHat <jfuller@redhat.com> (cherry picked from commit 0d28018)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit cfe4304)
Author: JimFuller-RedHat <jfuller@redhat.com> (cherry picked from commit 9bd79c2)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 42ca3ec)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 2ba4f2e)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit fdc8d41)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 93a2016)
(cherry picked from commit f1eb44e)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit b59667b)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 61b9ffc)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 4555717)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit c70dfa2)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 3081a3f)
(cherry picked from commit 7793044)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 255ade3)
Traversing the nodes and chasing external documents might require loading those documents. So far, this used on DB connection each, as we've been passing in the connection pool. This may lead to pool exhaustion and the API call failing. We now pass in a read-only transaction, which will lead to the DB connection acting as a mutex, only consuming one connection. It also adds a test ensuring that's the case. Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 2671c73)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit ca57735)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 6d682b8)
Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit ef47473)
The node_map in batch_resolve_direct_cpe_matches was keyed by node_id alone, causing silent HashMap collisions when multiple SBOMs shared the same node_id string (e.g. common bom-refs like "shared-ref"). Collect all matching sbom_node entries per node_id and prefer a match from a non-source SBOM to avoid picking up the source's own external-node entry. Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 7d8b145)
The field is only used in tests for debugging and assertions, not by production code. Gate it behind #[cfg(test)] to avoid carrying unused data in release builds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> (cherry picked from commit 52eef22)
actix_web::Result is just an alias for std::result::Result with a default error type. Using it with an explicit error argument is redundant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> (cherry picked from commit 58b0893)
Add three tests ensuring that deleting an SBOM properly cascades to the materialized sbom_describing_cpe and sbom_ancestor tables. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> (cherry picked from commit 138f953)
Also, document the RH linking process. Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 7eab4e9)
… lookup The candidate selection in batch_resolve_direct_cpe_matches was insertion-order dependent when multiple SBOMs shared the same node_id. LEFT JOIN sbom_ancestor into the node lookup query so candidates linked to the product SBOM via checksum-verified ancestor relationships are preferred over arbitrary same-node_id matches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> (cherry picked from commit c7ef9af)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> (cherry picked from commit c78742f)
(cherry picked from commit df7431a)
Exercises the full ingest → verify → delete → re-ingest cycle, confirming that sbom_ancestor links and the latest endpoint stay consistent across document additions and removals. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> (cherry picked from commit b5c7762)
Reviewer's GuideBackport of the ancestor materialization and latest-SBOM ranking rework: CPE/context resolution is moved from recursive in-memory graph walks to materialized tables and batched SQL, graph loading is made concurrent and connection-safe, new migrations/entities for sbom_ancestor and sbom_describing_cpe are added, ingestion populates these tables, and APIs/tests/configs are updated accordingly. Sequence diagram for latest SBOM CPE resolution using materialized ancestrysequenceDiagram
participant Client
participant AnalysisService
participant InnerService
participant resolve_sbom_cpes
participant batch_resolve_direct_cpe_matches
participant batch_resolve_ancestor_cpes
participant DB
Client->>AnalysisService: retrieve_latest(...)
AnalysisService->>InnerService: retrieve(...)
InnerService->>DB: sbom_node::Entity::find() ... (matching sboms)
DB-->>InnerService: Vec<Row>
InnerService->>resolve_sbom_cpes: resolve_sbom_cpes(cpe_search, connection, rows)
alt cpe_search == true
resolve_sbom_cpes->>batch_resolve_direct_cpe_matches: batch_resolve_direct_cpe_matches(rows, connection)
batch_resolve_direct_cpe_matches->>DB: query sbom_external_node + sbom_describing_cpe
DB-->>batch_resolve_direct_cpe_matches: external_node_ref, cpe_ids
batch_resolve_direct_cpe_matches->>DB: lookup nodes via sbom_node + sbom_ancestor
DB-->>batch_resolve_direct_cpe_matches: candidate nodes
batch_resolve_direct_cpe_matches-->>resolve_sbom_cpes: Vec<RankedSbom> (direct)
end
resolve_sbom_cpes->>batch_resolve_ancestor_cpes: batch_resolve_ancestor_cpes(sbom_ids, connection)
batch_resolve_ancestor_cpes->>DB: query sbom_ancestor + sbom_describing_cpe (phase2a)
DB-->>batch_resolve_ancestor_cpes: AncestorCpeRow (direct)
batch_resolve_ancestor_cpes->>DB: recursive CTE on sbom_ancestor + sbom_describing_cpe (phase2b)
DB-->>batch_resolve_ancestor_cpes: AncestorCpeRow (recursive)
batch_resolve_ancestor_cpes-->>resolve_sbom_cpes: (phase2a, phase2b)
resolve_sbom_cpes-->>InnerService: Vec<RankedSbom>
InnerService->>InnerService: apply_rank(&mut ranked_sboms)
InnerService->>InnerService: filter rank == 1
InnerService->>InnerService: load_graphs(connection, latest_ids)
InnerService->>DB: sbom_node / package_relates_to_package (per SBOM)
DB-->>InnerService: graphs
InnerService-->>AnalysisService: Vec<(Uuid, PackageGraph)>
AnalysisService-->>Client: response with latest SBOMs
Entity relationship diagram for materialized SBOM ancestry and describing CPEserDiagram
sbom {
UUID sbom_id PK
}
sbom_describing_cpe {
UUID sbom_id PK
UUID cpe_id PK
}
sbom_ancestor {
UUID sbom_id PK
UUID ancestor_sbom_id PK
}
sbom_node_cpe_ref {
UUID sbom_id
UUID cpe_id
string node_id
}
sbom_node_checksum {
UUID sbom_id
string node_id
string value
}
sbom_external_node {
UUID sbom_id
string node_id
string external_node_ref
int external_type
}
sbom ||--o{ sbom_describing_cpe : has
sbom ||--o{ sbom_ancestor : has_child
sbom ||--o{ sbom_ancestor : has_ancestor
sbom ||--o{ sbom_node_cpe_ref : has
sbom ||--o{ sbom_node_checksum : has
sbom ||--o{ sbom_external_node : has
sbom_node_cpe_ref }o--|| sbom_describing_cpe : materialized_from
sbom_node_checksum }o--o{ sbom_external_node : checksum_link
sbom_node_checksum }o--|| sbom_ancestor : derives_child_ancestor
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- The
Describesrelationship is hard-coded as13in multiple raw SQL snippets (e.g.populate_describing_cpes,CONTEXT_CPE_FILTER_SQL,product_advisory_info_sql); consider introducing a shared constant or helper to derive this fromRelationship::Describesto avoid drift if the enum changes. - In
batch_resolve_ancestor_cpesthe recursive CTE limits depth with a hard-coded< 10; it would be safer either to make this bound configurable or at least centralize it as a named constant with a comment explaining why 10 is appropriate for expected ancestor chain lengths. - In
resolve_sbom_cpesthe dedup set usesHashSet<(Uuid, String)>and callsmatched.name.clone()inside the loop for membership checks; you could avoid repeated allocations on this potentially hot path by keying the set on IDs only or using(&Uuid, &str)(e.g. via an interning map or by populating the set after building the finalresults).
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The `Describes` relationship is hard-coded as `13` in multiple raw SQL snippets (e.g. `populate_describing_cpes`, `CONTEXT_CPE_FILTER_SQL`, `product_advisory_info_sql`); consider introducing a shared constant or helper to derive this from `Relationship::Describes` to avoid drift if the enum changes.
- In `batch_resolve_ancestor_cpes` the recursive CTE limits depth with a hard-coded `< 10`; it would be safer either to make this bound configurable or at least centralize it as a named constant with a comment explaining why 10 is appropriate for expected ancestor chain lengths.
- In `resolve_sbom_cpes` the dedup set uses `HashSet<(Uuid, String)>` and calls `matched.name.clone()` inside the loop for membership checks; you could avoid repeated allocations on this potentially hot path by keying the set on IDs only or using `(&Uuid, &str)` (e.g. via an interning map or by populating the set after building the final `results`).
## Individual Comments
### Comment 1
<location path="modules/analysis/src/service/load/rank.rs" line_range="360-75" />
<code_context>
+
+ // Build dedup set from Phase 1 + Phase 2a using owned strings
+ // to avoid borrowing `results` while we push to it below
+ let existing: HashSet<(Uuid, String)> = results
+ .iter()
+ .map(|r| (r.cpe_id, r.matched_name.clone()))
+ .collect();
+
+ // Phase 2b results are deduped against the combined set — only
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Phase 2b dedup only considers pre-existing results, so duplicates within Phase 2b are still possible
`existing` is built once from `results` after Phases 1/2a and never updated as Phase 2b pushes new rows. If the recursive CTE yields the same `(cpe_id, matched_name)` multiple times (e.g., via different ancestor paths), all of them will be appended because only the first is checked against `existing`. If Phase 2b-internal duplicates are unwanted, consider updating `existing` after each insert or tracking a separate Phase 2b-local set.
Suggested implementation:
```rust
// Build dedup set from Phase 1 + Phase 2a using owned strings
// to avoid borrowing `results` while we push to it below. This set
// is updated as Phase 2b inserts new rows so that Phase 2b-internal
// duplicates are also filtered out.
let mut existing: HashSet<(Uuid, String)> = results
.iter()
.map(|r| (r.cpe_id, r.matched_name.clone()))
.collect();
```
```rust
for ancestor_cpe in &phase2b {
if let Some(matched_rows) = rows_by_sbom.get(&ancestor_cpe.sbom_id) {
for matched in matched_rows {
if existing.contains(&(ancestor_cpe.cpe_id, matched.name.clone())) {
continue;
}
```
To fully implement Phase 2b-internal deduplication, you also need to update the Phase 2b insertion logic (in the same `for ancestor_cpe in &phase2b` loop):
1. After you push the new result into `results` (e.g., `results.push(...)` for Phase 2b), insert the corresponding `(cpe_id, matched_name)` into `existing`, for example:
```rust
let matched_name = matched.name.clone();
if existing.contains(&(ancestor_cpe.cpe_id, matched_name.clone())) {
continue;
}
// Existing Phase 2b push; adapt field names to your struct
results.push(RankedMatch {
cpe_id: ancestor_cpe.cpe_id,
matched_name: matched_name.clone(),
// ... other fields ...
});
// Update dedup set so later Phase 2b rows also see this entry
existing.insert((ancestor_cpe.cpe_id, matched_name));
```
2. Ensure `matched_name` in the insert matches the field used in the struct (`matched_name` or whatever the field is actually called). This guarantees that duplicates produced solely within Phase 2b are filtered out, not just duplicates against Phases 1/2a.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| .map(|r| r.sbom_id) | ||
| .collect::<HashSet<_>>() | ||
| .into_iter() | ||
| .collect(); |
There was a problem hiding this comment.
suggestion (bug_risk): Phase 2b dedup only considers pre-existing results, so duplicates within Phase 2b are still possible
existing is built once from results after Phases 1/2a and never updated as Phase 2b pushes new rows. If the recursive CTE yields the same (cpe_id, matched_name) multiple times (e.g., via different ancestor paths), all of them will be appended because only the first is checked against existing. If Phase 2b-internal duplicates are unwanted, consider updating existing after each insert or tracking a separate Phase 2b-local set.
Suggested implementation:
// Build dedup set from Phase 1 + Phase 2a using owned strings
// to avoid borrowing `results` while we push to it below. This set
// is updated as Phase 2b inserts new rows so that Phase 2b-internal
// duplicates are also filtered out.
let mut existing: HashSet<(Uuid, String)> = results
.iter()
.map(|r| (r.cpe_id, r.matched_name.clone()))
.collect(); for ancestor_cpe in &phase2b {
if let Some(matched_rows) = rows_by_sbom.get(&ancestor_cpe.sbom_id) {
for matched in matched_rows {
if existing.contains(&(ancestor_cpe.cpe_id, matched.name.clone())) {
continue;
}To fully implement Phase 2b-internal deduplication, you also need to update the Phase 2b insertion logic (in the same for ancestor_cpe in &phase2b loop):
- After you push the new result into
results(e.g.,results.push(...)for Phase 2b), insert the corresponding(cpe_id, matched_name)intoexisting, for example:
let matched_name = matched.name.clone();
if existing.contains(&(ancestor_cpe.cpe_id, matched_name.clone())) {
continue;
}
// Existing Phase 2b push; adapt field names to your struct
results.push(RankedMatch {
cpe_id: ancestor_cpe.cpe_id,
matched_name: matched_name.clone(),
// ... other fields ...
});
// Update dedup set so later Phase 2b rows also see this entry
existing.insert((ancestor_cpe.cpe_id, matched_name));- Ensure
matched_namein the insert matches the field used in the struct (matched_nameor whatever the field is actually called). This guarantees that duplicates produced solely within Phase 2b are filtered out, not just duplicates against Phases 1/2a.
Description
Backport of #2412 to
release/0.5.z.Summary by Sourcery
Materialize SBOM ancestor and describing CPE relationships to support faster and more reliable latest-SBOM analysis, refactor CPE resolution and graph loading to use these tables and bounded concurrency, and align APIs, tests, and dependencies with the new model.
New Features:
Bug Fixes:
Enhancements:
Build:
Documentation:
Tests:
Chores: