fix(number-pool): count a scoped number only in the divisions of its branch [IFC-3447]
Human summary
the number pool queries were not correctly correlating scope values with pool values. the example from the Jira issue makes it clear.
- A number pool allocates
vlan_idfor devices and uses the relationship to thesiteas its allocation scope. - On branch
main, device D1 is insiteA and hasvlan_id1. - On branch
br2, device D1 is insiteB and hasvlan_id2.
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, notused_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
TextAreaattribute element is not indexed, so the query checks it on each reserved attribute. NumberPoolGetReservedand the branch-specific division check do not use the set, because they check a single attribute.
- 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 (
- The branch list of each edge reads the edge's
branch,fromandtoproperties once, not once for each live branch. - A scope attribute that a node reaches through several
HAS_ATTRIBUTEedges 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), thenDIVISION_FILTER_QUERYwithDIVISION_ELEMENT_STAGE, thenLIVE_DIVISIONS_QUERYwithLIVE_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_queryand_division_candidate_filterinresource_manager.py, andDivisionElementPath.value_indexedinbackend/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 onmain,ab25d2919→ this PR:Query Pool Time Database hits Free number in a division site 64.3 → 14.8 ms 172k → 27k Free number in a division role and status 59.5 → 20.8 ms 241k → 98k Used numbers in a division site and role 67.8 → 9.8 ms 177k → 29k Allocated numbers in a division (count and page of 1,000) site 136.6 → 27.6 ms 347k → 57k Divisions list role and status 98.7 → 87.0 ms 390k → 260k Device create that takes a number from the pool site 78.6 → 25.8 ms — Device create that takes a number from the pool unscoped 19.9 → 19.1 ms — Queries without a division have the same database hits as before. The division results are identical to
ab25d2919on this dataset. The measurement scripts are not committed, and no test counts database hits. -
Known limits of the performance changes: a
TextAreascope 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