Skip to content

scope uniqueness constraints to affected objects - #10019

Merged
ajtmccarty merged 22 commits into
ifc-2796-node-scope-uniqueness-fullfrom
ifc-2796-node-scope-uniqueness-2
Jul 27, 2026
Merged

scope uniqueness constraints to affected objects#10019
ajtmccarty merged 22 commits into
ifc-2796-node-scope-uniqueness-fullfrom
ifc-2796-node-scope-uniqueness-2

Conversation

@ajtmccarty

@ajtmccarty ajtmccarty commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Why

Uniqueness validation scanned the entire population of a kind on every data change. Merging, rebasing, or proposing a change that touched a handful of nodes of a large kind still ran a population-wide scan — slow on big kinds (hundreds of thousands of nodes) and it shipped large duplicate result sets back to Python.

Goal: scope a data-triggered uniqueness check to only the nodes the diff actually affects, so the work is proportional to the size of the change rather than the size of the kind.

Non-goals: this does not change node-save-time uniqueness (grouped_uniqueness), does not touch other constraint validators, and does not change the uniqueness error surfaced to users.

Note: uniqueness constraints do not support attribute of relationships (e.g. "device__name"). some parts of the code support this kind of uniqueness constraint, but not all of it and it is still blocked when creating/updating a schema.

Planned follow-ups

  • refactor MergeConstraintValidator construction. schema_branch should probably be an argument for this class and its dependents, not a dependency
  • check on integration/functional-level testing for uniqueness constraint validation and add more if necessary
  • issue for supporting attributes on relationships in uniqueness constraints

Part of IFC-2796 (epic IFC-2706).

What changed

Behavioral

  • Data-triggered uniqueness validation (merge, rebase, proposed-change integrity) now validates only the changed/affected nodes via a batched, index-anchored targeted query instead of a full-population scan.
  • A uniqueness check is scoped to exactly the nodes whose changed field participates in the constraint — changing one node's unique field no longer drags in every other changed node of the kind.
  • A newly added or broadened constraint (schema-diff origin, with no affected-node set) still runs the full-population scan, as before.
  • Cross-kind constraints that read a peer's attribute (e.g. owner__name) fall back to full-population validation, which supports them; the targeted query does not. Again, this type of uniqueness constraint cannot be added to schemas currently.
  • Uniqueness error messages are unchanged — attribute values render identically to the full-population path regardless of their type.

Implementation notes

  • A node_uuids carrier is threaded end to end: None means full scan, a list means validate exactly those nodes. It rides from the diff summary through the determiner, the constraint info, and the validator request into the checker.
  • NodeDiffFieldSummary/NodeDiffIndex now keep the correlation between each changed field and the nodes that changed it (per-field UUID maps), so scoping targets a specific field's nodes rather than every changed node of the kind.
  • New here (constructor-injected, wired by build_constraint_validator_determiner): UniquenessConstraintScoper decides, per kind, whether uniqueness must run and which nodes it affects (None → full scan); the ConstraintValidatorDeterminer update threads the affected set through; constraint dedup/merge helpers collapse a node's uniqueness check into its generic's where the generic already covers it.
  • UniquenessChecker now runs the targeted query for a scoped set, and falls back to the full-population scan when a constraint reads a peer attribute.

What stayed the same

  • The full-population path and the node-save-time uniqueness checker (grouped_uniqueness) are untouched.
  • No GraphQL/API contract changes, no database schema or migration changes.

Suggested review order

  1. core/diff/model/path.py, core/diff/query/field_summary.py, core/validators/node_diff_index.py — how per-field changed-node UUIDs are captured.
  2. core/validators/uniqueness/scope.py + core/validators/determiner.py — how the affected-node set (or None) is decided.
  3. core/validators/uniqueness/checker.py — running the targeted query for a scoped set vs the full-population fallback.
  4. proposed_change/tasks.py, merge/constraints.py, branch/tasks.py — the three call sites that thread the affected set through.
  5. Tests.

How to review

Focus on the scope decision (UniquenessConstraintScoper._compute_scope) and the checker's targeted-vs-full-population branch (UniquenessChecker.check / _supports_targeted) — those govern correctness. Mechanical: the attribute_node_uuids conversions across the test files.

Impact & rollout

  • Backward compatibility: no breaking changes; uniqueness error messages are identical to before.
  • Performance: the headline win. On a large local dataset (≈500k-node kind) the full-population scan ran 8+ minutes before OOM-killing Neo4j; the targeted query validates ~9,400 changed nodes of that kind in ~12s and ~23,500 in ~29s, and all implicated kinds complete in tens of seconds. Work is now bounded by the change, not the population.
  • Config/env changes: none.
  • Deployment notes: safe to deploy; no coordinated release or migration required.

Checklist

  • Tests added/updated
  • Changelog entry added
  • External docs updated (no user-facing surface change)
  • Internal .md docs updated (internal knowledge and AI code tools knowledge)
  • I have reviewed AI generated content

Summary by cubic

Scope node-level uniqueness validation to only the nodes that changed each unique field (and their dependents), using diff-derived IDs and batched, index-backed queries. Full scans now run only when a constraint is added/broadened or when peer-attribute elements are present, cutting worst-case merge and schema-update time (IFC-2796).

  • Bug Fixes

    • Fall back to full-population validation for peer-attribute paths (e.g., owner__name) unsupported by the targeted query.
    • Reset scoper cache on determiner re-init to prevent stale scopes between diffs.
    • Stringify targeted values in error paths to match full-scan messages.
  • Refactors

    • Batch checks per constraint group via TargetedUniquenessValidationQuery, paging over changed nodes with one read-only session per change.
    • UniquenessChecker reads branch/schema from the validator request; dependency builder and m018 migration updated.
    • Carry node_uuids through SchemaUpdateConstraintInfo and validator requests across proposed-change, merge, rebase, and migrations; merge/dedup constraints and standardize on ConstraintIdentifier.NODE_UNIQUENESS_CONSTRAINTS_UPDATE.
    • Consolidate chunking into infrahub.utilities.chunks.chunked and replace local helpers.
    • Refactor UniquenessConstraintScoper for clarity; trim docstrings.

Written for commit b7c6994. Summary will update on new commits.

Review in cubic

ajtmccarty and others added 11 commits July 22, 2026 16:30
…niquenessChecker

Replace the per-node targeted uniqueness path with a single batched
TargetedUniquenessValidationQuery per constraint group, paged over the changed
nodes. Take branch and schema_branch from the validator request rather than the
constructor, and open one read-only session per change instead of per query.

Update the dependency builder and the m018 migration for the new signatures, and
switch the node-scoped tests to exact violation-set assertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the group/backend Issue related to the backend (API Server, Git Agent) label Jul 23, 2026

@cubic-dev-ai cubic-dev-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.

0 issues found across 1 file (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Optimizes uniqueness validation by scoping checks to only the nodes affected by data diffs, reducing unnecessary full scans. All changes are implementation-level; cubic reviewed and found no issues.

Re-trigger cubic

@codspeed-hq

codspeed-hq Bot commented Jul 23, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 30.77%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
✅ 10 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
test_base_schema_duplicate_CoreProposedChange 2.2 ms 1.5 ms +49.4%
test_schemabranch_duplicate 7.7 ms 6.7 ms +14.46%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing ifc-2796-node-scope-uniqueness-2 (b7c6994) with stable (0ad9131)1

Open in CodSpeed

Footnotes

  1. No successful run was found on ifc-2796-node-scope-uniqueness-full (517c77a) during the generation of this report, so stable (0ad9131) was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@cubic-dev-ai cubic-dev-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.

All reported issues were addressed

Shadow auto-approve: would not auto-approve because issues were found.

Re-trigger cubic

Comment thread backend/infrahub/proposed_change/tasks.py Outdated
Comment thread backend/infrahub/core/validators/determiner.py
Comment thread backend/infrahub/core/diff/query/field_summary.py Outdated
self, candidate_schema: SchemaBranch, schema_diff_constraints: list[SchemaUpdateConstraintInfo]
) -> MergeConstraintValidationResult:
determiner = ConstraintValidatorDeterminer(schema_branch=candidate_schema)
determiner = build_constraint_validator_determiner(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this should be built as part of construction, but the dependencies require schema_branch which is not ready until runtime. I think the right solution is to move schema_branch from dependency to runtime argument for all of these, but I'm going to do that separately

ajtmccarty and others added 5 commits July 22, 2026 20:57
The constraint result model gained a node_uuids field, so model_dump()
now emits 'node_uuids': None. Update the two schema-validate tests whose
hardcoded expected dicts predate the field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The targeted validation path emitted attribute values with their native
graph type, so an integer attribute rendered as value=8 while the
full-population path rendered '8'. Stringify the value when building the
data path so error messages are identical across both paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The determiner re-initializes its node-diff index on each get_constraints
call, but the uniqueness scoper's per-kind scope cache was never cleared,
so a reused determiner could return scopes computed from a previous diff.
Add a reset() on the scoper and call it alongside index initialization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The batched targeted query cannot read an attribute of a related peer
(a constraint element such as owner__name) and raises on one. Detect
such constraints and validate the whole population for that schema
instead of scoping to the changed nodes, since the full-population path
supports them. Removes the xfail markers on the two peer-attribute tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The diff field summary collapsed all changed node uuids per kind, so
changing one node's unique field validated every changed node of that
kind. Track uuids per changed field through NodeDiffFieldSummary, the
diff query, NodeDiffIndex, and the uniqueness scoper, so a uniqueness
check is scoped to only the nodes whose changed field participates in it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-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.

0 issues found across 14 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Scopes uniqueness validation to affected nodes per diff, with fallback to full scan for unsupported paths; a bounded, clearly beneficial performance optimization. Implementation verified by Cubic.

Re-trigger cubic

@ajtmccarty
ajtmccarty marked this pull request as ready for review July 23, 2026 05:17
@ajtmccarty
ajtmccarty requested a review from a team as a code owner July 23, 2026 05:17
return constraints


def build_constraint_validator_determiner(

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.

only matter of taste but I like this pattern when we need this factory functions for collocation and less imports. Would it be possible here?

class ConstraintValidatorDeterminer
   @classmethod
    def from_(cls, ...) -> "ConstraintValidatorDeterminer":
        ....
        return cls(....)

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.

@polmichel explained that this is intentional and we prefer this pattern to separate concerns

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.

My opinion is that we'd rather limit these class methods to prevent increasing class responsibilities. A small component or method can take care of the object instantiation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes I agree with Pol here. constructing the class is not the responsibility of the class

self,
db: InfrahubDatabase,
max_concurrent_execution: int = 5,
query_batch_size: int = 500,

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.

I think a comment on how we decided to be 500, even if its a heurestic would be useful for the future

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

small comment added

def merge(self, *constraint_lists: list[SchemaUpdateConstraintInfo]) -> list[SchemaUpdateConstraintInfo]:
# Collapse the same constraint from multiple producers onto one entry. A constraint both
# broadened by a schema change (full population) and hit by a data change (node-scoped) must
# re-check every node, so a None node_uuids always wins; two node-scoped entries union.

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.

should this be a docstring?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes it should. updated


def _chunked(items: list[str], size: int) -> Iterator[list[str]]:
for start in range(0, len(items), size):
yield items[start : start + size]

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added consolidated chunks utility in 25fbe7f

@cubic-dev-ai cubic-dev-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.

All reported issues were addressed across 10 files (changes from recent commits).

Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread PR_DESCRIPTION.md Outdated

@polmichel polmichel 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.

Great PR and feature. Nice use of dependency injection, which makes the code readable and maintainable. The tests aren't all integration tests, and in my opinion that's a sign the design is good.

A few optional comments.

Comment on lines 104 to +111
class NodeDiffFieldSummary:
"""Which fields changed for a kind, and which nodes changed each one.

The per-field maps keep the correlation between a changed field and the nodes that changed it,
so a consumer can scope work to the nodes touching a specific field rather than every changed
node of the kind. ``attribute_names``/``relationship_names``/``node_uuids`` are derived views.
"""

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.

This looks pretty useful, I'll keep this data structure in mind.

Comment on lines +82 to +86
node_uuids |= await self.dependent_resolver.resolve(
node_kind=schema.kind,
relationship_identifier=peer_change.relationship_identifier,
peer_uuids=sorted(peer_change.changed_peer_uuids),
)

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.

DependentResolver object usage is clearer now. Nice injection

Comment on lines +92 to +97
"""Return the diffed kinds where `field_name` changed, on `schema` or a kind that inherits it.

An inherited field keeps its name on the implementing kind, so a generic-level constraint
is implicated when an implementation's copy of the field is what changed in the diff. A
generic knows its implementing kinds through `used_by`; the diff check then keeps only the
kinds actually present in the diff.

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.

Not sure whether we should keep the last sentence since the used_by is an implementation detail, which should be documented on its own.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good point. trimmed the last sentence

Comment on lines +128 to +136
def _compute_scope(self, schema: MainSchemaTypes) -> UniquenessScopeForKind:
"""Compute why and how a data change implicates `schema`'s uniqueness.

Uniqueness spans single unique attributes and multi-field constraint groups. A group
element such as "owner__name" reads an attribute of a related peer, so a data change on
the peer kind can create a violation without any change to the constrained kind itself:
that case yields a cross-kind peer change. Directly changed nodes of the constrained kind
are collected as object uuids.
"""

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.

This algorithm is really clean.

The method is a bit long though. From a readability perspective, would it be better to extract the two parts (unique_attributes / uniqueness_constraints) into private methods, each returning "UniquenessScopeFragment" objects? This method would then only assemble them and pass them to the UniquenessScopeForKind constructor. This would bring a better readability without having to know about the implementation details, and would give UniquenessScopeForKind a certain level immutability

Just an idea, feel free to ignore it or to do it differently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good suggestion. refactored and it is easier to read now

@cubic-dev-ai cubic-dev-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.

0 issues found across 1 file (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Performance optimization and refactor: scoped uniqueness validation to affected nodes instead of full-population scans. No user-facing changes, no schema/API changes, and falls back to full scan when unsupported.

Re-trigger cubic

@ajtmccarty
ajtmccarty merged commit a67223e into ifc-2796-node-scope-uniqueness-full Jul 27, 2026
58 checks passed
@ajtmccarty
ajtmccarty deleted the ifc-2796-node-scope-uniqueness-2 branch July 27, 2026 18:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

group/backend Issue related to the backend (API Server, Git Agent) review/release-1.11

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants