Skip to content

Guard component/version/license upserts against concurrent insert races - #416

Merged
haksungjang merged 2 commits into
mainfrom
fix/398a-component-license-insert-race
Sep 6, 2026
Merged

Guard component/version/license upserts against concurrent insert races#416
haksungjang merged 2 commits into
mainfrom
fix/398a-component-license-insert-race

Conversation

@haksungjang

Copy link
Copy Markdown
Contributor

Summary

_get_or_create_component, _get_or_create_component_version, and
_get_or_create_license in tasks/scan_source.py each did a plain
"SELECT, then INSERT if missing". persist_sbom_components runs the
entire SBOM walk for a scan inside one transaction, committed once at
the end. Two scans hitting the same purl or SPDX id at the same time
both miss the lookup and both insert; the loser's flush trips the
unique constraint (components.purl, component_versions.purl_with_version,
or licenses.spdx_id), Postgres aborts the transaction, and nothing
caught it, so the whole transaction was lost, including every
component/version/license the loop had already staged for that scan,
not just the one that collided.

This is the same shape PR #290 fixed for the vulnerability catalog
(ER8): the insert now runs inside a SAVEPOINT (session.begin_nested()),
and on a unique violation only that one statement is rolled back before
re-fetching the winner's row, so the rest of the caller's transaction
survives intact.

This is scoped to the race-safety fix only, not the batch-insert
performance work tracked separately in #398.

Test plan

  • New tests/integration/scan/test_component_license_insert_race.py
    (3 tests) reproduces the race against a real Postgres for all three
    helpers: a second session holds an uncommitted insert open on the
    target purl/spdx_id while the first session, having already staged
    an unrelated row in the same transaction, tries to upsert the same
    identity. Verified each test fails against the pre-fix code with an
    unhandled IntegrityError and passes after the fix, with exactly
    one row surviving and the row staged before the race still committed.
  • Updated tests/unit/tasks/test_license_self_heal.py's fake session
    to support begin_nested() (the "new license" branch now opens one).
  • tests/unit/tasks (945 tests via the broader run) and
    tests/integration/scan (all scan pipeline tests, including F-2
    dedup, F-3 sanitisation, EOL/malicious stamping) pass unchanged.
  • Coverage on tasks/scan_source.py: 92% (target 80%).

Refs #398

persist_sbom_components runs the whole SBOM walk in one transaction, so
two scans hitting the same purl or SPDX id at once make the loser's
flush trip a unique constraint and abort the transaction, discarding
every row staged ahead of it. The three get-or-create helpers now run
their insert in a SAVEPOINT and re-fetch the winner on a unique
violation, the same shape #290 gave the vulnerability catalog.

Refs #398-A
session.get() returns Component | None; reusing the loop-scoped component name for it left mypy inferring the earlier non-optional type on reassignment.
@haksungjang
haksungjang merged commit 1aa4288 into main Sep 6, 2026
25 checks passed
@haksungjang
haksungjang deleted the fix/398a-component-license-insert-race branch September 6, 2026 23:24
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