feat(graphql): InfrahubRepositoryBranchStatus per-branch attribute read from the graph (IFC-3127)
Closes IFC-3127. PR 3 of 5 under
IFC-3104. Base: cross-branch-repo-status-infp-671.
Replaces the fabricated attribute values from increment A with a real per-branch read through a new
core primitive, makes the three value filters real, and deletes the stub. The GraphQL contract
does not change beyond dropping the "preview" suffix from the field description.
What landed
RepositoryBranchAttributesQueryinbackend/infrahub/core/query/repository.py: one statement
resolving N branches x M attributes for one or more repositories, with the per-branch visibility
predicate and the standard election order.RepositoryBranchAttributesReaderinbackend/infrahub/core/repository_branch_status/reader.py,
behind the existingRepositoryBranchAttributesSourceprotocol.- Resolver swapped from
stub.py(deleted) to the reader;apply_value_filterswired in so
sync_status__value,internal_status__valueandown_values_onlynarrow rows andcount
server-side. - A
Branch(name)RANGE index (see Performance). atis now rejected on this field (see Behaviour notes).- 119 tests, a query benchmark, two
dev/knowledgepages, a docs section, two changelog fragments.
Sign-off requested
- Database: one new index declaration.
node_indexesis declarative and applied by
index_manager.initat startup and byinfrahub db index, so no numbered migration and existing
deployments self-heal on restart. - GraphQL: no SDL change other than the field description.
schema/schema.graphqland the
frontend generated types are regenerated and committed.
Performance: the Branch(name) index
The statement opens UNWIND $branch_names AS branch_name MATCH (br:Branch {name: branch_name}).
There was no Branch entry in backend/infrahub/core/graph/index.py, so that compiled to
NodeByLabelScan under an Apply, rescanning every Branch node per unwound name: O(branch_names
x Branch-node-count), quadratic in branch-set size.
Measured at 200 branches, median of 30 timed runs after warmup, A/B/A/B:
| branches | without index | with index |
|---|---|---|
| 5 | 3.2 ms | 2.1 ms |
| 50 | 8.9 ms | 5.7 ms |
| 200 | 30.0 ms | 22.3 ms |
Server-only time at 200 branches: 13 ms to 6 ms. PROFILE db-hits: 118,600 to 38,000, the
whole delta being NodeByLabelScan (40,600) plus Filter br.name = branch_name (40,400)
collapsing into a single NodeIndexSeek at 400. The client-side ratio is diluted by ~16 ms of
Python hydration for 1200 rows, which no index touches.
The index does not measurably help Branch.get_by_name on the registry cache-miss path
(db-hits 428 to 24, wall-clock unmoved) - that lookup is round-trip-bound. It is justified by this
query alone.
EXPLAIN plan (T034), 200 branches, 1 repository, 3 attributes
All four criteria pass:
Branch.nameusesNodeIndexSeek(this failed before the index)Node.uuidusesNodeIndexSeek- no
CartesianProduct - no eager operator between the
UNWINDand the subqueries
+ ProduceResults@neo4j repository_id, branch_name, attribute_name, attribute_id, value, value_branch, updated_at
+ Projection@neo4j cache[a.uuid] AS attribute_id, cache[r_value.branch] AS value_branch, cache[n.uuid] AS repository_id, av.value AS value, cache[r_value.from] AS updated_at, cache[a.name] AS attribute_name
+ CacheProperties@neo4j cache[a.name], cache[a.uuid]
+ Filter@neo4j cache[r_value.status] = $autostring_1
+ Apply@neo4j
+ Filter@neo4j cache[r.status] = $autostring_0
+ Apply@neo4j
+ Distinct@neo4j n, a, branch_name, default_window
+ Filter@neo4j (a:Attribute AND cache[a.name] IN $attribute_names)
+ Expand(All)@neo4j (n)-[:HAS_ATTRIBUTE]->(a)
+ Apply@neo4j
+ Projection@neo4j CASE
WHEN br.is_isolated THEN br.branched_from
ELSE $at
END AS default_window
+ Apply@neo4j
+ Unwind@neo4j $branch_names AS branch_name
+ NodeIndexSeek@neo4j RANGE INDEX br:Branch(name) WHERE name = branch_name
+ NodeIndexSeek@neo4j RANGE INDEX n:Node(uuid) WHERE uuid = $repository_ids[0], cache[n.uuid]
+ Top@neo4j `r.branch_level` DESC, `r.from` DESC, `r.status` ASC LIMIT 1
+ Projection@neo4j cache[r.branch_level] AS `r.branch_level`, cache[r.from] AS `r.from`, cache[r.status] AS `r.status`
+ CacheProperties@neo4j cache[r.branch_level], cache[r.from], cache[r.status]
+ Distinct@neo4j n, a, branch_name, r, default_window
+ Union@neo4j
+ Union@neo4j
+ Union@neo4j
+ Filter@neo4j n = anon_9 AND a = anon_10 AND (r.to IS NULL AND cache[r.from] <= $at)
+ DirectedRelationshipIndexSeek@neo4j RANGE INDEX (anon_9)-[r:HAS_ATTRIBUTE(branch)]->(anon_10) WHERE branch IN [branch_name, $global_branch_name]
+ Filter@neo4j cache[r.from] <= $at AND n = anon_19 AND a = anon_20 AND r.to > $at
+ DirectedRelationshipIndexSeek@neo4j RANGE INDEX (anon_19)-[r:HAS_ATTRIBUTE(branch)]->(anon_20) WHERE branch IN [branch_name, $global_branch_name]
+ Filter@neo4j NOT branch_name = $default_branch_name AND cache[r.from] <= default_window AND n = anon_29 AND a = anon_30 AND r.to IS NULL
+ DirectedRelationshipIndexSeek@neo4j RANGE INDEX (anon_29)-[r:HAS_ATTRIBUTE(branch)]->(anon_30) WHERE branch = $default_branch_name
+ Filter@neo4j NOT branch_name = $default_branch_name AND cache[r.from] <= default_window AND n = anon_39 AND a = anon_40 AND r.to > default_window
+ DirectedRelationshipIndexSeek@neo4j RANGE INDEX (anon_39)-[r:HAS_ATTRIBUTE(branch)]->(anon_40) WHERE branch = $default_branch_name
+ Top@neo4j `r_value.branch_level` DESC, `r_value.from` DESC, `r_value.status` ASC LIMIT 1
+ Projection@neo4j cache[r_value.branch_level] AS `r_value.branch_level`, cache[r_value.from] AS `r_value.from`, cache[r_value.status] AS `r_value.status`
+ CacheProperties@neo4j cache[r_value.branch_level], cache[r_value.from], cache[r_value.status]
+ Filter@neo4j av:AttributeValue
+ Distinct@neo4j default_window, a, r_value, av, branch_name
+ Union@neo4j
+ Union@neo4j
+ Union@neo4j
+ Filter@neo4j a = anon_67 AND (r_value.to IS NULL AND cache[r_value.from] <= $at)
+ DirectedRelationshipIndexSeek@neo4j RANGE INDEX (anon_67)-[r_value:HAS_VALUE(branch)]->(av) WHERE branch IN [branch_name, $global_branch_name], cache[r_value.branch]
+ Filter@neo4j cache[r_value.from] <= $at AND a = anon_72 AND r_value.to > $at
+ DirectedRelationshipIndexSeek@neo4j RANGE INDEX (anon_72)-[r_value:HAS_VALUE(branch)]->(av) WHERE branch IN [branch_name, $global_branch_name], cache[r_value.branch]
+ Filter@neo4j NOT branch_name = $default_branch_name AND cache[r_value.from] <= default_window AND a = anon_77 AND r_value.to IS NULL
+ DirectedRelationshipIndexSeek@neo4j RANGE INDEX (anon_77)-[r_value:HAS_VALUE(branch)]->(av) WHERE branch = $default_branch_name, cache[r_value.branch]
+ Filter@neo4j NOT branch_name = $default_branch_name AND cache[r_value.from] <= default_window AND a = anon_82 AND r_value.to > default_window
+ DirectedRelationshipIndexSeek@neo4j RANGE INDEX (anon_82)-[r_value:HAS_VALUE(branch)]->(av) WHERE branch = $default_branch_name, cache[r_value.branch]
Error-path review (T046)
Audited against dev/guidelines/backend/exceptions.md:
backend/infrahub/core/repository_branch_status/, backend/infrahub/core/query/repository.py,
backend/infrahub/graphql/queries/repository_branch_status/.
1. except clauses - PASS. The only except clause in the whole slice is
permissions.py:61, catching exactly InitializationError and re-raising PermissionDeniedError from exc after a log.warning. The other grep hits are from infrahub.exceptions import ...
lines, not handlers. No bare except, no except Exception, no # noqa: BLE001, no try around
code that cannot raise. core/query/repository.py and every module under
core/repository_branch_status/ contain no except at all.
2 and 3. Every raise, its class, and the test that provokes it:
| # | Site | Class | Infrahub Error subclass? | Provoking test |
|---|---|---|---|---|
| 1 | core/repository_branch_status/models.py:38 | ValueError | No - see note | unit/.../test_repository_branch_status.py::TestRepositoryBranchAttributes::test_duplicate_triple_raises |
| 2 | kind_dispatch.py:66 | ValidationError | Yes | unit/...::TestPolicyForKind::test_unsupported_kind_names_the_kind |
| 3 | permissions.py:43 | PermissionDeniedError | Yes | component/...::TestRepositoryBranchStatusPermissions::test_view_permission_matrix[default-branch-no-grant], [user-branch-no-grant], ::test_denial_runs_no_database_query, ::test_anonymous_session_without_a_grant_is_denied |
| 4 | permissions.py:56 | PermissionDeniedError | Yes | component/...::test_a_grant_on_one_kind_does_not_cover_the_other_kind |
| 5 | permissions.py:67 | PermissionDeniedError | Yes | component/...::test_context_without_loaded_permissions_is_denied |
| 6 | resolver.py:57 (at) | ValidationError | Yes | component/...::test_a_request_for_a_past_point_in_time_is_rejected (negative control: ::test_a_request_carrying_no_at_parameter_is_accepted) |
| 7 | resolver.py:117 (limit) | ValidationError | Yes | component/...::test_invalid_paging_arguments_are_rejected[limit-below-one], [explicit-null-limit] |
| 8 | resolver.py:119 (offset) | ValidationError | Yes | component/...::test_invalid_paging_arguments_are_rejected[negative-offset], [explicit-null-offset] |
| 9 | resolver.py:141 (non-repository node) | NodeNotFoundError | Yes | component/...::test_a_node_of_another_kind_is_reported_as_not_found |
core/query/repository.py raises nothing of its own; nor do reader.py, interface.py,
field.py, paging.py, payload.py.
Two errors propagated unchanged rather than raised here, both covered: NodeNotFoundError from the
repository lookup (::test_unknown_repository_is_reported_as_not_found) and ValidationError from
standard_node_ordering_from_order_input (::test_contradictory_order_is_rejected).
Deviation, row 1. RepositoryBranchAttributes.from_values raises a plain ValueError on a
duplicate (repository, branch, attribute) triple, deliberately not an Infrahub Error. The
Cypher makes the duplicate impossible (WITH DISTINCT before the election, LIMIT 1 in each
subquery), so reaching it is a server-side data-integrity bug; an Error subclass would render it
to the client as a clean, actionable message with data: null as though the caller had erred.
Reviewer input welcome - this is a knowing deviation from T046's wording.
4. Miss renders null, database errors propagate - PASS. build_attribute_payload returns
None for a None value, which serialises as GraphQL null; covered end to end by
::test_a_repository_that_was_never_imported_returns_rows_without_a_commit and
::test_ref_is_null_on_the_read_write_kind_and_set_on_the_read_only_kind. No fabricated value at
any layer. There is no handler on the read path at all, so driver errors reach the GraphQL error
formatter unmodified.
Behaviour notes
atis rejected on this field. The rows come fromBranch.get_list, which takes noat, so
honouring?at=would pair a current branch set with historical values: rows for branches that
did not exist then, none for branches deleted since. The guard reads
request.query_params.get("at")rather thangraphql_context.at, because
prepare_graphql_paramscoerces viaTimestamp(at)andTimestamp(None)is now, so
graphql_context.atis neverNone. Theatparameter is still plumbed to the reader for
increment C.- Known limitation: the guard covers the
/graphqlendpoint. A savedCoreGraphQLQuery
fetched through/api/query/<name>?at=or the transformation endpoints passes norequestinto
prepare_graphql_params, so it would still return current branches with historical values. That
is not an expected way to run this query; it becomes its own issue if it ever comes up. - Value filters now widen the read by the attribute they compare, so
sync_status__valueworks
whether or not the document selectssync_status. This was a bug found in review: before the
fix, a filter without the matching field in the selection returnedcount: 0and no rows, with
no error. - For
CoreReadOnlyRepository,commitandrefareAWAREand land on the current branch at
creation, soown_values_onlyis true on the default branch without an import, and a branch
forked before the repository existed resolves nocommit/ref. Documented.
Testing
119 tests: 10 primitive component, 82 GraphQL component, 27 unit. Plus an opt-in query benchmark in
backend/tests/query_benchmark/. /pre-ci green; re-verified after the review fixes (2523 backend
unit tests, whole-repo ruff, mypy on 1670 files, all four generated-file validations, docs lint).
Quickstart B1 through B8 are covered, stub.py is deleted, and the query count for one page is
equal at 5 and 200 branches.
Known gaps, deferred deliberately
Recorded in dev/specs/infp-671-cross-branch-repo-status/opsmill-implement-report-phase4.md:
- No value coverage for
CoreReadOnlyRepository, whoseAWAREcommit/reftake a different
edge-placement path. The most likely place for a surprise. - The differential test cannot catch a strict/non-strict boundary flip, because every fixture
timestamp is strictly ordered. default_windowomits theat > branched_fromguard thatget_branches_and_times_to_query_global
applies; unreachable from GraphQL now thatatis rejected, but the primitive still accepts one.- No explicit ceiling on the result set:
self.limitsteers into the single-shot branch, so
query_with_size_limitchunking never applies. - No coverage for a soft-deleted value or repository.
Not done
T047's acceptance names "Jira subtask from T032 closed". No such issue exists - IFC-3104's
children are IFC-3125 through IFC-3132. T032 asked for a "Remove InfrahubRepositoryBranchStatus
stub" task that was never created. Moot in substance, since the stub is deleted in this PR.