fix(number-pool): count a scoped number only in the divisions of its branch [IFC-3447]

Merged
AJ
6d ago
+574-192

Human summary

the number pool queries were not correctly correlating scope values with pool values. the example from the Jira issue makes it clear.

  1. A number pool allocates vlan_id for devices and uses the relationship to the site as its allocation scope.
  2. On branch main, device D1 is in site A and has vlan_id 1.
  3. On branch br2, device D1 is in site B and has vlan_id 2.

D1 counts as holding the values 1 and 2 in the division for site A and in the division for site B.
D1 does not have vlan_id 1 in site B on any single branch.
D1 does not have vlan_id 2 in site A on any single branch.

basically, we were not correctly linking the branches for active pool scope elements with active values attached to the pool. the fix is a bunch of cypher changes to pass branches around and compare lists of branches. it is not easy to read. I tried to find a middle ground between minimizing the repeated parts of the queries and making them readable in code and I don't know if I succeeded.

I also decided to add some performance improvements to this branch b/c it already changes the query shapes significantly, so I thought that adding more changes now would make it easier to review overall (1 cypher-heavy PR instead of 2).

Why

A number pool divided by site counted a device under sites it does not belong to when the device had a different site and a different number on two branches. On main, device D1 is in site A with VLAN ID 1; on branch br2, D1 is in site B with VLAN ID 2. Both numbers counted in both sites, so allocation skipped free numbers and the usage reported for each site was too high.

Closes IFC-3447

What changed

Behavioral changes:

  • A number counts in a division only when one live branch both holds the number and gives the device the division's value for every scope element. Before, each scope element could match on any live branch, independently of the branch holding the number.
  • A number that a branch inherits from the default branch counts on that branch. A device moved to another site on a branch, keeping its number, still counts in both sites.
  • When only an inheriting branch puts the device in a division, the allocated numbers of that division list the number on that branch, and the division's figures count it in used_branches, not used_default_branch.
  • A reservation is reused only when the device is in the division on the branch of the request.
  • Known limit: if one branch changes a device's number and another branch moves the device, merging both branches can give two devices the same number in one division. This will be caught be uniqueness constraints if they are correctly configured for the schema.

Implementation notes:

  • Each edge gives the list of live branches it is visible from. The lists are intersected one scope element at a time, and a device is dropped at the first element that leaves no branch.
  • The query text has one stage per scope element, so every subquery stays in the query plan (checked with EXPLAIN).
  • The list of divisions reads the divisions of each attribute once, not once per value.

Performance changes (commits 5d853b4d8, 22be5f8ce, 19727a7a8, 61d4f90b5):

  • The free, used and allocated queries with a division first read, once, the nodes that ever held one of the division's values. They then run the per-branch division check only on the reservations of those nodes.
    • The set starts from the first relationship element with a value. When the division has none, it starts from the first attribute element with a value whose kind is indexed (AttributeValueIndexed), limited to the pool's node kind.
    • A TextArea attribute element is not indexed, so the query checks it on each reserved attribute.
    • NumberPoolGetReserved and the branch-specific division check do not use the set, because they check a single attribute.
  • The branch list of each edge reads the edge's branch, from and to properties once, not once for each live branch.
  • A scope attribute that a node reaches through several HAS_ATTRIBUTE edges is read once.
  • Loading nodes parses each display-label Jinja2 template once per template text, not once per node. This change is outside the number-pool code.

What stayed the same:

  • No schema, GraphQL or API changes.
  • The queries without a division filter render the same Cypher as before, apart from one empty line.
  • No new index: the candidate set uses the existing indexes on AttributeValueIndexed.

How to review

  • backend/infrahub/core/query/resource_manager.py: the shared fragments (EDGE_IS_LIVE, EDGE_BRANCHES, ATTRIBUTE_ELEMENT_VALUES, RELATIONSHIP_ELEMENT_VALUES, HELD_BRANCH), then DIVISION_FILTER_QUERY with DIVISION_ELEMENT_STAGE, then LIVE_DIVISIONS_QUERY with LIVE_DIVISION_ELEMENT_STAGE.
  • Changed test expectations follow one pattern: a number that counts in a division only through an inheriting branch is reported on that branch. Expectations from #11239 (IFC-3446) that listed a number on a branch where the device is in another division are removed.
  • The candidate set: _candidate_set_index, division_candidates_query and _division_candidate_filter in resource_manager.py, and DivisionElementPath.value_indexed in backend/infrahub/pools/scope.py.
  • backend/infrahub/core/node/node_property_attribute.py: the display-label template cache.

Impact & rollout

  • Backward compatibility: allocation scopes are not released yet (1.12); no migration.

  • Performance: measured on a dev VM running Neo4j 2026.05 with 8,000 devices in four pools on ScopeDevice.vlan_id (unscoped; by site; by site and role; by role and status) and changes on 10 branches. Median on main, ab25d2919 → this PR:

    QueryPoolTimeDatabase hits
    Free number in a divisionsite64.3 → 14.8 ms172k → 27k
    Free number in a divisionrole and status59.5 → 20.8 ms241k → 98k
    Used numbers in a divisionsite and role67.8 → 9.8 ms177k → 29k
    Allocated numbers in a division (count and page of 1,000)site136.6 → 27.6 ms347k → 57k
    Divisions listrole and status98.7 → 87.0 ms390k → 260k
    Device create that takes a number from the poolsite78.6 → 25.8 ms—
    Device create that takes a number from the poolunscoped19.9 → 19.1 ms—

    Queries without a division have the same database hits as before. The division results are identical to ab25d2919 on this dataset. The measurement scripts are not committed, and no test counts database hits.

  • Known limits of the performance changes: a TextArea scope element has no candidate set and no test covers that path.

Checklist

  • Tests added/updated
  • Changelog entry added: not needed for the number-pool changes, allocation scopes are unreleased in 1.12. The display-label template cache has no entry yet.
  • External docs updated: not yet written for allocation scopes (spec task T042)
  • Internal .md docs updated (spec)
  • I have reviewed AI generated content

🤖 Generated with Claude Code